No files matched your search
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<title>LitePan</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "litepan-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build && node scripts/compress-build.mjs",
|
||||
"preview": "vite preview",
|
||||
"type-check": "vue-tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aiden0z/pptx-renderer": "^1.2.4",
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@panzoom/panzoom": "^4.6.2",
|
||||
"@zip.js/zip.js": "^2.8.15",
|
||||
"chardet": "^2.2.0",
|
||||
"docx-preview": "^0.4.0",
|
||||
"dompurify": "^3.4.12",
|
||||
"gsap": "^3.15.0",
|
||||
"heic-to": "^1.5.2",
|
||||
"hls.js": "^1.6.16",
|
||||
"libbitsub": "^1.10.1",
|
||||
"markdown-it": "^14.3.0",
|
||||
"markdown-it-anchor": "^9.2.1",
|
||||
"media-captions": "^0.0.18",
|
||||
"media-chrome": "^4.19.2",
|
||||
"mpegts.js": "^1.8.0",
|
||||
"pdfjs-dist": "^5.7.284",
|
||||
"pinia": "^3.0.4",
|
||||
"rtf.js": "^3.0.9",
|
||||
"vue": "^3.5.38",
|
||||
"vue-router": "^5.1.0",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^26.0.0",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.0",
|
||||
"vue-tsc": "^3.3.5"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 62 KiB |
@@ -0,0 +1,177 @@
|
||||
heic-to is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Lesser General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
heic-to is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Lesser General Public License for more details.
|
||||
|
||||
---------------------------------------------------------------
|
||||
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates
|
||||
the terms and conditions of version 3 of the GNU General Public
|
||||
License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License,
|
||||
other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided
|
||||
by the Library, but which is not otherwise based on the Library.
|
||||
Defining a subclass of a class defined by the Library is deemed a mode
|
||||
of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an
|
||||
Application with the Library. The particular version of the Library
|
||||
with which the Combined Work was made is also called the "Linked
|
||||
Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the
|
||||
Corresponding Source for the Combined Work, excluding any source code
|
||||
for portions of the Combined Work that, considered in isolation, are
|
||||
based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the
|
||||
object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under sections 3 and 4 of this License
|
||||
without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library, and, in your modifications, a
|
||||
facility refers to a function or data to be supplied by an Application
|
||||
that uses the facility (other than as an argument passed when the
|
||||
facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to
|
||||
ensure that, in the event an Application does not supply the
|
||||
function or data, the facility still operates, and performs
|
||||
whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of
|
||||
this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an Application may incorporate material from
|
||||
a header file that is part of the Library. You may convey such object
|
||||
code under terms of your choice, provided that, if the incorporated
|
||||
material is not limited to numerical parameters, data structure
|
||||
layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the
|
||||
Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
4. Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice that,
|
||||
taken together, effectively do not restrict modification of the
|
||||
portions of the Library contained in the Combined Work and reverse
|
||||
engineering for debugging such modifications, if you also do each of
|
||||
the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that
|
||||
the Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among
|
||||
these notices, as well as a reference directing the user to the
|
||||
copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this
|
||||
License, and the Corresponding Application Code in a form
|
||||
suitable for, and under terms that permit, the user to
|
||||
recombine or relink the Application with a modified version of
|
||||
the Linked Version to produce a modified Combined Work, in the
|
||||
manner specified by section 6 of the GNU GPL for conveying
|
||||
Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (a) uses at run time
|
||||
a copy of the Library already present on the user's computer
|
||||
system, and (b) will operate properly with a modified version
|
||||
of the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise
|
||||
be required to provide such information under section 6 of the
|
||||
GNU GPL, and only to the extent that such information is
|
||||
necessary to install and execute a modified version of the
|
||||
Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If
|
||||
you use option 4d0, the Installation Information must accompany
|
||||
the Minimal Corresponding Source and Corresponding Application
|
||||
Code. If you use option 4d1, you must provide the Installation
|
||||
Information in the manner specified by section 6 of the GNU GPL
|
||||
for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place library facilities that are a work based on the
|
||||
Library side by side in a single library together with other library
|
||||
facilities that are not Applications and are not covered by this
|
||||
License, and convey such a combined library under terms of your
|
||||
choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based
|
||||
on the Library, uncombined with any other library facilities,
|
||||
conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it
|
||||
is a work based on the Library, and explaining where to find the
|
||||
accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Library as you received it specifies that a certain numbered version
|
||||
of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and
|
||||
conditions either of that published version or of any later version
|
||||
published by the Free Software Foundation. If the Library as you
|
||||
received it does not specify a version number of the GNU Lesser
|
||||
General Public License, you may choose any version of the GNU Lesser
|
||||
General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide
|
||||
whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is
|
||||
permanent authorization for you to choose that version for the
|
||||
Library.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Jaruwit Jaiya
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,56 @@
|
||||
import { constants as zlibConstants, gzip } from "node:zlib";
|
||||
import { promisify } from "node:util";
|
||||
import { readdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import { extname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const gzipAsync = promisify(gzip);
|
||||
const outputDir = fileURLToPath(new URL("../../internal/api/web/", import.meta.url));
|
||||
const compressibleExtensions = new Set([".css", ".js", ".mjs", ".svg", ".ttf", ".wasm"]);
|
||||
|
||||
async function collectFiles(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const file = join(directory, entry.name);
|
||||
if (entry.isDirectory()) files.push(...await collectFiles(file));
|
||||
else files.push(file);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
let originalBytes = 0;
|
||||
let embeddedBytes = 0;
|
||||
let compressedFiles = 0;
|
||||
|
||||
for (const file of await collectFiles(outputDir)) {
|
||||
if (file.endsWith(".DS_Store")) {
|
||||
await unlink(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
const info = await stat(file);
|
||||
originalBytes += info.size;
|
||||
if (!compressibleExtensions.has(extname(file))) {
|
||||
embeddedBytes += info.size;
|
||||
continue;
|
||||
}
|
||||
|
||||
const source = await readFile(file);
|
||||
const compressed = await gzipAsync(source, {
|
||||
level: zlibConstants.Z_BEST_COMPRESSION,
|
||||
mtime: 0,
|
||||
});
|
||||
if (compressed.length >= source.length) {
|
||||
embeddedBytes += source.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
await writeFile(`${file}.gz`, compressed);
|
||||
await unlink(file);
|
||||
compressedFiles += 1;
|
||||
embeddedBytes += compressed.length;
|
||||
}
|
||||
|
||||
const saved = originalBytes - embeddedBytes;
|
||||
console.log(`内嵌资源压缩完成:${compressedFiles} 个文件,减少 ${(saved / 1024 / 1024).toFixed(2)} MB`);
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from "vue-router";
|
||||
import ToastHost from "@/components/base/ToastHost.vue";
|
||||
import ConfirmHost from "@/components/base/ConfirmHost.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<ToastHost />
|
||||
<ConfirmHost />
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { http } from "./client";
|
||||
import type { Account, AccountPayload } from "./types";
|
||||
|
||||
export const accountsApi = {
|
||||
list: () => http.get<Account[]>("/admin/accounts"),
|
||||
get: (id: number) => http.get<Account>(`/admin/accounts/${id}`),
|
||||
create: (payload: AccountPayload) => http.post<Account>("/admin/accounts", payload),
|
||||
update: (id: number, payload: AccountPayload) => http.put<Account>(`/admin/accounts/${id}`, payload),
|
||||
remove: (id: number) => http.del<{ id: number }>(`/admin/accounts/${id}`),
|
||||
toggle: (id: number) => http.post<Account>(`/admin/accounts/${id}/toggle`),
|
||||
setDefault: (id: number) => http.post<Account>(`/admin/accounts/${id}/set-default`),
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { http } from "./client";
|
||||
|
||||
export interface StrmKeyInfo {
|
||||
name: string;
|
||||
key: string;
|
||||
key_preview: string;
|
||||
format: "new";
|
||||
status: string;
|
||||
system: boolean;
|
||||
deletable: boolean;
|
||||
disableable: boolean;
|
||||
}
|
||||
|
||||
export interface ApiKeyRecord {
|
||||
id?: number;
|
||||
name: string;
|
||||
key_type: string;
|
||||
status: string;
|
||||
key_prefix: string;
|
||||
key_suffix: string;
|
||||
key_preview: string;
|
||||
expires_at?: string;
|
||||
last_used_at?: string;
|
||||
note?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
export interface ApiKeyListResult {
|
||||
strm_key: StrmKeyInfo;
|
||||
keys: ApiKeyRecord[];
|
||||
max_keys: number;
|
||||
key_count: number;
|
||||
}
|
||||
|
||||
export interface ApiKeyInput {
|
||||
name: string;
|
||||
key_type: string;
|
||||
expires_days?: number | null;
|
||||
status: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface RotateStrmKeyResult {
|
||||
strm_key: StrmKeyInfo;
|
||||
replace_result?: { total: number; matched: number; updated: number };
|
||||
}
|
||||
|
||||
export function fetchApiKeys() {
|
||||
return http.get<ApiKeyListResult>("/admin/api-keys");
|
||||
}
|
||||
|
||||
export function createApiKey(body: ApiKeyInput) {
|
||||
return http.post<ApiKeyRecord>("/admin/api-keys", body);
|
||||
}
|
||||
|
||||
export function updateApiKey(id: number, body: Partial<ApiKeyInput>) {
|
||||
return http.put<ApiKeyRecord>(`/admin/api-keys/${id}`, body);
|
||||
}
|
||||
|
||||
export function toggleApiKey(id: number) {
|
||||
return http.post<ApiKeyRecord>(`/admin/api-keys/${id}/toggle`, {});
|
||||
}
|
||||
|
||||
export function deleteApiKey(id: number) {
|
||||
return http.del<{ id: number }>(`/admin/api-keys/${id}`);
|
||||
}
|
||||
|
||||
export function rotateStrmKey(applyToExistingStrm = true) {
|
||||
return http.post<RotateStrmKeyResult>("/admin/api-keys/strm/rotate", {
|
||||
apply_to_existing_strm: applyToExistingStrm,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { http } from "./client";
|
||||
|
||||
export interface AuthStatus {
|
||||
is_admin: boolean;
|
||||
username?: string;
|
||||
public_index_enabled: boolean;
|
||||
must_change_password?: boolean;
|
||||
password_change_reason?: string;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
must_change_password?: boolean;
|
||||
password_change_reason?: string;
|
||||
}
|
||||
|
||||
export interface SystemConfig {
|
||||
admin_username: string;
|
||||
session_timeout: number;
|
||||
public_index_enabled: boolean;
|
||||
index_account_switch_mode?: string;
|
||||
admin_home_return_mode?: "sidebar" | "top_icon";
|
||||
header_effects_enabled?: boolean;
|
||||
index_strm_auto_detect_enabled?: boolean;
|
||||
must_change_password: boolean;
|
||||
password_change_reason?: string;
|
||||
oauth_server_url?: string;
|
||||
upload_task_concurrency?: number;
|
||||
log_retention_days?: number;
|
||||
auth_active_refresh_enabled?: boolean;
|
||||
webdav_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateCredentialsRequest {
|
||||
admin_username: string;
|
||||
admin_password?: string;
|
||||
session_timeout?: number;
|
||||
public_index_enabled?: boolean;
|
||||
index_account_switch_mode?: "dropdown" | "floating";
|
||||
admin_home_return_mode?: "sidebar" | "top_icon";
|
||||
header_effects_enabled?: boolean;
|
||||
index_strm_auto_detect_enabled?: boolean;
|
||||
oauth_server_url?: string;
|
||||
upload_task_concurrency?: number;
|
||||
log_retention_days?: number;
|
||||
auth_active_refresh_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface WebDAVConfigRequest {
|
||||
webdav_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ResetPasswordResult {
|
||||
reused?: boolean;
|
||||
expires_at?: number;
|
||||
remaining_seconds?: number;
|
||||
ttl_seconds?: number;
|
||||
}
|
||||
|
||||
export async function fetchAuthStatus(): Promise<AuthStatus> {
|
||||
return http.get<AuthStatus>("/auth/status");
|
||||
}
|
||||
|
||||
export async function login(input: {
|
||||
username: string;
|
||||
password: string;
|
||||
remember: boolean;
|
||||
}): Promise<LoginResult> {
|
||||
const body = new URLSearchParams();
|
||||
body.set("username", input.username);
|
||||
body.set("password", input.password);
|
||||
body.set("remember", input.remember ? "1" : "");
|
||||
return http.form<LoginResult>("/auth/login", body);
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await http.post<Record<string, never>>("/auth/logout");
|
||||
}
|
||||
|
||||
export async function resetPassword(): Promise<ResetPasswordResult> {
|
||||
return http.post<ResetPasswordResult>("/auth/reset-password");
|
||||
}
|
||||
|
||||
export async function fetchSystemConfig(): Promise<SystemConfig> {
|
||||
return http.get<SystemConfig>("/admin/system-config");
|
||||
}
|
||||
|
||||
export async function updateCredentials(payload: UpdateCredentialsRequest): Promise<void> {
|
||||
await http.post<Record<string, never>>("/admin/update-credentials", payload);
|
||||
}
|
||||
|
||||
export async function updateWebDAVConfig(payload: WebDAVConfigRequest): Promise<void> {
|
||||
await http.post<Record<string, never>>("/admin/webdav-config", payload);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { http } from "./client";
|
||||
|
||||
export type AutomationTriggerType = "daily" | "interval" | "webhook";
|
||||
export type AutomationStatus = "running" | "paused";
|
||||
export type AutomationCondition = "always" | "prev_success" | "prev_failed";
|
||||
export type AutomationActionType = "cache_clear" | "organize" | "strm" | "strm_scrape" | "delay" | "emby_refresh";
|
||||
|
||||
export interface AutomationAction {
|
||||
id: string;
|
||||
type: AutomationActionType;
|
||||
name: string;
|
||||
condition: AutomationCondition;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AutomationRuleInput {
|
||||
name: string;
|
||||
trigger_type: AutomationTriggerType;
|
||||
trigger_config: Record<string, unknown>;
|
||||
actions: AutomationAction[];
|
||||
status: AutomationStatus;
|
||||
}
|
||||
|
||||
export interface AutomationRule extends AutomationRuleInput {
|
||||
id: number;
|
||||
next_run_at?: string;
|
||||
last_run_at?: string;
|
||||
last_run_status?: string;
|
||||
last_run_message?: string;
|
||||
is_running?: boolean;
|
||||
running_step?: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface AutomationRun {
|
||||
id: number;
|
||||
rule_id: number;
|
||||
trigger_source: string;
|
||||
status: string;
|
||||
message: string;
|
||||
result: Record<string, unknown>;
|
||||
started_at?: string;
|
||||
finished_at?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface AutomationValidationIssue {
|
||||
level: string;
|
||||
message: string;
|
||||
action_index?: number;
|
||||
action_type?: string;
|
||||
}
|
||||
|
||||
export interface AutomationValidationResult {
|
||||
ok: boolean;
|
||||
issues: AutomationValidationIssue[];
|
||||
}
|
||||
|
||||
export interface AutomationOptionItem {
|
||||
id: string | number;
|
||||
name: string;
|
||||
account_id?: number;
|
||||
path?: string;
|
||||
schedule_mode?: string;
|
||||
branch_check_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface AutomationOptions {
|
||||
strm_tasks: AutomationOptionItem[];
|
||||
organize_tasks: AutomationOptionItem[];
|
||||
emby: {
|
||||
enabled: boolean;
|
||||
emby_url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AutomationTriggerConfig {
|
||||
time: string;
|
||||
start_time: string;
|
||||
interval_hours: number;
|
||||
event: string;
|
||||
source: string;
|
||||
path_prefix: string;
|
||||
require_path: boolean;
|
||||
}
|
||||
|
||||
export function normalizeAutomationTriggerConfig(
|
||||
config: Record<string, unknown> = {},
|
||||
): AutomationTriggerConfig {
|
||||
return {
|
||||
time: String(config.time ?? ""),
|
||||
start_time: String(config.start_time ?? ""),
|
||||
interval_hours: Number(config.interval_hours || 72),
|
||||
event: String(config.event ?? ""),
|
||||
source: String(config.source ?? ""),
|
||||
path_prefix: String(config.path_prefix ?? ""),
|
||||
require_path: config.require_path === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeAutomationTriggerConfig(
|
||||
config: AutomationTriggerConfig,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
time: config.time || "",
|
||||
start_time: config.start_time || "",
|
||||
interval_hours: Number(config.interval_hours || 72),
|
||||
event: String(config.event || "").trim(),
|
||||
source: String(config.source || "").trim(),
|
||||
path_prefix: String(config.path_prefix || "").trim(),
|
||||
require_path: config.require_path === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchAutomationRules() {
|
||||
return http.get<AutomationRule[]>("/admin/automation/rules");
|
||||
}
|
||||
|
||||
export function createAutomationRule(body: AutomationRuleInput) {
|
||||
return http.post<AutomationRule>("/admin/automation/rules", body);
|
||||
}
|
||||
|
||||
export function updateAutomationRule(id: number, body: AutomationRuleInput) {
|
||||
return http.put<AutomationRule>(`/admin/automation/rules/${id}`, body);
|
||||
}
|
||||
|
||||
export function deleteAutomationRule(id: number) {
|
||||
return http.del<{ id: number }>(`/admin/automation/rules/${id}`);
|
||||
}
|
||||
|
||||
export function toggleAutomationRule(id: number) {
|
||||
return http.post<AutomationRule>(`/admin/automation/rules/${id}/toggle`, {});
|
||||
}
|
||||
|
||||
export function runAutomationRule(id: number) {
|
||||
return http.post<{ submitted: boolean; rule_id: number; trigger_source: string }>(`/admin/automation/rules/${id}/run`, {});
|
||||
}
|
||||
|
||||
export function validateAutomationRule(actions: AutomationAction[]) {
|
||||
return http.post<AutomationValidationResult>("/admin/automation/validate", { actions });
|
||||
}
|
||||
|
||||
export function fetchAutomationRuns(ruleId?: number, limit = 20) {
|
||||
const query: Record<string, string | number> = { limit };
|
||||
if (ruleId) query.rule_id = ruleId;
|
||||
return http.get<AutomationRun[]>("/admin/automation/runs", query);
|
||||
}
|
||||
|
||||
export function clearAutomationRuns() {
|
||||
return http.post<{ deleted: number }>("/admin/automation/runs/clear", {});
|
||||
}
|
||||
|
||||
export function fetchAutomationOptions() {
|
||||
return http.get<AutomationOptions>("/admin/automation/options");
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { http } from "./client";
|
||||
|
||||
export interface CacheStats {
|
||||
total_keys: number;
|
||||
total_size_bytes: number;
|
||||
hit_rate: number;
|
||||
hits?: number;
|
||||
misses?: number;
|
||||
evictions?: number;
|
||||
expirations?: number;
|
||||
}
|
||||
|
||||
export function fetchCacheStats() {
|
||||
return http.get<CacheStats>("/admin/cache/stats");
|
||||
}
|
||||
|
||||
export function clearCache() {
|
||||
return http.post<{ cleared_count: number }>("/admin/clear-cache", {});
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { http } from "./client";
|
||||
|
||||
export interface CacheRetentionTask {
|
||||
id?: number;
|
||||
account_id: number;
|
||||
account_name?: string;
|
||||
parent_id: string;
|
||||
path: string;
|
||||
scan_depth: number;
|
||||
api_interval: number;
|
||||
refresh_interval: number;
|
||||
status: string;
|
||||
paused_reason?: string;
|
||||
file_count: number;
|
||||
last_refresh?: string;
|
||||
last_refresh_status?: string;
|
||||
last_duration_ms: number;
|
||||
last_api_calls: number;
|
||||
last_skip_calls: number;
|
||||
last_scanned_dirs: number;
|
||||
error_message?: string;
|
||||
time_window_enabled: boolean;
|
||||
time_start: string;
|
||||
time_end: string;
|
||||
scanned_dirs?: number;
|
||||
scanned_files?: number;
|
||||
current_dir?: string;
|
||||
started_at?: string;
|
||||
current_duration_ms?: number;
|
||||
is_running?: boolean;
|
||||
is_pending?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface CacheRetentionListPayload {
|
||||
items: CacheRetentionTask[];
|
||||
startup_remaining: number;
|
||||
}
|
||||
|
||||
export interface CacheRetentionStats {
|
||||
total: number;
|
||||
running: number;
|
||||
paused: number;
|
||||
executing_task_ids: number[];
|
||||
pending_task_ids?: number[];
|
||||
startup_remaining: number;
|
||||
}
|
||||
|
||||
export interface CacheRetentionDefaults {
|
||||
api_interval: number;
|
||||
refresh_interval: number;
|
||||
scan_depth: number;
|
||||
max_configs: number;
|
||||
startup_remaining: number;
|
||||
}
|
||||
|
||||
export interface RetentionRunResult {
|
||||
state: string;
|
||||
startup_remaining: number;
|
||||
retry_after_seconds?: number;
|
||||
cache_ttl_minutes?: number;
|
||||
}
|
||||
|
||||
export type CacheRetentionTaskInput = Pick<
|
||||
CacheRetentionTask,
|
||||
| "account_id"
|
||||
| "parent_id"
|
||||
| "path"
|
||||
| "scan_depth"
|
||||
| "api_interval"
|
||||
| "refresh_interval"
|
||||
| "time_window_enabled"
|
||||
| "time_start"
|
||||
| "time_end"
|
||||
>;
|
||||
|
||||
export const SCAN_DEPTH_OPTIONS = [
|
||||
{ value: 1, label: "单层" },
|
||||
{ value: 2, label: "2 层" },
|
||||
{ value: 3, label: "3 层" },
|
||||
{ value: 4, label: "4 层" },
|
||||
{ value: 5, label: "5 层" },
|
||||
] as const;
|
||||
|
||||
export function fetchCacheRetentionTasks() {
|
||||
return http.get<CacheRetentionListPayload>("/admin/cache-retention/configs");
|
||||
}
|
||||
|
||||
export function fetchCacheRetentionStats() {
|
||||
return http.get<CacheRetentionStats>("/admin/cache-retention/stats");
|
||||
}
|
||||
|
||||
export function fetchCacheRetentionDefaults() {
|
||||
return http.get<CacheRetentionDefaults>("/admin/cache-retention/defaults");
|
||||
}
|
||||
|
||||
export function createCacheRetentionTask(body: CacheRetentionTaskInput) {
|
||||
return http.post<{ id: number; run?: RetentionRunResult }>("/admin/cache-retention/configs", body);
|
||||
}
|
||||
|
||||
export function updateCacheRetentionTask(id: number, body: CacheRetentionTaskInput) {
|
||||
return http.put<CacheRetentionTask>(`/admin/cache-retention/configs/${id}`, body);
|
||||
}
|
||||
|
||||
export function deleteCacheRetentionTask(id: number) {
|
||||
return http.del<null>(`/admin/cache-retention/configs/${id}`);
|
||||
}
|
||||
|
||||
export function toggleCacheRetentionTask(id: number) {
|
||||
return http.post<CacheRetentionTask>(`/admin/cache-retention/configs/${id}/toggle`, {});
|
||||
}
|
||||
|
||||
export function refreshCacheRetentionTask(id: number) {
|
||||
return http.post<RetentionRunResult>(`/admin/cache-retention/configs/${id}/refresh`, {});
|
||||
}
|
||||
|
||||
export function forceStopCacheRetentionTask(id: number) {
|
||||
return http.post<null>(`/admin/cache-retention/configs/${id}/force-stop`, {});
|
||||
}
|
||||
|
||||
export function ackRetentionScopeWarn(id: number) {
|
||||
return http.post<null>(`/admin/cache-retention/configs/${id}/ack-scope-warn`, {});
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { ApiResp } from "./types";
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly errorType: string;
|
||||
readonly status: number;
|
||||
constructor(message: string, errorType: string, status: number) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.errorType = errorType;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export function getApiErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof ApiError ? error.message : fallback;
|
||||
}
|
||||
|
||||
type Query = Record<string, string | number | boolean | undefined>;
|
||||
|
||||
interface RequestOptions {
|
||||
query?: Query;
|
||||
body?: unknown;
|
||||
form?: URLSearchParams;
|
||||
skipAuthRedirect?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
function buildURL(path: string, query?: Query): string {
|
||||
if (!query) return path;
|
||||
const params = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v !== undefined) params.set(k, String(v));
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `${path}?${qs}` : path;
|
||||
}
|
||||
|
||||
let authRedirecting = false;
|
||||
|
||||
function maybeRedirectLogin(errorType: string, status: number, skip?: boolean) {
|
||||
if (skip || authRedirecting) return;
|
||||
if (status !== 401 || errorType !== "ADMIN_AUTH_REQUIRED") return;
|
||||
const path = window.location.pathname;
|
||||
if (path === "/login" || path.startsWith("/login")) return;
|
||||
authRedirecting = true;
|
||||
const redirect = encodeURIComponent(window.location.pathname + window.location.search);
|
||||
window.location.assign(`/login?redirect=${redirect}`);
|
||||
}
|
||||
|
||||
function shouldSkipAuthRedirect(path: string): boolean {
|
||||
return path.startsWith("/auth/") || path.startsWith("/public/");
|
||||
}
|
||||
|
||||
const defaultRequestTimeoutMs = 90_000;
|
||||
|
||||
async function request<T>(method: string, path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, defaultRequestTimeoutMs);
|
||||
const onAbort = () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
if (opts.signal?.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
credentials: "include",
|
||||
headers: {},
|
||||
signal: controller.signal,
|
||||
};
|
||||
if (opts.form) {
|
||||
init.body = opts.form;
|
||||
} else if (opts.body !== undefined) {
|
||||
(init.headers as Record<string, string>)["Content-Type"] = "application/json";
|
||||
init.body = JSON.stringify(opts.body);
|
||||
}
|
||||
if (method !== "GET" && method !== "HEAD") {
|
||||
(init.headers as Record<string, string>)["Origin"] = window.location.origin;
|
||||
}
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(buildURL(`/api${path}`, opts.query), init);
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === "AbortError") {
|
||||
if (cancelled || opts.signal?.aborted) {
|
||||
throw new ApiError("请求已取消", "aborted", 0);
|
||||
}
|
||||
if (timedOut) {
|
||||
throw new ApiError("请求超时,请稍后重试", "timeout", 0);
|
||||
}
|
||||
throw new ApiError("请求超时,请稍后重试", "timeout", 0);
|
||||
}
|
||||
throw new ApiError("网络请求失败,请检查后端是否在运行", "network_error", 0);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
opts.signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
let payload: ApiResp<T> | null = null;
|
||||
try {
|
||||
payload = (await resp.json()) as ApiResp<T>;
|
||||
} catch {
|
||||
/* 非 JSON 响应由调用方另行处理 */
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
if (!resp.ok) throw new ApiError(`请求失败 (${resp.status})`, "http_error", resp.status);
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
if (!payload.success) {
|
||||
const errorType = payload.error_type || "unknown";
|
||||
maybeRedirectLogin(errorType, resp.status, opts.skipAuthRedirect || shouldSkipAuthRedirect(path));
|
||||
throw new ApiError(payload.message || "请求失败", errorType, resp.status);
|
||||
}
|
||||
return payload.data as T;
|
||||
}
|
||||
|
||||
export const http = {
|
||||
get: <T>(path: string, query?: Query) => request<T>("GET", path, { query }),
|
||||
post: <T>(path: string, body?: unknown, query?: Query, signal?: AbortSignal) =>
|
||||
request<T>("POST", path, { body, query, signal }),
|
||||
put: <T>(path: string, body?: unknown) => request<T>("PUT", path, { body }),
|
||||
del: <T>(path: string, body?: unknown, query?: Query) =>
|
||||
request<T>("DELETE", path, { body, query }),
|
||||
form: <T>(path: string, form: URLSearchParams) =>
|
||||
request<T>("POST", path, { form, skipAuthRedirect: path.startsWith("/auth/") }),
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import { http } from "./client";
|
||||
|
||||
export interface CrossTransferPanMeta {
|
||||
driver: string;
|
||||
name: string;
|
||||
logo: string;
|
||||
color: string;
|
||||
conflict_policies: string[];
|
||||
}
|
||||
|
||||
export interface CrossTransferRoute {
|
||||
id: string;
|
||||
from: CrossTransferPanMeta;
|
||||
to: CrossTransferPanMeta;
|
||||
method: string;
|
||||
method_label: string;
|
||||
bidirectional: boolean;
|
||||
}
|
||||
|
||||
export interface CrossTransferScanFile {
|
||||
source_file_id: string;
|
||||
rel_path: string;
|
||||
rel_dir: string;
|
||||
name: string;
|
||||
size: number;
|
||||
hash: string;
|
||||
eligible: boolean;
|
||||
}
|
||||
|
||||
export interface CrossTransferScanResult {
|
||||
tree: unknown[];
|
||||
total: number;
|
||||
shallow_dirs: number;
|
||||
truncated: boolean;
|
||||
files: CrossTransferScanFile[];
|
||||
}
|
||||
|
||||
export interface CrossTransferRelayTask {
|
||||
task_id: string;
|
||||
source_account_id: number;
|
||||
source_account_name: string;
|
||||
source_driver_type: string;
|
||||
target_account_id: number;
|
||||
target_account_name: string;
|
||||
target_driver_type: string;
|
||||
source_file_id: string;
|
||||
file_name: string;
|
||||
rel_path: string;
|
||||
rel_dir: string;
|
||||
target_parent_id: string;
|
||||
target_display_path: string;
|
||||
total_bytes: number;
|
||||
method: string;
|
||||
conflict_policy: string;
|
||||
status: string;
|
||||
phase: string;
|
||||
progress: number;
|
||||
downloaded_bytes: number;
|
||||
uploaded_bytes: number;
|
||||
speed_bytes_per_second: number;
|
||||
message: string;
|
||||
error: string;
|
||||
result?: Record<string, unknown>;
|
||||
queue_order?: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export function listCrossTransferRoutes() {
|
||||
return http.get<CrossTransferRoute[]>("/cross-transfer/routes");
|
||||
}
|
||||
|
||||
export function scanCrossTransferSource(body: {
|
||||
source_account_id: number;
|
||||
source_parent_id: string;
|
||||
method: string;
|
||||
source_display_path?: string;
|
||||
}) {
|
||||
return http.post<CrossTransferScanResult>("/cross-transfer/scan", body);
|
||||
}
|
||||
|
||||
export async function* streamCrossTransferNDJSON<T extends Record<string, unknown>>(
|
||||
path: string,
|
||||
body: unknown,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<T> {
|
||||
const resp = await fetch(`/api${path}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Origin: window.location.origin,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
if (!resp.ok || !resp.body) {
|
||||
let message = `请求失败 (${resp.status})`;
|
||||
try {
|
||||
const payload = await resp.json();
|
||||
if (payload?.message) message = payload.message;
|
||||
} catch {}
|
||||
throw new Error(message);
|
||||
}
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let idx = buffer.indexOf("\n");
|
||||
while (idx >= 0) {
|
||||
const line = buffer.slice(0, idx).trim();
|
||||
buffer = buffer.slice(idx + 1);
|
||||
if (line) yield JSON.parse(line) as T;
|
||||
idx = buffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
const tail = buffer.trim();
|
||||
if (tail) yield JSON.parse(tail) as T;
|
||||
}
|
||||
|
||||
export function probeCrossTransferStream(
|
||||
body: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return streamCrossTransferNDJSON<Record<string, unknown>>("/cross-transfer/probe", body, signal);
|
||||
}
|
||||
|
||||
export function executeCrossTransferStream(
|
||||
body: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return streamCrossTransferNDJSON<Record<string, unknown>>("/cross-transfer/execute", body, signal);
|
||||
}
|
||||
|
||||
export function listCrossTransferRelayTasks() {
|
||||
return http.get<CrossTransferRelayTask[]>("/cross-transfer/relay/tasks");
|
||||
}
|
||||
|
||||
export function deleteCrossTransferRelayTasks(taskIds: string[]) {
|
||||
return http.post<{ removed: number }>("/cross-transfer/relay/tasks/batch-delete", {
|
||||
task_ids: taskIds,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { http } from "./client";
|
||||
import type { DriverInfo } from "./types";
|
||||
|
||||
export const driversApi = {
|
||||
list: () => http.get<DriverInfo[]>("/admin/drivers"),
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { http } from "./client";
|
||||
|
||||
export interface EmbyConfig {
|
||||
enabled: boolean;
|
||||
emby_url: string;
|
||||
api_key: string;
|
||||
proxy_port: string;
|
||||
proxy_url: string;
|
||||
running: boolean;
|
||||
last_error?: string;
|
||||
}
|
||||
|
||||
export interface EmbyConfigUpdate {
|
||||
enabled: boolean;
|
||||
emby_url: string;
|
||||
api_key: string;
|
||||
proxy_port: string;
|
||||
}
|
||||
|
||||
export function fetchEmbyConfig() {
|
||||
return http.get<EmbyConfig>("/admin/emby/config");
|
||||
}
|
||||
|
||||
export function saveEmbyConfig(values: EmbyConfigUpdate) {
|
||||
return http.put<EmbyConfig>("/admin/emby/config", values);
|
||||
}
|
||||
|
||||
export function testEmbyConfig(values: EmbyConfigUpdate) {
|
||||
return http.post<{ ok: boolean }>("/admin/emby/test", values);
|
||||
}
|
||||
|
||||
export function refreshEmbyLibrary() {
|
||||
return http.post<{ mode: string; task_id?: string }>("/admin/emby/refresh");
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { http } from "./client";
|
||||
import type {
|
||||
BrowserFavoritesPayload,
|
||||
BrowserFavoritesState,
|
||||
FileNameAlignApplyPayload,
|
||||
FileNameAlignApplyResult,
|
||||
FileNameAlignPreviewPayload,
|
||||
FileNameAlignPreviewResult,
|
||||
FileCreateFolderPayload,
|
||||
FileDeletePayload,
|
||||
FileListResult,
|
||||
FileRenamePayload,
|
||||
FileTransferPayload,
|
||||
} from "./types";
|
||||
|
||||
export interface TextPreviewBytes {
|
||||
bytes: Uint8Array;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export const TEXT_PREVIEW_MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
function downloadURL(
|
||||
accountId: number,
|
||||
fileId: string,
|
||||
fileName?: string,
|
||||
options: Record<string, string> = {},
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
account_id: String(accountId),
|
||||
file_id: fileId,
|
||||
...options,
|
||||
});
|
||||
if (fileName) params.set("file_name", fileName);
|
||||
return `/api/files/download?${params.toString()}`;
|
||||
}
|
||||
|
||||
function proxyPreviewURL(accountId: number, fileId: string, fileName?: string) {
|
||||
return downloadURL(accountId, fileId, fileName, { force_proxy: "1" });
|
||||
}
|
||||
|
||||
async function fetchPreview(
|
||||
accountId: number,
|
||||
fileId: string,
|
||||
fileName: string,
|
||||
init: RequestInit,
|
||||
failureLabel: string,
|
||||
) {
|
||||
const response = await fetch(proxyPreviewURL(accountId, fileId, fileName), {
|
||||
credentials: "include",
|
||||
...init,
|
||||
});
|
||||
if (response.ok) return response;
|
||||
|
||||
let message = `${failureLabel} (${response.status})`;
|
||||
try {
|
||||
const payload = await response.json() as { message?: string };
|
||||
if (payload.message) message = payload.message;
|
||||
} catch {
|
||||
// 下载网关也可能返回非 JSON 错误页,保留状态码信息。
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
async function readPreviewBytes(response: Response, fileSize: number): Promise<TextPreviewBytes> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
return {
|
||||
bytes: bytes.slice(0, TEXT_PREVIEW_MAX_BYTES),
|
||||
truncated: fileSize > TEXT_PREVIEW_MAX_BYTES || bytes.length > TEXT_PREVIEW_MAX_BYTES,
|
||||
};
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
while (total <= TEXT_PREVIEW_MAX_BYTES) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
total += value.length;
|
||||
}
|
||||
if (total > TEXT_PREVIEW_MAX_BYTES) await reader.cancel();
|
||||
|
||||
const length = Math.min(total, TEXT_PREVIEW_MAX_BYTES);
|
||||
const bytes = new Uint8Array(length);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
if (offset >= length) break;
|
||||
const part = chunk.subarray(0, length - offset);
|
||||
bytes.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
|
||||
const contentRangeTotal = Number(response.headers.get("Content-Range")?.split("/").pop());
|
||||
return {
|
||||
bytes,
|
||||
truncated:
|
||||
fileSize > TEXT_PREVIEW_MAX_BYTES ||
|
||||
total > TEXT_PREVIEW_MAX_BYTES ||
|
||||
(Number.isFinite(contentRangeTotal) && contentRangeTotal > TEXT_PREVIEW_MAX_BYTES),
|
||||
};
|
||||
}
|
||||
|
||||
async function readHeadBytes(response: Response, limit: number) {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) return new Uint8Array(await response.arrayBuffer()).slice(0, limit);
|
||||
const bytes = new Uint8Array(limit);
|
||||
let offset = 0;
|
||||
while (offset < limit) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const part = value.subarray(0, limit - offset);
|
||||
bytes.set(part, offset);
|
||||
offset += part.length;
|
||||
if (part.length < value.length) break;
|
||||
}
|
||||
await reader.cancel().catch(() => undefined);
|
||||
return bytes.slice(0, offset);
|
||||
}
|
||||
|
||||
async function readBinaryPreviewBytes(response: Response, maxBytes: number) {
|
||||
const contentLength = Number(response.headers.get("Content-Length"));
|
||||
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
throw new Error("文件过大,建议下载后查看");
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (bytes.length > maxBytes) throw new Error("文件过大,建议下载后查看");
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.length;
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw new Error("文件过大,建议下载后查看");
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export const filesApi = {
|
||||
list: (accountId: number, parentId: string, opts?: { forceRefresh?: boolean }) =>
|
||||
http.get<FileListResult>("/files/list", {
|
||||
account_id: accountId,
|
||||
parent_id: parentId,
|
||||
...(opts?.forceRefresh ? { force_refresh: "true" } : {}),
|
||||
}),
|
||||
|
||||
deleteFiles: (payload: FileDeletePayload) =>
|
||||
http.del<{ file_ids: string[] }>("/files/delete", payload),
|
||||
|
||||
moveFiles: (payload: FileTransferPayload) =>
|
||||
http.post<{ file_ids: string[]; target_parent_id: string }>("/files/move", payload),
|
||||
|
||||
copyFiles: (payload: FileTransferPayload) =>
|
||||
http.post<{ file_ids: string[]; target_parent_id: string }>("/files/copy", payload),
|
||||
|
||||
renameFile: (payload: FileRenamePayload) =>
|
||||
http.put<{ file_id: string; new_name: string }>("/files/rename", payload),
|
||||
|
||||
getFavorites: (accountId: number) =>
|
||||
http.get<BrowserFavoritesState>("/files/favorites", {
|
||||
account_id: accountId,
|
||||
}),
|
||||
|
||||
saveFavorites: (payload: BrowserFavoritesPayload) =>
|
||||
http.put<BrowserFavoritesState>("/files/favorites", payload),
|
||||
|
||||
previewNameAlign: (payload: FileNameAlignPreviewPayload) =>
|
||||
http.post<FileNameAlignPreviewResult>("/files/name-align/preview", payload),
|
||||
|
||||
applyNameAlign: (payload: FileNameAlignApplyPayload) =>
|
||||
http.post<FileNameAlignApplyResult>("/files/name-align/apply", payload),
|
||||
|
||||
createFolder: (payload: FileCreateFolderPayload) =>
|
||||
http.post<{ folder_id: string; folder_name: string; parent_id: string }>(
|
||||
"/files/create-folder",
|
||||
payload,
|
||||
),
|
||||
|
||||
downloadURL,
|
||||
|
||||
previewURL: (accountId: number, fileId: string, fileName?: string) =>
|
||||
downloadURL(accountId, fileId, fileName, { inline: "1" }),
|
||||
|
||||
pdfPreviewURL: proxyPreviewURL,
|
||||
|
||||
proxyPreviewURL,
|
||||
|
||||
binaryPreviewBytes: async (
|
||||
accountId: number,
|
||||
fileId: string,
|
||||
fileName: string,
|
||||
maxBytes: number,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
const response = await fetchPreview(
|
||||
accountId,
|
||||
fileId,
|
||||
fileName,
|
||||
{ signal },
|
||||
"文件读取失败",
|
||||
);
|
||||
return readBinaryPreviewBytes(response, maxBytes);
|
||||
},
|
||||
|
||||
textPreviewBytes: async (
|
||||
accountId: number,
|
||||
fileId: string,
|
||||
fileName: string,
|
||||
fileSize: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TextPreviewBytes> => {
|
||||
const response = await fetchPreview(
|
||||
accountId,
|
||||
fileId,
|
||||
fileName,
|
||||
{ headers: { Range: `bytes=0-${TEXT_PREVIEW_MAX_BYTES}` }, signal },
|
||||
"文本读取失败",
|
||||
);
|
||||
return readPreviewBytes(response, fileSize);
|
||||
},
|
||||
|
||||
previewHeadBytes: async (
|
||||
accountId: number,
|
||||
fileId: string,
|
||||
fileName: string,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
const response = await fetchPreview(
|
||||
accountId,
|
||||
fileId,
|
||||
fileName,
|
||||
{ headers: { Range: "bytes=0-32767" }, signal },
|
||||
"文件类型检测失败",
|
||||
);
|
||||
return readHeadBytes(response, 32 * 1024);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { http } from "./client";
|
||||
|
||||
export interface FnosConfig {
|
||||
enabled: boolean;
|
||||
fnos_url: string;
|
||||
proxy_port: string;
|
||||
strm_path_maps: string;
|
||||
strm_dir: string;
|
||||
proxy_url: string;
|
||||
running: boolean;
|
||||
last_error?: string;
|
||||
}
|
||||
|
||||
export interface FnosConfigUpdate {
|
||||
enabled: boolean;
|
||||
fnos_url: string;
|
||||
proxy_port: string;
|
||||
strm_path_maps: string;
|
||||
}
|
||||
|
||||
export function fetchFnosConfig() {
|
||||
return http.get<FnosConfig>("/admin/fnos/config");
|
||||
}
|
||||
|
||||
export function saveFnosConfig(values: FnosConfigUpdate) {
|
||||
return http.put<FnosConfig>("/admin/fnos/config", values);
|
||||
}
|
||||
|
||||
export function testFnosConfig(values: FnosConfigUpdate) {
|
||||
return http.post<{ ok: boolean }>("/admin/fnos/test", values);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { http } from "./client";
|
||||
|
||||
export interface FuseMount {
|
||||
id?: number;
|
||||
name: string;
|
||||
account_id: number;
|
||||
account_name?: string;
|
||||
root_item_id: string;
|
||||
root_path: string;
|
||||
mount_point: string;
|
||||
read_only: boolean;
|
||||
auto_mount: boolean;
|
||||
uid: number;
|
||||
gid: number;
|
||||
dir_mode: string;
|
||||
file_mode: string;
|
||||
enabled: boolean;
|
||||
state: string;
|
||||
last_error?: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface FuseStatus {
|
||||
enabled: boolean;
|
||||
compile_support: boolean;
|
||||
mount_root: string;
|
||||
entry_timeout_s: number;
|
||||
attr_timeout_s: number;
|
||||
read_cache?: FuseReadCacheConfig;
|
||||
}
|
||||
|
||||
export interface FuseReadCacheConfig {
|
||||
enabled: boolean;
|
||||
max_gb: number;
|
||||
retention_days: number;
|
||||
eviction_policy: "lru" | "large_file";
|
||||
used_bytes: number;
|
||||
limit_bytes: number;
|
||||
block_count: number;
|
||||
root_path: string;
|
||||
}
|
||||
|
||||
export function fetchFuseReadCache() {
|
||||
return http.get<FuseReadCacheConfig>("/admin/fuse/read-cache");
|
||||
}
|
||||
|
||||
export function updateFuseReadCache(body: Partial<FuseReadCacheConfig>) {
|
||||
return http.put<FuseReadCacheConfig>("/admin/fuse/read-cache", body);
|
||||
}
|
||||
|
||||
export function clearFuseReadCache() {
|
||||
return http.post<{ cleared: boolean }>("/admin/fuse/read-cache/clear");
|
||||
}
|
||||
|
||||
export function fetchFuseStatus() {
|
||||
return http.get<FuseStatus>("/admin/fuse/status");
|
||||
}
|
||||
|
||||
export function updateFuseConfig(enabled: boolean) {
|
||||
return http.put<FuseStatus>("/admin/fuse/config", { enabled });
|
||||
}
|
||||
|
||||
export function fetchFuseMounts() {
|
||||
return http.get<FuseMount[]>("/admin/fuse/mounts");
|
||||
}
|
||||
|
||||
export function createFuseMount(body: Partial<FuseMount>) {
|
||||
return http.post<FuseMount>("/admin/fuse/mounts", body);
|
||||
}
|
||||
|
||||
export function updateFuseMount(id: number, body: Partial<FuseMount>) {
|
||||
return http.put<FuseMount>(`/admin/fuse/mounts/${id}`, body);
|
||||
}
|
||||
|
||||
export function deleteFuseMount(id: number) {
|
||||
return http.del<{ deleted: boolean }>(`/admin/fuse/mounts/${id}`);
|
||||
}
|
||||
|
||||
export function mountFuse(id: number) {
|
||||
return http.post<FuseMount>(`/admin/fuse/mounts/${id}/mount`);
|
||||
}
|
||||
|
||||
export function unmountFuse(id: number) {
|
||||
return http.post<FuseMount>(`/admin/fuse/mounts/${id}/unmount`);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { http } from "./client";
|
||||
|
||||
export interface LogEntry {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
level: number;
|
||||
level_name: string;
|
||||
level_emoji: string;
|
||||
module: string;
|
||||
module_name: string;
|
||||
module_color: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
account_id?: string;
|
||||
driver_name?: string;
|
||||
}
|
||||
|
||||
export interface LogStats {
|
||||
total: number;
|
||||
by_level: Record<string, number>;
|
||||
by_module: Record<string, number>;
|
||||
recent_errors: number;
|
||||
recent_errors_total: number;
|
||||
}
|
||||
|
||||
export interface LogCleanupResult {
|
||||
deleted_files: number;
|
||||
retention_days: number;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
export interface LogQuery {
|
||||
level?: number;
|
||||
module?: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
keyword?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export const LOG_LEVELS = [
|
||||
{ value: "", label: "所有级别" },
|
||||
{ value: 20, label: "ℹ️ INFO" },
|
||||
{ value: 30, label: "⚠️ WARNING" },
|
||||
{ value: 40, label: "❌ ERROR" },
|
||||
] as const;
|
||||
|
||||
export const LOG_MODULE_GROUPS = [
|
||||
{ value: "", label: "所有模块" },
|
||||
{ value: "driver", label: "驱动" },
|
||||
{ value: "file", label: "文件" },
|
||||
{ value: "cache", label: "缓存" },
|
||||
{ value: "interface", label: "接口" },
|
||||
{ value: "system", label: "系统" },
|
||||
] as const;
|
||||
|
||||
export const LOG_PERIODS = [
|
||||
{ value: "all", label: "全部时间" },
|
||||
{ value: "today", label: "今天" },
|
||||
{ value: "24h", label: "近 24 小时" },
|
||||
{ value: "7d", label: "近 7 天" },
|
||||
] as const;
|
||||
|
||||
export function logsApi() {
|
||||
return {
|
||||
list: (query?: LogQuery) => http.get<LogEntry[]>("/logs", query as Record<string, string | number | undefined>),
|
||||
stats: () => http.get<LogStats>("/logs/stats"),
|
||||
cleanup: () => http.post<LogCleanupResult>("/logs/cleanup"),
|
||||
cleanupKeepToday: () => http.post<LogCleanupResult>("/logs/cleanup/keep-today"),
|
||||
cleanupAll: () => http.post<LogCleanupResult>("/logs/cleanup/all"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { http } from "./client";
|
||||
|
||||
export interface MediaOrganizeTaskConfig {
|
||||
target_directory?: string;
|
||||
target_directory_id?: string;
|
||||
action_type?: string;
|
||||
target_root?: string;
|
||||
target_root_id?: string;
|
||||
media_type?: string;
|
||||
rename_marker?: string;
|
||||
use_tmdb?: boolean;
|
||||
overwrite_existing?: boolean;
|
||||
recursive?: boolean;
|
||||
account_id?: string | number;
|
||||
}
|
||||
|
||||
export interface MediaOrganizeTask {
|
||||
id: string;
|
||||
task_name: string;
|
||||
account_id: number;
|
||||
config: MediaOrganizeTaskConfig;
|
||||
status: string;
|
||||
last_run_at?: string;
|
||||
last_run_result?: MediaOrganizeRunResult;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
is_running?: boolean;
|
||||
}
|
||||
|
||||
export interface MediaOrganizeRunResult {
|
||||
total?: number;
|
||||
renamed?: number;
|
||||
moved?: number;
|
||||
skipped?: number;
|
||||
failed?: number;
|
||||
stopped?: boolean;
|
||||
}
|
||||
|
||||
export interface MediaOrganizePlanAction {
|
||||
id: string;
|
||||
kind: string;
|
||||
source_id?: string;
|
||||
source_name?: string;
|
||||
source_parent_id?: string;
|
||||
target_parent_id?: string;
|
||||
target_name?: string;
|
||||
reason?: string;
|
||||
confidence?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
status?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface MediaOrganizePlan {
|
||||
task_id?: string;
|
||||
created_at?: string;
|
||||
target_root_id?: string;
|
||||
target_parent_id?: string;
|
||||
actions?: MediaOrganizePlanAction[];
|
||||
skipped?: Array<Record<string, unknown>>;
|
||||
diagnostics?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MediaOrganizeProgress {
|
||||
stage?: string;
|
||||
scanned_dirs?: number;
|
||||
scanned_files?: number;
|
||||
groups?: number;
|
||||
actions?: number;
|
||||
skipped?: number;
|
||||
current_dir?: string;
|
||||
planned_works?: number;
|
||||
max_works?: number;
|
||||
quota_reached?: boolean;
|
||||
}
|
||||
|
||||
export interface MediaOrganizeLogEntry {
|
||||
time: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface MediaOrganizeSettings {
|
||||
proxy_enabled: boolean;
|
||||
proxy_url: string;
|
||||
proxy_username: string;
|
||||
proxy_password: string;
|
||||
tmdb_api_key: string;
|
||||
tmdb_language: string;
|
||||
api_request_interval_ms: number;
|
||||
tmdb_request_interval_ms: number;
|
||||
min_confidence_threshold: number;
|
||||
file_extensions: string;
|
||||
metadata_extensions: string;
|
||||
media_tag_order: string[] | string;
|
||||
align_media_tags: boolean;
|
||||
max_works_per_run: number;
|
||||
overwrite_existing: boolean;
|
||||
}
|
||||
|
||||
export type MediaOrganizeTaskInput = {
|
||||
task_name: string;
|
||||
account_id: number;
|
||||
target_directory: string;
|
||||
target_directory_id: string;
|
||||
action_type: string;
|
||||
target_root?: string;
|
||||
target_root_id?: string;
|
||||
media_type: string;
|
||||
rename_marker?: string;
|
||||
use_tmdb: boolean;
|
||||
overwrite_existing?: boolean;
|
||||
recursive?: boolean;
|
||||
};
|
||||
|
||||
export function fetchMediaOrganizeTasks() {
|
||||
return http.get<MediaOrganizeTask[]>("/admin/media-organize/tasks");
|
||||
}
|
||||
|
||||
export function createMediaOrganizeTask(input: MediaOrganizeTaskInput) {
|
||||
return http.post<MediaOrganizeTask>("/admin/media-organize/tasks", input);
|
||||
}
|
||||
|
||||
export function updateMediaOrganizeTask(id: string, input: Partial<MediaOrganizeTaskInput>) {
|
||||
return http.put<MediaOrganizeTask>(`/admin/media-organize/tasks/${id}`, input);
|
||||
}
|
||||
|
||||
export function deleteMediaOrganizeTask(id: string) {
|
||||
return http.del<{ id: string; stopping?: boolean }>(`/admin/media-organize/tasks/${id}`);
|
||||
}
|
||||
|
||||
export interface MediaOrganizePlanResult {
|
||||
plan: MediaOrganizePlan;
|
||||
summary?: { actions?: number; skipped?: number };
|
||||
}
|
||||
|
||||
export function planMediaOrganizeTask(id: string) {
|
||||
return http.post<MediaOrganizePlanResult>(`/admin/media-organize/tasks/${id}/plan`);
|
||||
}
|
||||
|
||||
export function fetchMediaOrganizePlan(id: string) {
|
||||
return http.get<MediaOrganizePlan>(`/admin/media-organize/tasks/${id}/plan`);
|
||||
}
|
||||
|
||||
export function applyMediaOrganizeTask(id: string) {
|
||||
return http.post<Record<string, unknown>>(`/admin/media-organize/tasks/${id}/apply`);
|
||||
}
|
||||
|
||||
export function stopMediaOrganizeTask(id: string) {
|
||||
return http.post<{ stopping: boolean }>(`/admin/media-organize/tasks/${id}/stop`);
|
||||
}
|
||||
|
||||
export function fetchMediaOrganizeLogs(id: string) {
|
||||
return http.get<{
|
||||
logs: MediaOrganizeLogEntry[];
|
||||
status: string;
|
||||
last_run_result?: MediaOrganizeRunResult;
|
||||
}>(`/admin/media-organize/tasks/${id}/logs`);
|
||||
}
|
||||
|
||||
export function fetchMediaOrganizeProgress(id: string) {
|
||||
return http.get<MediaOrganizeProgress>(`/admin/media-organize/tasks/${id}/progress`);
|
||||
}
|
||||
|
||||
export function updateMediaOrganizePlanAction(taskId: string, actionId: string, targetName: string) {
|
||||
return http.put<{ action?: MediaOrganizePlanAction; changed?: boolean }>(
|
||||
`/admin/media-organize/tasks/${taskId}/plan/actions/${actionId}`,
|
||||
{ target_name: targetName },
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteMediaOrganizePlanAction(taskId: string, actionId: string) {
|
||||
return http.del<{ removed?: string }>(`/admin/media-organize/tasks/${taskId}/plan/actions/${actionId}`);
|
||||
}
|
||||
|
||||
export function batchDeleteMediaOrganizePlanActions(taskId: string, actionIds: string[]) {
|
||||
return http.post<{ removed?: string[] }>(`/admin/media-organize/tasks/${taskId}/plan/actions/batch-delete`, {
|
||||
action_ids: actionIds,
|
||||
});
|
||||
}
|
||||
|
||||
export function testMediaOrganizeTmdb(payload?: Partial<MediaOrganizeSettings>) {
|
||||
return http.post<{ ok: boolean; language?: string; proxy_used?: boolean }>(
|
||||
"/admin/media-organize/test-tmdb",
|
||||
payload ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
export interface MediaOrganizeTmdbSearchHit {
|
||||
id?: number | string;
|
||||
title?: string;
|
||||
name?: string;
|
||||
original_title?: string;
|
||||
original_name?: string;
|
||||
release_date?: string;
|
||||
first_air_date?: string;
|
||||
poster_path?: string;
|
||||
media_type?: string;
|
||||
overview?: string;
|
||||
}
|
||||
|
||||
export function searchMediaOrganizeTmdb(params: {
|
||||
query: string;
|
||||
year?: number;
|
||||
language?: string;
|
||||
media_type?: string;
|
||||
}) {
|
||||
return http.get<MediaOrganizeTmdbSearchHit[]>("/admin/media-organize/search-tmdb", {
|
||||
query: params.query,
|
||||
year: params.year,
|
||||
language: params.language,
|
||||
media_type: params.media_type ?? "auto",
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchMediaOrganizeSettings() {
|
||||
return http.get<MediaOrganizeSettings>("/admin/media-organize/settings");
|
||||
}
|
||||
|
||||
export function saveMediaOrganizeSettings(settings: Partial<MediaOrganizeSettings>) {
|
||||
return http.put<MediaOrganizeSettings>("/admin/media-organize/settings", settings);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { http } from "./client";
|
||||
|
||||
export const NOTIFICATION_CATEGORY_CACHE_SCOPE_WARN = "cache_scope_warn";
|
||||
export const NOTIFICATION_CATEGORY_STRM_SCAN_WARN = "strm_scan_warn";
|
||||
export const NOTIFICATION_CATEGORY_STRM_SCRAPE_WARN = "strm_scrape_warn";
|
||||
|
||||
const STRM_SCAN_DETAIL_SEP = "\n---detail---\n";
|
||||
|
||||
export interface NotificationItem {
|
||||
id: number;
|
||||
level: string;
|
||||
category: string;
|
||||
title: string;
|
||||
message: string;
|
||||
account_id?: number;
|
||||
ref_id?: number;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface StrmScanFailureItem {
|
||||
kind: string;
|
||||
path: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface StrmScrapeFailureItem {
|
||||
stage: string;
|
||||
name: string;
|
||||
path: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export async function fetchNotifications(params?: { limit?: number; offset?: number }) {
|
||||
return http.get<{ items: NotificationItem[] }>("/admin/notifications", params);
|
||||
}
|
||||
|
||||
export async function fetchUnreadCount() {
|
||||
return http.get<{ count: number }>("/admin/notifications/unread-count");
|
||||
}
|
||||
|
||||
export async function markNotificationRead(id: number) {
|
||||
await http.post<Record<string, never>>(`/admin/notifications/${id}/read`);
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead() {
|
||||
return http.post<{ marked: number }>("/admin/notifications/read-all");
|
||||
}
|
||||
|
||||
export async function deleteNotification(id: number) {
|
||||
await http.del<Record<string, never>>(`/admin/notifications/${id}`);
|
||||
}
|
||||
|
||||
export async function deleteAllNotifications() {
|
||||
return http.del<{ deleted: number }>("/admin/notifications");
|
||||
}
|
||||
|
||||
export function isCacheScopeWarnNotification(item: NotificationItem): boolean {
|
||||
if (item.category === NOTIFICATION_CATEGORY_CACHE_SCOPE_WARN && (item.ref_id ?? 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
return item.category === "cache" && item.title.includes("范围过大") && (item.ref_id ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function isStrmScanWarnNotification(item: NotificationItem): boolean {
|
||||
return item.category === NOTIFICATION_CATEGORY_STRM_SCAN_WARN;
|
||||
}
|
||||
|
||||
export function isStrmScrapeWarnNotification(item: NotificationItem): boolean {
|
||||
return item.category === NOTIFICATION_CATEGORY_STRM_SCRAPE_WARN;
|
||||
}
|
||||
|
||||
function parseFailureDetails<T>(message: string): { summary: string; items: T[] } {
|
||||
const text = (message ?? "").trim();
|
||||
const idx = text.indexOf(STRM_SCAN_DETAIL_SEP);
|
||||
if (idx < 0) return { summary: text, items: [] };
|
||||
const summary = text.slice(0, idx).trim();
|
||||
const payload = text.slice(idx + STRM_SCAN_DETAIL_SEP.length).trim();
|
||||
if (!payload) return { summary, items: [] };
|
||||
try {
|
||||
const items = JSON.parse(payload) as T[];
|
||||
return { summary, items: Array.isArray(items) ? items : [] };
|
||||
} catch {
|
||||
return { summary, items: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function parseStrmScanFailures(message: string): {
|
||||
summary: string;
|
||||
items: StrmScanFailureItem[];
|
||||
} {
|
||||
return parseFailureDetails<StrmScanFailureItem>(message);
|
||||
}
|
||||
|
||||
export function parseStrmScrapeFailures(message: string): {
|
||||
summary: string;
|
||||
items: StrmScrapeFailureItem[];
|
||||
} {
|
||||
return parseFailureDetails<StrmScrapeFailureItem>(message);
|
||||
}
|
||||
|
||||
export function strmScanFailureKindLabel(kind: string): string {
|
||||
if (kind === "metadata") return "元数据";
|
||||
return "STRM";
|
||||
}
|
||||
|
||||
export function strmScrapeFailureStageLabel(stage: string): string {
|
||||
if (stage === "match") return "TMDB 匹配";
|
||||
if (stage === "write") return "元数据写入";
|
||||
return "刮削";
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ApiResp } from "./types";
|
||||
|
||||
export interface OAuthStartResult {
|
||||
session_id: string;
|
||||
oauth_url: string;
|
||||
}
|
||||
|
||||
export interface OAuthStatusResult {
|
||||
status: "pending" | "success" | "error";
|
||||
token_data?: Record<string, string>;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
async function oauthFetch<T>(method: string, path: string, body?: unknown): Promise<ApiResp<T>> {
|
||||
const init: RequestInit = { method, headers: {} };
|
||||
if (body !== undefined) {
|
||||
(init.headers as Record<string, string>)["Content-Type"] = "application/json";
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const resp = await fetch(`/api${path}`, init);
|
||||
return (await resp.json()) as ApiResp<T>;
|
||||
}
|
||||
|
||||
export async function startOAuth(driverType: string) {
|
||||
return oauthFetch<OAuthStartResult>("POST", "/oauth/start", {
|
||||
driver_type: driverType,
|
||||
server_use: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOAuthStatus(sessionId: string) {
|
||||
return oauthFetch<OAuthStatusResult>("GET", `/oauth/status/${sessionId}`);
|
||||
}
|
||||
|
||||
export async function confirmOAuthReceived(sessionId: string) {
|
||||
return oauthFetch<unknown>("POST", `/oauth/confirm-received/${sessionId}`);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { ApiError, http } from "./client";
|
||||
import type { ApiResp } from "./types";
|
||||
import type {
|
||||
OfflineBatchDeleteResult,
|
||||
OfflineDownloadCapabilities,
|
||||
OfflineDownloadTask,
|
||||
OfflineTorrentPreparation,
|
||||
} from "@/types/offline-download";
|
||||
|
||||
export interface AddOfflineURLsPayload {
|
||||
account_id: number;
|
||||
urls: string[];
|
||||
file_name?: string;
|
||||
target_parent_id: string;
|
||||
target_display_path: string;
|
||||
}
|
||||
|
||||
export interface AddOfflineTorrentPayload {
|
||||
account_id: number;
|
||||
preparation_id: string;
|
||||
wanted: number[];
|
||||
target_parent_id: string;
|
||||
target_display_path: string;
|
||||
save_path?: string;
|
||||
}
|
||||
|
||||
async function parseMultipartResponse<T>(resp: Response): Promise<T> {
|
||||
let payload: ApiResp<T> | null = null;
|
||||
try {
|
||||
payload = (await resp.json()) as ApiResp<T>;
|
||||
} catch {
|
||||
// 统一转换为可展示错误。
|
||||
}
|
||||
if (!payload?.success) {
|
||||
throw new ApiError(payload?.message || "请求失败", payload?.error_type || "unknown", resp.status);
|
||||
}
|
||||
return payload.data as T;
|
||||
}
|
||||
|
||||
export const offlineDownloadApi = {
|
||||
capabilities(accountId: number) {
|
||||
return http.get<OfflineDownloadCapabilities>("/files/offline-download/capabilities", {
|
||||
account_id: accountId,
|
||||
});
|
||||
},
|
||||
|
||||
addURLs(payload: AddOfflineURLsPayload) {
|
||||
return http.post<OfflineDownloadTask[]>("/files/offline-download/urls", payload);
|
||||
},
|
||||
|
||||
prepareTorrent(accountId: number, file: File) {
|
||||
const form = new FormData();
|
||||
form.append("account_id", String(accountId));
|
||||
form.append("file", file, file.name);
|
||||
return fetch("/api/files/offline-download/torrent/prepare", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: form,
|
||||
}).then((resp) => parseMultipartResponse<OfflineTorrentPreparation>(resp));
|
||||
},
|
||||
|
||||
addTorrent(payload: AddOfflineTorrentPayload) {
|
||||
return http.post<OfflineDownloadTask>("/files/offline-download/torrent", payload);
|
||||
},
|
||||
|
||||
listTasks(refresh = true, accountId?: number) {
|
||||
return http.get<OfflineDownloadTask[]>("/files/offline-download/tasks", {
|
||||
refresh,
|
||||
account_id: accountId,
|
||||
});
|
||||
},
|
||||
|
||||
refreshTasks(accountId?: number) {
|
||||
return http.post<OfflineDownloadTask[]>(
|
||||
"/files/offline-download/tasks/refresh",
|
||||
undefined,
|
||||
{ account_id: accountId },
|
||||
);
|
||||
},
|
||||
|
||||
deleteTask(taskId: string) {
|
||||
return http.del<void>(`/files/offline-download/tasks/${encodeURIComponent(taskId)}`);
|
||||
},
|
||||
|
||||
batchDelete(taskIds: string[]) {
|
||||
return http.post<OfflineBatchDeleteResult>("/files/offline-download/tasks/batch-delete", {
|
||||
task_ids: taskIds,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { http } from "./client";
|
||||
import type { Account } from "./types";
|
||||
|
||||
export interface PublicSystemConfig {
|
||||
index_account_switch_mode: "dropdown" | "floating";
|
||||
header_effects_enabled?: boolean;
|
||||
index_strm_auto_detect_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface CacheHitRateResult {
|
||||
hit_rate: number;
|
||||
}
|
||||
|
||||
export const publicApi = {
|
||||
listAccounts: () => http.get<Account[]>("/public/accounts"),
|
||||
systemConfig: () => http.get<PublicSystemConfig>("/public/system-config"),
|
||||
cacheHitRate: () => http.get<CacheHitRateResult>("/public/cache/hit-rate"),
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ApiResp } from "./types";
|
||||
|
||||
export interface QrStartResult {
|
||||
token: string;
|
||||
qr_image_base64: string;
|
||||
qr_url: string;
|
||||
expires_in: number;
|
||||
title?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export type QrStatus = "waiting" | "success" | "failed" | "expired";
|
||||
|
||||
export interface QrPollResult {
|
||||
status: QrStatus;
|
||||
cookie?: string;
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
async function qrFetch<T>(path: string, body: unknown): Promise<ApiResp<T>> {
|
||||
const resp = await fetch(`/api${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return (await resp.json()) as ApiResp<T>;
|
||||
}
|
||||
|
||||
export async function qrStart(driverType: string) {
|
||||
return qrFetch<QrStartResult>("/qr/start", { driver_type: driverType });
|
||||
}
|
||||
|
||||
export async function qrPoll(driverType: string, token: string) {
|
||||
return qrFetch<QrPollResult>("/qr/poll", { driver_type: driverType, token });
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { http } from "./client";
|
||||
|
||||
export type SettingType = "string" | "int" | "bool" | "select";
|
||||
|
||||
export interface SettingOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SettingItem {
|
||||
key: string;
|
||||
type: SettingType;
|
||||
category: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
value: string;
|
||||
default: string;
|
||||
is_default: boolean;
|
||||
unit?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
options?: SettingOption[];
|
||||
sensitive?: boolean;
|
||||
}
|
||||
|
||||
export interface SettingCategory {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SettingsPayload {
|
||||
categories: SettingCategory[];
|
||||
items: SettingItem[];
|
||||
}
|
||||
|
||||
export function fetchSettings() {
|
||||
return http.get<SettingsPayload>("/admin/settings");
|
||||
}
|
||||
|
||||
// 仅提交改动过的键值(字符串形式),后端按类型校验并返回最新快照。
|
||||
export function saveSettings(values: Record<string, string>) {
|
||||
return http.put<SettingsPayload>("/admin/settings", values);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { http } from "./client";
|
||||
|
||||
export interface StrmTask {
|
||||
id?: number;
|
||||
name: string;
|
||||
account_id: number;
|
||||
parent_id: string;
|
||||
path: string;
|
||||
recursive: boolean;
|
||||
scan_interval: number;
|
||||
scan_mode: string;
|
||||
extensions: string;
|
||||
output_folder: string;
|
||||
api_interval: number;
|
||||
exclude_dir_keywords: string;
|
||||
exclude_file_keywords: string;
|
||||
sync_metadata: boolean;
|
||||
branch_check_enabled: boolean;
|
||||
time_window_enabled: boolean;
|
||||
time_start: string;
|
||||
time_end: string;
|
||||
schedule_mode: string;
|
||||
status: string;
|
||||
paused_reason?: string;
|
||||
error_message?: string;
|
||||
scanned_count: number;
|
||||
generated_count: number;
|
||||
updated_count: number;
|
||||
removed_count: number;
|
||||
last_scan?: string;
|
||||
last_scan_status?: string;
|
||||
automation_managed?: boolean;
|
||||
is_scanning?: boolean;
|
||||
scan_phase?: "scanning" | "syncing_metadata";
|
||||
current_label?: string;
|
||||
scanned_dirs?: number;
|
||||
scanned_files?: number;
|
||||
metadata_total?: number;
|
||||
metadata_done?: number;
|
||||
started_at?: string;
|
||||
current_duration_ms?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface StrmBranch {
|
||||
id?: number;
|
||||
task_id?: number;
|
||||
account_id?: number;
|
||||
parent_id: string;
|
||||
path: string;
|
||||
relative_path?: string;
|
||||
recursive: boolean;
|
||||
retention_days: number;
|
||||
branch_type: string;
|
||||
status?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface StrmSettings {
|
||||
token: string;
|
||||
base_url: string;
|
||||
signature_enabled: boolean;
|
||||
default_scan_interval: number;
|
||||
default_extensions: string;
|
||||
iso_filename_enabled: boolean;
|
||||
min_file_size_mb: number;
|
||||
conflict_policy: string;
|
||||
task_concurrency: number;
|
||||
metadata_extensions: string;
|
||||
metadata_max_size_mb: number;
|
||||
metadata_parent_enabled: boolean;
|
||||
}
|
||||
|
||||
export type StrmTaskInput = Pick<
|
||||
StrmTask,
|
||||
| "name"
|
||||
| "account_id"
|
||||
| "parent_id"
|
||||
| "path"
|
||||
| "recursive"
|
||||
| "scan_interval"
|
||||
| "scan_mode"
|
||||
| "extensions"
|
||||
| "output_folder"
|
||||
| "api_interval"
|
||||
| "exclude_dir_keywords"
|
||||
| "exclude_file_keywords"
|
||||
| "sync_metadata"
|
||||
| "branch_check_enabled"
|
||||
| "time_window_enabled"
|
||||
| "time_start"
|
||||
| "time_end"
|
||||
| "schedule_mode"
|
||||
>;
|
||||
|
||||
export const SCAN_MODES = [
|
||||
{ value: "incremental_missing", label: "补缺" },
|
||||
{ value: "incremental_update", label: "更新" },
|
||||
{ value: "full_sync", label: "全量" },
|
||||
] as const;
|
||||
|
||||
export const CONFLICT_POLICIES = [
|
||||
{ value: "size_desc", label: "大文件优先" },
|
||||
{ value: "size_asc", label: "小文件优先" },
|
||||
{ value: "name_asc", label: "文件名靠前优先" },
|
||||
] as const;
|
||||
|
||||
export type StrmRunMode = "auto" | "full" | "branch";
|
||||
|
||||
export function fetchStrmTasks() {
|
||||
return http.get<StrmTask[]>("/admin/strm/tasks");
|
||||
}
|
||||
|
||||
export function fetchStrmStartupRemaining() {
|
||||
return http.get<{ startup_remaining: number }>("/admin/strm/startup");
|
||||
}
|
||||
|
||||
export function createStrmTask(body: StrmTaskInput) {
|
||||
return http.post<StrmTask>("/admin/strm/tasks", body);
|
||||
}
|
||||
|
||||
export interface StrmAccountRepairPrecheck {
|
||||
needs_prompt: boolean;
|
||||
has_history_strm: boolean;
|
||||
old_account_id?: number;
|
||||
sample_total: number;
|
||||
sample_matched: number;
|
||||
can_repair: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface StrmAccountRepairResult {
|
||||
total: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export function precheckStrmAccountRepair(body: {
|
||||
account_id: number;
|
||||
parent_id: string;
|
||||
recursive: boolean;
|
||||
output_folder: string;
|
||||
}) {
|
||||
return http.post<StrmAccountRepairPrecheck>("/admin/strm/tasks/precheck-account-repair", body);
|
||||
}
|
||||
|
||||
export function repairStrmAccountReferences(body: {
|
||||
account_id: number;
|
||||
old_account_id: number;
|
||||
parent_id: string;
|
||||
recursive: boolean;
|
||||
output_folder: string;
|
||||
}) {
|
||||
return http.post<StrmAccountRepairResult>("/admin/strm/tasks/repair-account-references", body);
|
||||
}
|
||||
|
||||
export function updateStrmTask(id: number, body: StrmTaskInput) {
|
||||
return http.put<StrmTask>(`/admin/strm/tasks/${id}`, body);
|
||||
}
|
||||
|
||||
export function deleteStrmTask(id: number, deleteStrmFiles = false) {
|
||||
return http.del<{ id: number }>(
|
||||
`/admin/strm/tasks/${id}`,
|
||||
undefined,
|
||||
deleteStrmFiles ? { delete_strm_files: true } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export function toggleStrmTask(id: number) {
|
||||
return http.post<StrmTask>(`/admin/strm/tasks/${id}/toggle`, {});
|
||||
}
|
||||
|
||||
export function runStrmTaskNow(id: number, mode: StrmRunMode = "auto") {
|
||||
return http.post<StrmTask>(`/admin/strm/tasks/${id}/run`, {}, { mode });
|
||||
}
|
||||
|
||||
export function forceStopStrmTask(id: number) {
|
||||
return http.post<null>(`/admin/strm/tasks/${id}/force-stop`, {});
|
||||
}
|
||||
|
||||
export interface StrmCurrentDirectoryItem {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
is_dir: boolean;
|
||||
}
|
||||
|
||||
export interface StrmCurrentDirectoryResult {
|
||||
matched_task_id: number;
|
||||
created: number;
|
||||
updated: number;
|
||||
skipped_existing: number;
|
||||
skipped_conflict: number;
|
||||
skipped_path_too_long: number;
|
||||
deleted: number;
|
||||
media_count: number;
|
||||
metadata_created: number;
|
||||
}
|
||||
|
||||
export function generateCurrentDirectoryStrm(body: {
|
||||
account_id: number;
|
||||
path: string;
|
||||
items: StrmCurrentDirectoryItem[];
|
||||
}) {
|
||||
return http.post<StrmCurrentDirectoryResult>("/admin/strm/generate-current-directory", body);
|
||||
}
|
||||
|
||||
export interface StrmDirectoryStatus {
|
||||
matched_task_id: number;
|
||||
pending_strm: number;
|
||||
pending_metadata: number;
|
||||
}
|
||||
|
||||
export function fetchStrmDirectoryStatus(body: {
|
||||
account_id: number;
|
||||
path: string;
|
||||
items: StrmCurrentDirectoryItem[];
|
||||
}, signal?: AbortSignal) {
|
||||
return http.post<StrmDirectoryStatus>("/admin/strm/directory-status", body, undefined, signal);
|
||||
}
|
||||
|
||||
export function fetchStrmBranches(taskId: number) {
|
||||
return http.get<StrmBranch[]>(`/admin/strm/tasks/${taskId}/branches`);
|
||||
}
|
||||
|
||||
export function createStrmBranch(taskId: number, body: Omit<StrmBranch, "id" | "task_id">) {
|
||||
return http.post<StrmBranch>(`/admin/strm/tasks/${taskId}/branches`, body);
|
||||
}
|
||||
|
||||
export function updateStrmBranch(taskId: number, branchId: number, body: Partial<StrmBranch>) {
|
||||
return http.put<StrmBranch>(`/admin/strm/tasks/${taskId}/branches/${branchId}`, body);
|
||||
}
|
||||
|
||||
export function deleteStrmBranch(taskId: number, branchId: number) {
|
||||
return http.del<{ id: number }>(`/admin/strm/tasks/${taskId}/branches/${branchId}`);
|
||||
}
|
||||
|
||||
export function fetchStrmSettings() {
|
||||
return http.get<StrmSettings>("/admin/strm/settings");
|
||||
}
|
||||
|
||||
export function saveStrmSettings(body: Partial<Record<keyof StrmSettings, string | boolean | number>>) {
|
||||
const payload: Record<string, string> = {};
|
||||
const map: Array<[keyof StrmSettings, (v: unknown) => string]> = [
|
||||
["token", (v) => String(v)],
|
||||
["base_url", (v) => String(v)],
|
||||
["signature_enabled", (v) => (v ? "true" : "false")],
|
||||
["default_scan_interval", (v) => String(v)],
|
||||
["default_extensions", (v) => String(v)],
|
||||
["iso_filename_enabled", (v) => (v ? "true" : "false")],
|
||||
["min_file_size_mb", (v) => String(v)],
|
||||
["conflict_policy", (v) => String(v)],
|
||||
["task_concurrency", (v) => String(v)],
|
||||
["metadata_extensions", (v) => String(v)],
|
||||
["metadata_max_size_mb", (v) => String(v)],
|
||||
["metadata_parent_enabled", (v) => (v ? "true" : "false")],
|
||||
];
|
||||
for (const [key, fmt] of map) {
|
||||
if (body[key] !== undefined) payload[key] = fmt(body[key]);
|
||||
}
|
||||
return http.put<StrmSettings>("/admin/strm/settings", payload);
|
||||
}
|
||||
|
||||
export interface ReplaceStrmBaseURLResult {
|
||||
total: number;
|
||||
updated: number;
|
||||
base_url: string;
|
||||
}
|
||||
|
||||
export function replaceStrmBaseURL(newBaseURL: string) {
|
||||
return http.post<ReplaceStrmBaseURLResult>("/admin/strm/replace-base-url", {
|
||||
new_base_url: newBaseURL,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { http } from "./client";
|
||||
|
||||
export type StrmScrapeWriteMode = "missing_only" | "overwrite";
|
||||
export type StrmScrapeItemStatus = "ok" | "miss" | "doubt";
|
||||
export type StrmScrapeTVState = "ended" | "updating";
|
||||
|
||||
export interface StrmScrapeItem {
|
||||
id: string;
|
||||
rel_dir: string;
|
||||
strm_name?: string;
|
||||
title: string;
|
||||
year?: number;
|
||||
media_type: string;
|
||||
status: StrmScrapeItemStatus;
|
||||
has_nfo: boolean;
|
||||
has_poster: boolean;
|
||||
has_pending?: boolean;
|
||||
tmdb_id?: string;
|
||||
poster_url?: string;
|
||||
folder_name?: string;
|
||||
file_count: number;
|
||||
ep_local?: number;
|
||||
ep_tmdb?: number;
|
||||
ep_scraped?: number;
|
||||
tv_state?: StrmScrapeTVState | string;
|
||||
added_at?: string;
|
||||
}
|
||||
|
||||
export interface StrmScrapeProgress {
|
||||
running: boolean;
|
||||
strm_task_id: number;
|
||||
total: number;
|
||||
done: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
message: string;
|
||||
error?: string;
|
||||
started_at?: string;
|
||||
current_item_id: string;
|
||||
item_revision: number;
|
||||
updated_item?: StrmScrapeItem;
|
||||
}
|
||||
|
||||
export interface StrmScrapeRematchResult {
|
||||
item: StrmScrapeItem;
|
||||
started: boolean;
|
||||
progress: StrmScrapeProgress;
|
||||
}
|
||||
|
||||
export interface StrmScrapeSettings {
|
||||
write_mode: StrmScrapeWriteMode;
|
||||
tmdb_api_key: string;
|
||||
tmdb_language: string;
|
||||
tmdb_request_interval_ms: number;
|
||||
proxy_enabled: boolean;
|
||||
proxy_url: string;
|
||||
proxy_username: string;
|
||||
proxy_password: string;
|
||||
}
|
||||
|
||||
export function fetchStrmScrapeSettings() {
|
||||
return http.get<StrmScrapeSettings>("/admin/strm-scrape/settings");
|
||||
}
|
||||
|
||||
export function saveStrmScrapeSettings(settings: Partial<StrmScrapeSettings>) {
|
||||
return http.put<StrmScrapeSettings>("/admin/strm-scrape/settings", settings);
|
||||
}
|
||||
|
||||
export function runStrmScrape(strmTaskId: number, writeMode?: StrmScrapeWriteMode) {
|
||||
return http.post<StrmScrapeProgress>("/admin/strm-scrape/run", {
|
||||
strm_task_id: strmTaskId,
|
||||
write_mode: writeMode,
|
||||
});
|
||||
}
|
||||
|
||||
export function stopStrmScrape() {
|
||||
return http.post<StrmScrapeProgress>("/admin/strm-scrape/stop");
|
||||
}
|
||||
|
||||
export function fetchStrmScrapeProgress() {
|
||||
return http.get<StrmScrapeProgress>("/admin/strm-scrape/progress");
|
||||
}
|
||||
|
||||
export function fetchStrmScrapeItems(strmTaskId: number) {
|
||||
return http.get<{ items: StrmScrapeItem[] }>("/admin/strm-scrape/items", {
|
||||
strm_task_id: strmTaskId,
|
||||
});
|
||||
}
|
||||
|
||||
export function refreshStrmScrapeIndex(strmTaskId: number) {
|
||||
return http.post<{ items: StrmScrapeItem[] }>("/admin/strm-scrape/refresh-index", {
|
||||
strm_task_id: strmTaskId,
|
||||
});
|
||||
}
|
||||
|
||||
export function rematchStrmScrapeItem(input: {
|
||||
strm_task_id: number;
|
||||
item_id: string;
|
||||
tmdb_id: string;
|
||||
media_type: string;
|
||||
title?: string;
|
||||
year?: number;
|
||||
}) {
|
||||
return http.post<StrmScrapeRematchResult>("/admin/strm-scrape/rematch", input);
|
||||
}
|
||||
|
||||
export function markStrmScrapeNormal(input: { strm_task_id: number; item_id: string }) {
|
||||
return http.post<StrmScrapeItem>("/admin/strm-scrape/mark-normal", input);
|
||||
}
|
||||
|
||||
export function rescrapeStrmScrapeItem(input: { strm_task_id: number; item_id: string }) {
|
||||
return http.post<StrmScrapeRematchResult>("/admin/strm-scrape/rescrape", input);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// 与后端 internal/api 的响应契约一一对应。
|
||||
|
||||
export interface ApiResp<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message: string;
|
||||
error_type?: string;
|
||||
details?: unknown;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface FieldOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// 驱动配置字段,后端反射 Addition 的 tag 生成,用于动态渲染表单。
|
||||
export interface FieldSchema {
|
||||
name: string;
|
||||
label: string;
|
||||
type: "string" | "select" | "number" | "bool" | "password" | "local_dir";
|
||||
required: boolean;
|
||||
default?: string;
|
||||
options?: FieldOption[];
|
||||
full_width?: boolean;
|
||||
pair_key?: string;
|
||||
}
|
||||
|
||||
export interface DriverInfo {
|
||||
name: string;
|
||||
display_name: string;
|
||||
description?: string;
|
||||
card_tags?: string[];
|
||||
sort_order?: number;
|
||||
auth_label?: string;
|
||||
card_color: string;
|
||||
card_logo?: string;
|
||||
auth_type: string;
|
||||
supports_oauth?: boolean;
|
||||
supports_qr_login?: boolean;
|
||||
fields: FieldSchema[];
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: number;
|
||||
name: string;
|
||||
driver_type: string;
|
||||
driver_card_name?: string;
|
||||
driver_card_color?: string;
|
||||
driver_card_logo?: string;
|
||||
auth_status?: "active" | "cooldown" | "token_expired" | "failed" | string;
|
||||
auth_last_error?: string;
|
||||
config: string; // JSON 字符串
|
||||
is_active: boolean;
|
||||
is_default: boolean;
|
||||
sort_order: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// 新建/更新账号时的载荷(config 为 JSON 字符串)。
|
||||
export interface AccountPayload {
|
||||
name: string;
|
||||
driver_type: string;
|
||||
config: string;
|
||||
is_active: boolean;
|
||||
is_default: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface FileItem {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
is_dir: boolean;
|
||||
mod_time?: string;
|
||||
}
|
||||
|
||||
export interface FileListResult {
|
||||
parent_id: string;
|
||||
items: FileItem[];
|
||||
}
|
||||
|
||||
export interface FileDeletePayload {
|
||||
account_id: number;
|
||||
file_ids: string[];
|
||||
parent_id?: string;
|
||||
}
|
||||
|
||||
export interface FileTransferPayload {
|
||||
account_id: number;
|
||||
file_ids: string[];
|
||||
target_parent_id: string;
|
||||
source_parent_id?: string;
|
||||
}
|
||||
|
||||
export interface FileRenamePayload {
|
||||
account_id: number;
|
||||
file_id: string;
|
||||
new_name: string;
|
||||
parent_id?: string;
|
||||
}
|
||||
|
||||
export interface FileCreateFolderPayload {
|
||||
account_id: number;
|
||||
parent_id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface FileNameAlignPreviewPayload {
|
||||
account_id: number;
|
||||
parent_id: string;
|
||||
target_file_id: string;
|
||||
sample_file_id?: string;
|
||||
}
|
||||
|
||||
export interface FileNameAlignApplyPayload {
|
||||
account_id: number;
|
||||
parent_id: string;
|
||||
target_file_id: string;
|
||||
sample_file_id?: string;
|
||||
selected_file_ids: string[];
|
||||
}
|
||||
|
||||
export interface FileNameAlignSample {
|
||||
file_id: string;
|
||||
file_name: string;
|
||||
pattern_label: string;
|
||||
}
|
||||
|
||||
export interface FileNameAlignPreviewItem {
|
||||
file_id: string;
|
||||
file_name: string;
|
||||
new_name: string;
|
||||
episode: number;
|
||||
season?: number;
|
||||
pattern_hint: string;
|
||||
}
|
||||
|
||||
export interface FileNameAlignPreviewResult {
|
||||
target: FileNameAlignPreviewItem;
|
||||
sample: FileNameAlignSample;
|
||||
sample_candidates: FileNameAlignSample[];
|
||||
suspects: FileNameAlignPreviewItem[];
|
||||
}
|
||||
|
||||
export interface FileNameAlignApplyResult {
|
||||
renamed: Array<{
|
||||
file_id: string;
|
||||
old_name: string;
|
||||
new_name: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface BrowserFavoriteCrumb {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface BrowserFavoriteItem {
|
||||
id: string;
|
||||
name: string;
|
||||
crumbs: BrowserFavoriteCrumb[];
|
||||
}
|
||||
|
||||
export interface BrowserFavoritesState {
|
||||
open: boolean;
|
||||
items: BrowserFavoriteItem[];
|
||||
}
|
||||
|
||||
export interface BrowserFavoritesPayload extends BrowserFavoritesState {
|
||||
account_id: number;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ApiError } from "./client";
|
||||
import type { ApiResp } from "./types";
|
||||
import type { BatchDeleteUploadResult, UploadTask } from "@/types/upload";
|
||||
|
||||
async function parseJSON<T>(resp: Response): Promise<T> {
|
||||
const payload = (await resp.json()) as ApiResp<T>;
|
||||
if (!payload.success) {
|
||||
throw new ApiError(payload.message || "请求失败", payload.error_type || "unknown", resp.status);
|
||||
}
|
||||
return payload.data as T;
|
||||
}
|
||||
|
||||
export interface UploadRuntimeConfig {
|
||||
concurrency: number;
|
||||
concurrency_min?: number;
|
||||
concurrency_max?: number;
|
||||
}
|
||||
|
||||
export const uploadApi = {
|
||||
getRuntime() {
|
||||
return fetch("/api/files/upload/runtime").then((r) => parseJSON<UploadRuntimeConfig>(r));
|
||||
},
|
||||
|
||||
updateRuntime(concurrency: number) {
|
||||
return fetch("/api/files/upload/runtime", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ concurrency }),
|
||||
}).then((r) => parseJSON<UploadRuntimeConfig>(r));
|
||||
},
|
||||
|
||||
listTasks(accountId?: number) {
|
||||
const qs = accountId != null ? `?account_id=${accountId}` : "";
|
||||
return fetch(`/api/files/upload/tasks${qs}`).then((r) => parseJSON<UploadTask[]>(r));
|
||||
},
|
||||
|
||||
pauseTask(taskId: string) {
|
||||
return fetch(`/api/files/upload/tasks/${taskId}/pause`, { method: "POST" }).then((r) =>
|
||||
parseJSON<UploadTask>(r),
|
||||
);
|
||||
},
|
||||
|
||||
resumeTask(taskId: string) {
|
||||
return fetch(`/api/files/upload/tasks/${taskId}/resume`, { method: "POST" }).then((r) =>
|
||||
parseJSON<UploadTask>(r),
|
||||
);
|
||||
},
|
||||
|
||||
deleteTask(taskId: string, deleteUploadedFile = false) {
|
||||
const qs = deleteUploadedFile ? "?delete_uploaded_file=true" : "";
|
||||
return fetch(`/api/files/upload/tasks/${taskId}${qs}`, { method: "DELETE" }).then((r) =>
|
||||
parseJSON<unknown>(r),
|
||||
);
|
||||
},
|
||||
|
||||
batchDelete(taskIds: string[], deleteUploadedFile = false) {
|
||||
return fetch("/api/files/upload/tasks/batch-delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ task_ids: taskIds, delete_uploaded_file: deleteUploadedFile }),
|
||||
}).then((r) => parseJSON<BatchDeleteUploadResult>(r));
|
||||
},
|
||||
|
||||
createTask(
|
||||
formData: FormData,
|
||||
opts?: { signal?: AbortSignal; onProgress?: (loaded: number, total: number) => void },
|
||||
): Promise<UploadTask> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/files/upload-task");
|
||||
if (opts?.signal) {
|
||||
opts.signal.addEventListener("abort", () => xhr.abort());
|
||||
}
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (opts?.onProgress && e.lengthComputable) {
|
||||
opts.onProgress(e.loaded, e.total);
|
||||
}
|
||||
};
|
||||
xhr.onload = () => {
|
||||
try {
|
||||
const payload = JSON.parse(xhr.responseText) as ApiResp<UploadTask>;
|
||||
if (!payload.success) {
|
||||
reject(new ApiError(payload.message || "创建上传任务失败", payload.error_type || "unknown", xhr.status));
|
||||
return;
|
||||
}
|
||||
resolve(payload.data as UploadTask);
|
||||
} catch {
|
||||
reject(new ApiError("创建上传任务失败", "parse_error", xhr.status));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new ApiError("网络请求失败", "network_error", 0));
|
||||
xhr.onabort = () => reject(new ApiError("任务已取消", "canceled", 0));
|
||||
xhr.send(formData);
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
window._iconfont_svg_string_='<svg><symbol id="icon-71shibai" viewBox="0 0 1024 1024"><path d="M511.999488 65.290005C265.29162 65.290005 65.290517 265.291109 65.290517 511.998977c0 246.708891 200.001103 446.709995 446.708971 446.709995S958.70846 758.707868 958.70846 511.998977C958.70846 265.291109 758.707356 65.290005 511.999488 65.290005L511.999488 65.290005zM716.82855 637.854383c6.803963 6.819313 6.803963 17.856693 0 24.676006l-54.298673 54.29765c-6.819313 6.804986-17.85567 6.820336-24.676006 0L511.999488 590.973656 386.144082 716.828039c-6.819313 6.804986-17.871019 6.804986-24.676006 0l-54.29765-54.29765c-6.804986-6.804986-6.804986-17.856693 0-24.676006l125.869732-125.855406L307.170426 386.144594c-6.804986-6.819313-6.804986-17.871019 0-24.676006l54.29765-54.298673c6.820336-6.803963 17.856693-6.803963 24.676006 0l125.855406 125.870756 125.854383-125.870756c6.820336-6.803963 17.856693-6.803963 24.676006 0l54.298673 54.298673c6.803963 6.804986 6.803963 17.856693 0 24.676006L590.973144 511.998977 716.82855 637.854383 716.82855 637.854383zM716.82855 637.854383" fill="#FF696A" ></path></symbol><symbol id="icon-fawenguanli" viewBox="0 0 1024 1024"><path d="M950.817234 907.130246c-7.999185 34.139541-32.746822 109.706611-73.137741 109.706611l-73.137741 0L219.435735 1016.836857l-73.138764 0c-40.389896 0-73.137741-19.641339-73.137741-43.926442L0.021489 621.86113c0-24.212448 32.747845-43.85481 73.138764-43.85481l132.563178 0 13.713326-87.779206c0-32.31908 32.747845-58.496276 73.137741-58.496276L603.376127 431.730838c16.856923-31.927153 41.247426-73.138764 54.890144-73.138764l329.121879 0c33.889855 1.143033 36.56887 89.49529 36.56887 109.707634L950.817234 907.130246zM889.85786 504.868579 341.320713 504.868579c-33.641191 0-60.924581 21.284769-60.924581 47.497781l-4.143367 25.640983 455.150224 0c40.389896 0 65.137533 18.285458 73.138764 43.85481l69.387323 333.192591c1.286296-3.571339 2.6432-6.856152 3.750417-11.356652l73.137741-402.261667C950.817234 515.152806 923.534867 504.868579 889.85786 504.868579zM331.35678 265.741363c-34.533514 3.999081-127.312594 41.211611-167.168324 82.637092-83.566254 86.922698-89.422635 122.670877-91.029226 193.057971-71.38891-184.093808-41.033555-269.482571 53.175083-334.334601 22.926152-15.784498 74.566276-48.711422 96.529497-63.711044l-104.029308-85.708034 332.336084-51.211359-56.102762 355.762633L331.35678 265.741363z" fill="#2C83DB" ></path></symbol><symbol id="icon-ppt" viewBox="0 0 1024 1024"><path d="M145.6 0C100.8 0 64 36.8 64 81.6v860.8C64 987.2 100.8 1024 145.6 1024h732.8c44.8 0 81.6-36.8 81.6-81.6V324.8L657.6 0h-512z" fill="#E34221" ></path><path d="M960 326.4v16H755.2s-100.8-20.8-99.2-108.8c0 0 4.8 92.8 97.6 92.8H960z" fill="#DC3119" ></path><path d="M657.6 0v233.6c0 25.6 17.6 92.8 97.6 92.8H960L657.6 0z" fill="#FFFFFF" ></path><path d="M304 784h-54.4v67.2c0 6.4-4.8 11.2-11.2 11.2-6.4 0-12.8-4.8-12.8-11.2V686.4c0-9.6 8-17.6 17.6-17.6H304c38.4 0 59.2 25.6 59.2 57.6S340.8 784 304 784z m-3.2-94.4h-51.2v73.6h51.2c22.4 0 38.4-16 38.4-36.8 0-22.4-16-36.8-38.4-36.8zM480 784h-54.4v67.2c0 6.4-4.8 11.2-11.2 11.2-6.4 0-11.2-4.8-11.2-11.2V686.4c0-9.6 6.4-17.6 16-17.6H480c38.4 0 59.2 25.6 59.2 57.6S518.4 784 480 784z m-3.2-94.4h-49.6v73.6h49.6c22.4 0 38.4-16 38.4-36.8 0-22.4-16-36.8-38.4-36.8z m225.6 0h-52.8v161.6c0 6.4-4.8 11.2-11.2 11.2-6.4 0-12.8-4.8-12.8-11.2V689.6h-51.2c-6.4 0-11.2-4.8-11.2-11.2 0-4.8 4.8-9.6 11.2-9.6h128c6.4 0 11.2 4.8 11.2 11.2 0 4.8-4.8 9.6-11.2 9.6z" fill="#FFFFFF" ></path></symbol><symbol id="icon-wenjian" viewBox="0 0 1024 1024"><path d="M752 80H272c-70.4 0-128 57.6-128 128v608c0 70.4 57.6 128 128 128h353.6c33.6 0 65.6-12.8 91.2-36.8l126.4-126.4c24-24 36.8-56 36.8-91.2V208c0-70.4-57.6-128-128-128zM208 816V208c0-35.2 28.8-64 64-64h480c35.2 0 64 28.8 64 64v464h-96c-70.4 0-128 57.6-128 128v80H272c-35.2 0-64-28.8-64-64z m462.4 44.8c-4.8 4.8-9.6 8-14.4 11.2V800c0-35.2 28.8-64 64-64h75.2l-124.8 124.8z" fill="#4A576A" ></path><path d="M368 352h288c17.6 0 32-14.4 32-32s-14.4-32-32-32H368c-17.6 0-32 14.4-32 32s14.4 32 32 32zM496 608h-128c-17.6 0-32 14.4-32 32s14.4 32 32 32h128c17.6 0 32-14.4 32-32s-14.4-32-32-32zM368 512h288c17.6 0 32-14.4 32-32s-14.4-32-32-32H368c-17.6 0-32 14.4-32 32s14.4 32 32 32z" fill="#4A576A" ></path></symbol><symbol id="icon-sousuo" viewBox="0 0 1024 1024"><path d="M929.299 739.872L774.98 634.816c31.086-51.044 48.995-110.994 48.995-175.129 0-186.22-150.96-337.181-337.18-337.181s-337.18 150.962-337.18 337.182c0 186.218 150.961 337.179 337.181 337.179 101.842 0 193.139-45.151 254.964-116.527l155.843 106.097a28.021 28.021 0 0 0 15.823 4.886c9.006 0 17.854-4.312 23.306-12.316 8.752-12.86 5.424-30.377-7.433-39.135zM596.1 718.493c-34.589 14.629-71.364 22.047-109.304 22.047s-74.716-7.418-109.305-22.047c-33.437-14.142-63.478-34.4-89.29-60.213-25.811-25.811-46.068-55.852-60.213-89.29-14.63-34.589-22.047-71.368-22.047-109.303 0-37.941 7.417-74.715 22.047-109.304 14.145-33.438 34.402-63.48 60.213-89.29 25.813-25.813 55.854-46.07 89.29-60.214 34.589-14.63 71.Line truncated
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
@@ -0,0 +1,500 @@
|
||||
<template>
|
||||
<div ref="containerRef" :style="{ position: 'relative', width: '550px', height: '400px' }">
|
||||
<div ref="purpleRef" :style="purpleStyle">
|
||||
<div ref="purpleFaceRef" :style="purpleFaceStyle">
|
||||
<div class="eyeball" data-max-distance="5" :style="smallEyeStyle">
|
||||
<div class="eyeball-pupil" :style="smallPupilStyle" />
|
||||
</div>
|
||||
<div class="eyeball" data-max-distance="5" :style="smallEyeStyle">
|
||||
<div class="eyeball-pupil" :style="smallPupilStyle" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="blackRef" :style="blackStyle">
|
||||
<div ref="blackFaceRef" :style="blackFaceStyle">
|
||||
<div class="eyeball" data-max-distance="4" :style="blackEyeStyle">
|
||||
<div class="eyeball-pupil" :style="blackPupilStyle" />
|
||||
</div>
|
||||
<div class="eyeball" data-max-distance="4" :style="blackEyeStyle">
|
||||
<div class="eyeball-pupil" :style="blackPupilStyle" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="orangeRef" :style="orangeStyle">
|
||||
<div ref="orangeFaceRef" :style="orangeFaceStyle">
|
||||
<div class="pupil" data-max-distance="5" :style="dotPupilStyle" />
|
||||
<div class="pupil" data-max-distance="5" :style="dotPupilStyle" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="yellowRef" :style="yellowStyle">
|
||||
<div ref="yellowFaceRef" :style="yellowFaceStyle">
|
||||
<div class="pupil" data-max-distance="5" :style="dotPupilStyle" />
|
||||
<div class="pupil" data-max-distance="5" :style="dotPupilStyle" />
|
||||
</div>
|
||||
<div ref="yellowMouthRef" :style="yellowMouthStyle" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import gsap from 'gsap'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
isTyping: { type: Boolean, default: false },
|
||||
showPassword: { type: Boolean, default: false },
|
||||
passwordLength: { type: Number, default: 0 },
|
||||
isPasswordGuardMode: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const containerRef = ref(null)
|
||||
const mouseRef = ref({ x: 0, y: 0 })
|
||||
const rafIdRef = ref(0)
|
||||
|
||||
const purpleRef = ref(null)
|
||||
const blackRef = ref(null)
|
||||
const yellowRef = ref(null)
|
||||
const orangeRef = ref(null)
|
||||
|
||||
const purpleFaceRef = ref(null)
|
||||
const blackFaceRef = ref(null)
|
||||
const yellowFaceRef = ref(null)
|
||||
const orangeFaceRef = ref(null)
|
||||
const yellowMouthRef = ref(null)
|
||||
|
||||
const purpleBlinkTimerRef = ref()
|
||||
const blackBlinkTimerRef = ref()
|
||||
const purplePeekTimerRef = ref()
|
||||
const lookingTimerRef = ref()
|
||||
|
||||
const isLookingRef = ref(false)
|
||||
const isHidingPassword = computed(() => props.passwordLength > 0 && !props.showPassword)
|
||||
const isShowingPassword = computed(() => props.passwordLength > 0 && props.showPassword)
|
||||
|
||||
const stateRef = ref({
|
||||
isTyping: props.isTyping,
|
||||
isHidingPassword: isHidingPassword.value,
|
||||
isShowingPassword: isShowingPassword.value,
|
||||
isLooking: false,
|
||||
isPasswordGuardMode: props.isPasswordGuardMode,
|
||||
})
|
||||
|
||||
const quickToRef = ref(null)
|
||||
|
||||
const purpleStyle = {
|
||||
position: 'absolute', bottom: 0, left: '70px', width: '180px', height: '400px',
|
||||
backgroundColor: '#6C3FF5', borderRadius: '10px 10px 0 0', zIndex: 1,
|
||||
transformOrigin: 'bottom center', willChange: 'transform',
|
||||
}
|
||||
|
||||
const blackStyle = {
|
||||
position: 'absolute', bottom: 0, left: '240px', width: '120px', height: '310px',
|
||||
backgroundColor: '#2D2D2D', borderRadius: '8px 8px 0 0', zIndex: 2,
|
||||
transformOrigin: 'bottom center', willChange: 'transform',
|
||||
}
|
||||
|
||||
const orangeStyle = {
|
||||
position: 'absolute', bottom: 0, left: 0, width: '240px', height: '200px',
|
||||
backgroundColor: '#FF9B6B', borderRadius: '120px 120px 0 0', zIndex: 3,
|
||||
transformOrigin: 'bottom center', willChange: 'transform',
|
||||
}
|
||||
|
||||
const yellowStyle = {
|
||||
position: 'absolute', bottom: 0, left: '310px', width: '140px', height: '230px',
|
||||
backgroundColor: '#E8D754', borderRadius: '70px 70px 0 0', zIndex: 4,
|
||||
transformOrigin: 'bottom center', willChange: 'transform',
|
||||
}
|
||||
|
||||
const purpleFaceStyle = { position: 'absolute', display: 'flex', gap: '32px', left: '45px', top: '40px' }
|
||||
const blackFaceStyle = { position: 'absolute', display: 'flex', gap: '24px', left: '26px', top: '32px' }
|
||||
const orangeFaceStyle = { position: 'absolute', display: 'flex', gap: '32px', left: '82px', top: '90px' }
|
||||
const yellowFaceStyle = { position: 'absolute', display: 'flex', gap: '24px', left: '52px', top: '40px' }
|
||||
const yellowMouthStyle = {
|
||||
position: 'absolute', width: '80px', height: '4px', backgroundColor: '#2D2D2D',
|
||||
borderRadius: '9999px', left: '40px', top: '88px',
|
||||
}
|
||||
|
||||
const eyeBase = {
|
||||
borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
overflow: 'hidden', willChange: 'height',
|
||||
}
|
||||
|
||||
const smallEyeStyle = { ...eyeBase, width: '18px', height: '18px', backgroundColor: 'white' }
|
||||
const blackEyeStyle = { ...eyeBase, width: '16px', height: '16px', backgroundColor: 'white' }
|
||||
const smallPupilStyle = { width: '7px', height: '7px', borderRadius: '50%', backgroundColor: '#2D2D2D', willChange: 'transform' }
|
||||
const blackPupilStyle = { width: '6px', height: '6px', borderRadius: '50%', backgroundColor: '#2D2D2D', willChange: 'transform' }
|
||||
const dotPupilStyle = { width: '12px', height: '12px', borderRadius: '50%', backgroundColor: '#2D2D2D', willChange: 'transform' }
|
||||
|
||||
const calcPos = (el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const cx = rect.left + rect.width / 2
|
||||
const cy = rect.top + rect.height / 3
|
||||
const dx = mouseRef.value.x - cx
|
||||
const dy = mouseRef.value.y - cy
|
||||
return {
|
||||
faceX: Math.max(-15, Math.min(15, dx / 20)),
|
||||
faceY: Math.max(-10, Math.min(10, dy / 30)),
|
||||
bodySkew: Math.max(-6, Math.min(6, -dx / 120)),
|
||||
}
|
||||
}
|
||||
|
||||
const calcEyePos = (el, maxDist) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
const cx = r.left + r.width / 2
|
||||
const cy = r.top + r.height / 2
|
||||
const dx = mouseRef.value.x - cx
|
||||
const dy = mouseRef.value.y - cy
|
||||
const dist = Math.min(Math.sqrt(dx ** 2 + dy ** 2), maxDist)
|
||||
const angle = Math.atan2(dy, dx)
|
||||
return { x: Math.cos(angle) * dist, y: Math.sin(angle) * dist }
|
||||
}
|
||||
|
||||
const applyLookAtEachOther = () => {
|
||||
const qt = quickToRef.value
|
||||
if (qt) {
|
||||
qt.purpleFaceLeft(55)
|
||||
qt.purpleFaceTop(65)
|
||||
qt.blackFaceLeft(32)
|
||||
qt.blackFaceTop(12)
|
||||
}
|
||||
purpleRef.value?.querySelectorAll('.eyeball-pupil').forEach((p) => {
|
||||
gsap.to(p, { x: 3, y: 4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
|
||||
})
|
||||
blackRef.value?.querySelectorAll('.eyeball-pupil').forEach((p) => {
|
||||
gsap.to(p, { x: 0, y: -4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
|
||||
})
|
||||
}
|
||||
|
||||
const applyHidingPassword = () => {
|
||||
const qt = quickToRef.value
|
||||
if (qt) {
|
||||
qt.purpleFaceLeft(55)
|
||||
qt.purpleFaceTop(65)
|
||||
}
|
||||
}
|
||||
|
||||
const applyShowPassword = () => {
|
||||
const qt = quickToRef.value
|
||||
if (qt) {
|
||||
qt.purpleSkew(0); qt.blackSkew(0); qt.orangeSkew(0); qt.yellowSkew(0)
|
||||
qt.purpleX(0); qt.blackX(0)
|
||||
qt.purpleHeight(400)
|
||||
qt.purpleFaceLeft(20); qt.purpleFaceTop(35)
|
||||
qt.blackFaceLeft(10); qt.blackFaceTop(28)
|
||||
qt.orangeFaceX(50 - 82); qt.orangeFaceY(85 - 90)
|
||||
qt.yellowFaceX(20 - 52); qt.yellowFaceY(35 - 40)
|
||||
qt.mouthX(10 - 40); qt.mouthY(0)
|
||||
}
|
||||
purpleRef.value?.querySelectorAll('.eyeball-pupil').forEach((p) => {
|
||||
gsap.to(p, { x: -4, y: -4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
|
||||
})
|
||||
blackRef.value?.querySelectorAll('.eyeball-pupil').forEach((p) => {
|
||||
gsap.to(p, { x: -4, y: -4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
|
||||
})
|
||||
orangeRef.value?.querySelectorAll('.pupil').forEach((p) => {
|
||||
gsap.to(p, { x: -5, y: -4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
|
||||
})
|
||||
yellowRef.value?.querySelectorAll('.pupil').forEach((p) => {
|
||||
gsap.to(p, { x: -5, y: -4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
|
||||
})
|
||||
}
|
||||
|
||||
const applyPasswordGuardMode = () => {
|
||||
const qt = quickToRef.value
|
||||
if (qt) {
|
||||
qt.purpleSkew(0); qt.blackSkew(0); qt.orangeSkew(0); qt.yellowSkew(0)
|
||||
qt.purpleX(0); qt.blackX(0)
|
||||
qt.purpleHeight(400)
|
||||
qt.purpleFaceLeft(24); qt.purpleFaceTop(22)
|
||||
qt.blackFaceLeft(14); qt.blackFaceTop(20)
|
||||
qt.orangeFaceX(22 - 82); qt.orangeFaceY(72 - 90)
|
||||
qt.yellowFaceX(12 - 52); qt.yellowFaceY(22 - 40)
|
||||
qt.mouthX(-14); qt.mouthY(-8)
|
||||
}
|
||||
purpleRef.value?.querySelectorAll('.eyeball-pupil').forEach((p) => {
|
||||
gsap.to(p, { x: -5, y: -5, duration: 0.25, ease: 'power2.out', overwrite: 'auto' })
|
||||
})
|
||||
blackRef.value?.querySelectorAll('.eyeball-pupil').forEach((p) => {
|
||||
gsap.to(p, { x: -4, y: -4, duration: 0.25, ease: 'power2.out', overwrite: 'auto' })
|
||||
})
|
||||
orangeRef.value?.querySelectorAll('.pupil').forEach((p) => {
|
||||
gsap.to(p, { x: -5, y: -5, duration: 0.25, ease: 'power2.out', overwrite: 'auto' })
|
||||
})
|
||||
yellowRef.value?.querySelectorAll('.pupil').forEach((p) => {
|
||||
gsap.to(p, { x: -5, y: -5, duration: 0.25, ease: 'power2.out', overwrite: 'auto' })
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
gsap.set('.pupil', { x: 0, y: 0 })
|
||||
gsap.set('.eyeball-pupil', { x: 0, y: 0 })
|
||||
|
||||
if (!purpleRef.value || !blackRef.value || !orangeRef.value || !yellowRef.value ||
|
||||
!purpleFaceRef.value || !blackFaceRef.value || !orangeFaceRef.value ||
|
||||
!yellowFaceRef.value || !yellowMouthRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
quickToRef.value = {
|
||||
purpleSkew: gsap.quickTo(purpleRef.value, 'skewX', { duration: 0.3, ease: 'power2.out' }),
|
||||
blackSkew: gsap.quickTo(blackRef.value, 'skewX', { duration: 0.3, ease: 'power2.out' }),
|
||||
orangeSkew: gsap.quickTo(orangeRef.value, 'skewX', { duration: 0.3, ease: 'power2.out' }),
|
||||
yellowSkew: gsap.quickTo(yellowRef.value, 'skewX', { duration: 0.3, ease: 'power2.out' }),
|
||||
purpleX: gsap.quickTo(purpleRef.value, 'x', { duration: 0.3, ease: 'power2.out' }),
|
||||
blackX: gsap.quickTo(blackRef.value, 'x', { duration: 0.3, ease: 'power2.out' }),
|
||||
purpleHeight: gsap.quickTo(purpleRef.value, 'height', { duration: 0.3, ease: 'power2.out' }),
|
||||
purpleFaceLeft: gsap.quickTo(purpleFaceRef.value, 'left', { duration: 0.3, ease: 'power2.out' }),
|
||||
purpleFaceTop: gsap.quickTo(purpleFaceRef.value, 'top', { duration: 0.3, ease: 'power2.out' }),
|
||||
blackFaceLeft: gsap.quickTo(blackFaceRef.value, 'left', { duration: 0.3, ease: 'power2.out' }),
|
||||
blackFaceTop: gsap.quickTo(blackFaceRef.value, 'top', { duration: 0.3, ease: 'power2.out' }),
|
||||
orangeFaceX: gsap.quickTo(orangeFaceRef.value, 'x', { duration: 0.2, ease: 'power2.out' }),
|
||||
orangeFaceY: gsap.quickTo(orangeFaceRef.value, 'y', { duration: 0.2, ease: 'power2.out' }),
|
||||
yellowFaceX: gsap.quickTo(yellowFaceRef.value, 'x', { duration: 0.2, ease: 'power2.out' }),
|
||||
yellowFaceY: gsap.quickTo(yellowFaceRef.value, 'y', { duration: 0.2, ease: 'power2.out' }),
|
||||
mouthX: gsap.quickTo(yellowMouthRef.value, 'x', { duration: 0.2, ease: 'power2.out' }),
|
||||
mouthY: gsap.quickTo(yellowMouthRef.value, 'y', { duration: 0.2, ease: 'power2.out' }),
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
const container = containerRef.value
|
||||
const qt = quickToRef.value
|
||||
if (!container || !qt) return
|
||||
|
||||
const { isTyping, isHidingPassword, isShowingPassword, isLooking, isPasswordGuardMode } = stateRef.value
|
||||
|
||||
if (isPasswordGuardMode) {
|
||||
applyPasswordGuardMode()
|
||||
rafIdRef.value = requestAnimationFrame(tick)
|
||||
return
|
||||
}
|
||||
|
||||
if (purpleRef.value && !isShowingPassword) {
|
||||
const pp = calcPos(purpleRef.value)
|
||||
if (isTyping || isHidingPassword) {
|
||||
qt.purpleSkew(pp.bodySkew - 12)
|
||||
qt.purpleX(40)
|
||||
qt.purpleHeight(440)
|
||||
} else {
|
||||
qt.purpleSkew(pp.bodySkew)
|
||||
qt.purpleX(0)
|
||||
qt.purpleHeight(400)
|
||||
}
|
||||
}
|
||||
|
||||
if (blackRef.value && !isShowingPassword) {
|
||||
const bp = calcPos(blackRef.value)
|
||||
if (isLooking) {
|
||||
qt.blackSkew(bp.bodySkew * 1.5 + 10)
|
||||
qt.blackX(20)
|
||||
} else if (isTyping || isHidingPassword) {
|
||||
qt.blackSkew(bp.bodySkew * 1.5)
|
||||
qt.blackX(0)
|
||||
} else {
|
||||
qt.blackSkew(bp.bodySkew)
|
||||
qt.blackX(0)
|
||||
}
|
||||
}
|
||||
|
||||
if (orangeRef.value && !isShowingPassword) {
|
||||
const op = calcPos(orangeRef.value)
|
||||
qt.orangeSkew(op.bodySkew)
|
||||
qt.orangeFaceX(op.faceX)
|
||||
qt.orangeFaceY(op.faceY)
|
||||
}
|
||||
|
||||
if (yellowRef.value && !isShowingPassword) {
|
||||
const yp = calcPos(yellowRef.value)
|
||||
qt.yellowSkew(yp.bodySkew)
|
||||
qt.yellowFaceX(yp.faceX)
|
||||
qt.yellowFaceY(yp.faceY)
|
||||
qt.mouthX(yp.faceX)
|
||||
qt.mouthY(yp.faceY)
|
||||
}
|
||||
|
||||
if (purpleRef.value && !isShowingPassword && !isLooking) {
|
||||
const pp = calcPos(purpleRef.value)
|
||||
const purpleFaceX = pp.faceX >= 0 ? Math.min(25, pp.faceX * 1.5) : pp.faceX
|
||||
qt.purpleFaceLeft(45 + purpleFaceX)
|
||||
qt.purpleFaceTop(40 + pp.faceY)
|
||||
}
|
||||
|
||||
if (blackRef.value && !isShowingPassword && !isLooking) {
|
||||
const bp = calcPos(blackRef.value)
|
||||
qt.blackFaceLeft(26 + bp.faceX)
|
||||
qt.blackFaceTop(32 + bp.faceY)
|
||||
}
|
||||
|
||||
if (!isShowingPassword) {
|
||||
container.querySelectorAll('.pupil').forEach((p) => {
|
||||
const el = p
|
||||
const maxDist = Number(el.dataset.maxDistance) || 5
|
||||
const ePos = calcEyePos(el, maxDist)
|
||||
gsap.set(el, { x: ePos.x, y: ePos.y })
|
||||
})
|
||||
|
||||
if (!isLooking) {
|
||||
container.querySelectorAll('.eyeball').forEach((eb) => {
|
||||
const el = eb
|
||||
const maxDist = Number(el.dataset.maxDistance) || 10
|
||||
const pupil = el.querySelector('.eyeball-pupil')
|
||||
if (!pupil) return
|
||||
const ePos = calcEyePos(el, maxDist)
|
||||
gsap.set(pupil, { x: ePos.x, y: ePos.y })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
rafIdRef.value = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
const onMove = (e) => {
|
||||
mouseRef.value = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMove, { passive: true })
|
||||
rafIdRef.value = requestAnimationFrame(tick)
|
||||
|
||||
const purpleEyeballs = purpleRef.value.querySelectorAll('.eyeball')
|
||||
const blackEyeballs = blackRef.value.querySelectorAll('.eyeball')
|
||||
|
||||
const schedulePurpleBlink = () => {
|
||||
purpleBlinkTimerRef.value = setTimeout(() => {
|
||||
purpleEyeballs.forEach((el) => {
|
||||
gsap.to(el, { height: 2, duration: 0.08, ease: 'power2.in' })
|
||||
})
|
||||
setTimeout(() => {
|
||||
purpleEyeballs.forEach((el) => {
|
||||
const size = Number(el.style.width.replace('px', '')) || 18
|
||||
gsap.to(el, { height: size, duration: 0.08, ease: 'power2.out' })
|
||||
})
|
||||
schedulePurpleBlink()
|
||||
}, 150)
|
||||
}, Math.random() * 4000 + 3000)
|
||||
}
|
||||
|
||||
const scheduleBlackBlink = () => {
|
||||
blackBlinkTimerRef.value = setTimeout(() => {
|
||||
blackEyeballs.forEach((el) => {
|
||||
gsap.to(el, { height: 2, duration: 0.08, ease: 'power2.in' })
|
||||
})
|
||||
setTimeout(() => {
|
||||
blackEyeballs.forEach((el) => {
|
||||
const size = Number(el.style.width.replace('px', '')) || 16
|
||||
gsap.to(el, { height: size, duration: 0.08, ease: 'power2.out' })
|
||||
})
|
||||
scheduleBlackBlink()
|
||||
}, 150)
|
||||
}, Math.random() * 4000 + 3000)
|
||||
}
|
||||
|
||||
schedulePurpleBlink()
|
||||
scheduleBlackBlink()
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
cancelAnimationFrame(rafIdRef.value)
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.isTyping, isHidingPassword.value, isShowingPassword.value, isLookingRef.value, props.isPasswordGuardMode],
|
||||
() => {
|
||||
stateRef.value = {
|
||||
isTyping: props.isTyping,
|
||||
isHidingPassword: isHidingPassword.value,
|
||||
isShowingPassword: isShowingPassword.value,
|
||||
isLooking: isLookingRef.value,
|
||||
isPasswordGuardMode: props.isPasswordGuardMode,
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [isShowingPassword.value, props.passwordLength],
|
||||
() => {
|
||||
if (props.isPasswordGuardMode || !isShowingPassword.value || props.passwordLength <= 0) {
|
||||
clearTimeout(purplePeekTimerRef.value)
|
||||
return
|
||||
}
|
||||
const purpleEyePupils = purpleRef.value?.querySelectorAll('.eyeball-pupil')
|
||||
if (!purpleEyePupils?.length) return
|
||||
|
||||
const schedulePeek = () => {
|
||||
purplePeekTimerRef.value = setTimeout(() => {
|
||||
purpleEyePupils.forEach((p) => {
|
||||
gsap.to(p, { x: 4, y: 5, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
|
||||
})
|
||||
const qt = quickToRef.value
|
||||
if (qt) {
|
||||
qt.purpleFaceLeft(20)
|
||||
qt.purpleFaceTop(35)
|
||||
}
|
||||
setTimeout(() => {
|
||||
purpleEyePupils.forEach((p) => {
|
||||
gsap.to(p, { x: -4, y: -4, duration: 0.3, ease: 'power2.out', overwrite: 'auto' })
|
||||
})
|
||||
schedulePeek()
|
||||
}, 800)
|
||||
}, Math.random() * 3000 + 2000)
|
||||
}
|
||||
schedulePeek()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.isTyping, isShowingPassword.value],
|
||||
() => {
|
||||
if (props.isPasswordGuardMode) {
|
||||
clearTimeout(lookingTimerRef.value)
|
||||
isLookingRef.value = false
|
||||
stateRef.value.isLooking = false
|
||||
return
|
||||
}
|
||||
if (props.isTyping && !isShowingPassword.value) {
|
||||
isLookingRef.value = true
|
||||
stateRef.value.isLooking = true
|
||||
applyLookAtEachOther()
|
||||
clearTimeout(lookingTimerRef.value)
|
||||
lookingTimerRef.value = setTimeout(() => {
|
||||
isLookingRef.value = false
|
||||
stateRef.value.isLooking = false
|
||||
purpleRef.value?.querySelectorAll('.eyeball-pupil').forEach((p) => {
|
||||
gsap.killTweensOf(p)
|
||||
})
|
||||
}, 800)
|
||||
} else {
|
||||
clearTimeout(lookingTimerRef.value)
|
||||
isLookingRef.value = false
|
||||
stateRef.value.isLooking = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [isHidingPassword.value, isShowingPassword.value],
|
||||
() => {
|
||||
if (props.isPasswordGuardMode) {
|
||||
applyPasswordGuardMode()
|
||||
} else if (isShowingPassword.value) {
|
||||
applyShowPassword()
|
||||
} else if (isHidingPassword.value) {
|
||||
applyHidingPassword()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(purpleBlinkTimerRef.value)
|
||||
clearTimeout(blackBlinkTimerRef.value)
|
||||
clearTimeout(purplePeekTimerRef.value)
|
||||
clearTimeout(lookingTimerRef.value)
|
||||
cancelAnimationFrame(rafIdRef.value)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { Account, DriverInfo } from "@/api/types";
|
||||
import { formatTime } from "@/utils/format";
|
||||
import DriverIcon from "@/components/driver/DriverIcon.vue";
|
||||
import AppDropdown from "@/components/base/AppDropdown.vue";
|
||||
import type { DropdownMenuItem } from "@/types/menu";
|
||||
|
||||
const props = defineProps<{ account: Account; driver?: DriverInfo }>();
|
||||
const emit = defineEmits<{
|
||||
edit: [Account];
|
||||
remove: [Account];
|
||||
toggle: [Account];
|
||||
setDefault: [Account];
|
||||
}>();
|
||||
|
||||
const driverLabel = computed(() => props.driver?.display_name || props.account.driver_type);
|
||||
const color = computed(() => props.driver?.card_color || "");
|
||||
const logo = computed(() => props.driver?.card_logo || "");
|
||||
const createdAt = computed(() =>
|
||||
props.account.created_at ? formatTime(props.account.created_at) : "",
|
||||
);
|
||||
const authStatus = computed(() => (props.account.auth_status || "").trim().toLowerCase());
|
||||
const hasAuthError = computed(() =>
|
||||
props.account.is_active && ["token_expired", "failed"].includes(authStatus.value),
|
||||
);
|
||||
const isCooldown = computed(() => props.account.is_active && authStatus.value === "cooldown");
|
||||
const statusClass = computed(() => {
|
||||
if (hasAuthError.value) return "is-auth-error";
|
||||
if (isCooldown.value) return "is-cooldown";
|
||||
return props.account.is_active ? "is-active" : "is-inactive";
|
||||
});
|
||||
|
||||
const menuItems = computed<DropdownMenuItem[]>(() => {
|
||||
const items: DropdownMenuItem[] = [
|
||||
{ key: "edit", label: "编辑账号", type: "action" },
|
||||
{
|
||||
key: "toggle",
|
||||
label: props.account.is_active ? "禁用账号" : "启用账号",
|
||||
type: "action",
|
||||
},
|
||||
];
|
||||
if (!props.account.is_default) {
|
||||
items.push({ key: "setDefault", label: "设为默认", type: "action" });
|
||||
} else {
|
||||
items.push({ key: "defaultHint", label: "当前默认", type: "hint" });
|
||||
}
|
||||
items.push({ key: "divider", label: "", type: "divider" });
|
||||
items.push({ key: "remove", label: "删除账号", type: "action", danger: true });
|
||||
return items;
|
||||
});
|
||||
|
||||
function onMenuSelect(key: string) {
|
||||
switch (key) {
|
||||
case "edit":
|
||||
emit("edit", props.account);
|
||||
break;
|
||||
case "toggle":
|
||||
emit("toggle", props.account);
|
||||
break;
|
||||
case "setDefault":
|
||||
emit("setDefault", props.account);
|
||||
break;
|
||||
case "remove":
|
||||
emit("remove", props.account);
|
||||
break;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="acc-card" :class="{ 'is-auth-error': hasAuthError, 'is-cooldown': isCooldown }">
|
||||
<span class="acc-card__bar" :class="statusClass" />
|
||||
|
||||
<div class="acc-card__menu">
|
||||
<AppDropdown
|
||||
:items="menuItems"
|
||||
trigger="click"
|
||||
align="right"
|
||||
:min-width="148"
|
||||
density="compact"
|
||||
@select="onMenuSelect"
|
||||
>
|
||||
<template #trigger="{ toggle }">
|
||||
<button
|
||||
type="button"
|
||||
class="acc-card__menu-btn"
|
||||
aria-label="更多操作"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
</template>
|
||||
</AppDropdown>
|
||||
</div>
|
||||
|
||||
<div class="acc-card__head">
|
||||
<DriverIcon :name="driverLabel" :color="color" :logo="logo" :size="48" />
|
||||
<div class="acc-card__meta">
|
||||
<h4 class="acc-card__name">
|
||||
{{ account.name }}
|
||||
<span v-if="account.is_default" class="acc-card__default-tag">默认</span>
|
||||
<span v-if="hasAuthError" class="acc-card__status-pill">失效</span>
|
||||
</h4>
|
||||
<p v-if="createdAt" class="acc-card__time">创建于 {{ createdAt }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.acc-card {
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 20px;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: var(--transition);
|
||||
overflow: hidden;
|
||||
}
|
||||
.acc-card:hover {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.acc-card.is-auth-error {
|
||||
border-color: color-mix(in srgb, var(--danger) 48%, var(--border));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--danger) 14%, transparent);
|
||||
}
|
||||
.acc-card.is-cooldown {
|
||||
border-color: color-mix(in srgb, var(--warning) 42%, var(--border));
|
||||
}
|
||||
|
||||
.acc-card__bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
}
|
||||
.acc-card__bar.is-active {
|
||||
background: linear-gradient(180deg, var(--success), #059669);
|
||||
}
|
||||
.acc-card__bar.is-cooldown {
|
||||
background: linear-gradient(180deg, var(--warning), #d97706);
|
||||
}
|
||||
.acc-card__bar.is-auth-error {
|
||||
background: linear-gradient(180deg, var(--danger), #dc2626);
|
||||
}
|
||||
.acc-card__bar.is-inactive {
|
||||
background: linear-gradient(180deg, #9ca3af, #6b7280);
|
||||
}
|
||||
|
||||
.acc-card__menu {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
}
|
||||
.acc-card__menu-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.acc-card__menu-btn:hover {
|
||||
background: var(--border-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.acc-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding-right: 28px;
|
||||
}
|
||||
.acc-card__name {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.acc-card__default-tag {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 1px 7px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--info-soft);
|
||||
color: var(--info);
|
||||
}
|
||||
.acc-card__status-pill {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 1px 7px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: color-mix(in srgb, var(--danger) 12%, var(--surface));
|
||||
color: var(--danger);
|
||||
}
|
||||
.acc-card__time {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
:root[data-skin="brutal"] .acc-card__menu-btn {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
:root[data-skin="brutal"] .acc-card__menu-btn:hover {
|
||||
background: var(--brutal-yellow);
|
||||
color: var(--brutal-ink);
|
||||
}
|
||||
|
||||
:root[data-skin="brutal"] .acc-card__default-tag {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
:root[data-skin="brutal"] .acc-card__status-pill {
|
||||
border-radius: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import AppButton from "@/components/base/AppButton.vue";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
display?: string;
|
||||
title?: string;
|
||||
placeholder?: string;
|
||||
browseLabel?: string;
|
||||
wrapperClass?: string;
|
||||
}>(),
|
||||
{
|
||||
display: "",
|
||||
placeholder: "点击浏览选择账号及目录",
|
||||
browseLabel: "浏览",
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ browse: [] }>();
|
||||
|
||||
function openBrowse() {
|
||||
emit("browse");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="account-folder-field" :class="wrapperClass">
|
||||
<button
|
||||
type="button"
|
||||
class="account-folder-field__main"
|
||||
:title="title || placeholder"
|
||||
@click="openBrowse"
|
||||
>
|
||||
<span
|
||||
class="account-folder-field__text"
|
||||
:class="{ 'account-folder-field__text--placeholder': !display }"
|
||||
>
|
||||
{{ display || placeholder }}
|
||||
</span>
|
||||
</button>
|
||||
<AppButton type="button" variant="primary" class="account-folder-field__browse" @click="openBrowse">
|
||||
{{ browseLabel }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.account-folder-field {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.account-folder-field:focus-within {
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.account-folder-field__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.account-folder-field__main:hover {
|
||||
background: color-mix(in srgb, var(--surface-sunken) 60%, transparent);
|
||||
}
|
||||
|
||||
.account-folder-field__text {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-folder-field__text--placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.account-folder-field__browse {
|
||||
flex-shrink: 0;
|
||||
min-width: 72px;
|
||||
height: auto;
|
||||
border-radius: 0 !important;
|
||||
border: none !important;
|
||||
border-left: 1px solid var(--border) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useAccountsStore } from "@/stores/accounts";
|
||||
import { getApiErrorMessage } from "@/api/client";
|
||||
import type { Account } from "@/api/types";
|
||||
import { toast } from "@/composables/useToast";
|
||||
import { confirm } from "@/composables/useConfirm";
|
||||
import AccountCard from "./AccountCard.vue";
|
||||
import AddAccountDialog from "./AddAccountDialog.vue";
|
||||
|
||||
const store = useAccountsStore();
|
||||
const { accounts, loading } = storeToRefs(store);
|
||||
|
||||
const dialogOpen = ref(false);
|
||||
const editing = ref<Account | null>(null);
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null;
|
||||
dialogOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(account: Account) {
|
||||
editing.value = account;
|
||||
dialogOpen.value = true;
|
||||
}
|
||||
|
||||
async function remove(account: Account) {
|
||||
try {
|
||||
await confirm({
|
||||
title: "确认删除",
|
||||
message: `确定要删除账号「${account.name}」吗?此操作不可撤销。`,
|
||||
icon: "trash",
|
||||
confirmText: "删除",
|
||||
danger: true,
|
||||
size: "md",
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await store.remove(account.id);
|
||||
toast.success("账号已删除");
|
||||
} catch (e) {
|
||||
toast.error(getApiErrorMessage(e, "删除失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(account: Account) {
|
||||
const nextActive = !account.is_active;
|
||||
try {
|
||||
await store.toggle(account.id);
|
||||
toast.success(`账号「${account.name}」已${nextActive ? "启用" : "禁用"}`);
|
||||
} catch (e) {
|
||||
toast.error(getApiErrorMessage(e, "状态切换失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function setDefault(account: Account) {
|
||||
try {
|
||||
await store.setDefault(account.id);
|
||||
toast.success(`已将「${account.name}」设为默认账号`);
|
||||
} catch (e) {
|
||||
toast.error(getApiErrorMessage(e, "设置默认失败"));
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.loadAccounts();
|
||||
store.loadDrivers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="accounts">
|
||||
<div v-if="loading && !accounts.length" class="accounts__loading">加载中…</div>
|
||||
|
||||
<div class="accounts__grid">
|
||||
<AccountCard
|
||||
v-for="acc in accounts"
|
||||
:key="acc.id"
|
||||
:account="acc"
|
||||
:driver="store.driverOf(acc.driver_type)"
|
||||
@edit="openEdit"
|
||||
@remove="remove"
|
||||
@toggle="toggle"
|
||||
@set-default="setDefault"
|
||||
/>
|
||||
|
||||
<button class="add-card" @click="openCreate">
|
||||
<span class="add-card__plus">+</span>
|
||||
<span>添加账号</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AddAccountDialog
|
||||
:open="dialogOpen"
|
||||
:editing="editing"
|
||||
@close="dialogOpen = false"
|
||||
@saved="store.loadAccounts"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.accounts__loading {
|
||||
color: var(--text-muted);
|
||||
padding: 20px 0;
|
||||
}
|
||||
.accounts__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.add-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 120px;
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
transition: var(--transition);
|
||||
}
|
||||
.add-card:hover {
|
||||
border-color: var(--brand);
|
||||
color: var(--brand);
|
||||
}
|
||||
.add-card__plus {
|
||||
font-size: 28px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,342 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useAccountsStore } from "@/stores/accounts";
|
||||
import { getApiErrorMessage } from "@/api/client";
|
||||
import type { Account, FieldSchema } from "@/api/types";
|
||||
import { toast } from "@/composables/useToast";
|
||||
import { useOAuthAuth } from "@/composables/useOAuthAuth";
|
||||
import AppModal from "@/components/base/AppModal.vue";
|
||||
import AppButton from "@/components/base/AppButton.vue";
|
||||
import AppInput from "@/components/base/AppInput.vue";
|
||||
import FormField from "@/components/base/FormField.vue";
|
||||
import DriverPickerStep from "./DriverPickerStep.vue";
|
||||
import QrLoginModal from "./QrLoginModal.vue";
|
||||
import DynamicForm from "@/components/form/DynamicForm.vue";
|
||||
|
||||
const props = defineProps<{ open: boolean; editing: Account | null }>();
|
||||
const emit = defineEmits<{ close: []; saved: [] }>();
|
||||
|
||||
const store = useAccountsStore();
|
||||
const { drivers, accounts } = storeToRefs(store);
|
||||
const { loading: oauthLoading, run: runOAuth, cancel: cancelOAuth } = useOAuthAuth();
|
||||
|
||||
const step = ref<1 | 2>(1);
|
||||
const driverType = ref("");
|
||||
const name = ref("");
|
||||
const formValues = ref<Record<string, unknown>>({});
|
||||
const submitting = ref(false);
|
||||
|
||||
const selectedDriver = computed(() => store.driverOf(driverType.value));
|
||||
const fields = computed<FieldSchema[]>(() => selectedDriver.value?.fields ?? []);
|
||||
const isEdit = computed(() => props.editing !== null);
|
||||
const stepTitle = computed(() =>
|
||||
isEdit.value ? "编辑账号" : step.value === 1 ? "选择网盘驱动" : "配置账号信息",
|
||||
);
|
||||
const supportsOAuth = computed(() => Boolean(selectedDriver.value?.supports_oauth));
|
||||
const supportsQRLogin = computed(() => Boolean(selectedDriver.value?.supports_qr_login));
|
||||
const qrOpen = ref(false);
|
||||
|
||||
function parseBooleanSelectValue(f: FieldSchema, raw: unknown) {
|
||||
if (f.type !== "select" || !(f.options ?? []).some((o) => o.value === "true" || o.value === "false")) {
|
||||
return raw;
|
||||
}
|
||||
if (typeof raw === "boolean") return raw;
|
||||
const normalized = String(raw ?? "").trim().toLowerCase();
|
||||
if (normalized === "true") return true;
|
||||
if (normalized === "false") return false;
|
||||
return raw;
|
||||
}
|
||||
|
||||
function fieldInitialValue(f: FieldSchema, preset: Record<string, unknown>) {
|
||||
const raw = preset[f.name] ?? (f.type === "bool" ? false : (f.default ?? ""));
|
||||
return parseBooleanSelectValue(f, raw);
|
||||
}
|
||||
|
||||
function initForm(fs: FieldSchema[], preset: Record<string, unknown> = {}) {
|
||||
const v: Record<string, unknown> = { ...preset };
|
||||
for (const f of fs) {
|
||||
v[f.name] = fieldInitialValue(f, preset);
|
||||
}
|
||||
formValues.value = v;
|
||||
}
|
||||
|
||||
function resetDialog() {
|
||||
cancelOAuth();
|
||||
step.value = 1;
|
||||
driverType.value = "";
|
||||
name.value = "";
|
||||
formValues.value = {};
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) {
|
||||
cancelOAuth();
|
||||
return;
|
||||
}
|
||||
store.loadDrivers();
|
||||
store.loadAccounts();
|
||||
if (props.editing) {
|
||||
driverType.value = props.editing.driver_type;
|
||||
name.value = props.editing.name;
|
||||
let preset: Record<string, unknown> = {};
|
||||
try {
|
||||
preset = JSON.parse(props.editing.config || "{}");
|
||||
} catch {
|
||||
preset = {};
|
||||
}
|
||||
initForm(store.driverOf(props.editing.driver_type)?.fields ?? [], preset);
|
||||
step.value = 2;
|
||||
} else {
|
||||
resetDialog();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function goPrevStep() {
|
||||
cancelOAuth();
|
||||
formValues.value = {};
|
||||
step.value = 1;
|
||||
}
|
||||
|
||||
function goStep2() {
|
||||
if (!driverType.value) {
|
||||
toast.warning("请选择驱动类型");
|
||||
return;
|
||||
}
|
||||
initForm(fields.value);
|
||||
step.value = 2;
|
||||
}
|
||||
|
||||
function buildConfig(): string {
|
||||
const cfg: Record<string, unknown> = {};
|
||||
const schemaNames = new Set(fields.value.map((f) => f.name));
|
||||
for (const f of fields.value) {
|
||||
const raw = formValues.value[f.name];
|
||||
if (f.type === "bool") {
|
||||
cfg[f.name] = Boolean(raw);
|
||||
} else if (f.type === "number") {
|
||||
const s = String(raw ?? "").trim();
|
||||
if (s) cfg[f.name] = s;
|
||||
} else if (f.type === "select") {
|
||||
const normalized = parseBooleanSelectValue(f, raw);
|
||||
if (typeof normalized === "boolean") {
|
||||
cfg[f.name] = normalized;
|
||||
continue;
|
||||
}
|
||||
const s = String(normalized ?? "").trim();
|
||||
if (s) cfg[f.name] = s;
|
||||
else if (f.default) cfg[f.name] = parseBooleanSelectValue(f, f.default);
|
||||
} else {
|
||||
const s = String(raw ?? "").trim();
|
||||
if (s) cfg[f.name] = s;
|
||||
else if (f.default) cfg[f.name] = f.default;
|
||||
}
|
||||
}
|
||||
for (const [key, raw] of Object.entries(formValues.value)) {
|
||||
if (schemaNames.has(key)) continue;
|
||||
const s = String(raw ?? "").trim();
|
||||
if (s) cfg[key] = s;
|
||||
}
|
||||
return JSON.stringify(cfg);
|
||||
}
|
||||
|
||||
function validate(): string | null {
|
||||
const trimmed = name.value.trim();
|
||||
if (!trimmed) return "请填写账号名称";
|
||||
const dup = accounts.value.find(
|
||||
(a) =>
|
||||
a.name.toLowerCase() === trimmed.toLowerCase() &&
|
||||
(!props.editing || a.id !== props.editing.id),
|
||||
);
|
||||
if (dup) return "账号名称已存在,请使用其他名称";
|
||||
for (const f of fields.value) {
|
||||
if (!f.required) continue;
|
||||
const v = formValues.value[f.name];
|
||||
if (f.type === "bool") continue;
|
||||
if (!String(v ?? "").trim() && !f.default) return `请填写「${f.label}」`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const err = validate();
|
||||
if (err) {
|
||||
toast.warning(err);
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
name: name.value.trim(),
|
||||
driver_type: driverType.value,
|
||||
config: buildConfig(),
|
||||
is_active: props.editing?.is_active ?? true,
|
||||
is_default: props.editing?.is_default ?? false,
|
||||
sort_order: props.editing?.sort_order ?? 0,
|
||||
};
|
||||
if (props.editing) {
|
||||
await store.update(props.editing.id, payload);
|
||||
toast.success("账号已更新");
|
||||
} else {
|
||||
await store.create(payload);
|
||||
toast.success("账号已添加");
|
||||
}
|
||||
emit("saved");
|
||||
emit("close");
|
||||
} catch (e) {
|
||||
toast.error(getApiErrorMessage(e, "保存失败"));
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOAuth() {
|
||||
if (!driverType.value) return;
|
||||
const fieldNames = fields.value.map((f) => f.name);
|
||||
try {
|
||||
const filled = await runOAuth(driverType.value, fieldNames);
|
||||
formValues.value = { ...formValues.value, ...filled };
|
||||
} catch {
|
||||
/* toast 已在 composable 内处理 */
|
||||
}
|
||||
}
|
||||
|
||||
function openQRLogin() {
|
||||
if (!driverType.value) return;
|
||||
qrOpen.value = true;
|
||||
}
|
||||
|
||||
function onQRSuccess(credentials: Record<string, string>) {
|
||||
formValues.value = { ...formValues.value, ...credentials };
|
||||
qrOpen.value = false;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
cancelOAuth();
|
||||
qrOpen.value = false;
|
||||
emit("close");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppModal :open="open" size="account" @close="handleClose">
|
||||
<template #header>
|
||||
<div class="dialog-head">
|
||||
<span v-if="!isEdit" class="step-badge">{{ step }}</span>
|
||||
<h3 class="dialog-head__title">{{ stepTitle }}</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<DriverPickerStep
|
||||
v-if="step === 1 && !isEdit"
|
||||
v-model="driverType"
|
||||
:drivers="drivers"
|
||||
@next="goStep2"
|
||||
/>
|
||||
|
||||
<div v-else class="form">
|
||||
<FormField label="账号名称" required>
|
||||
<AppInput v-model="name" placeholder="请输入账号名称" />
|
||||
</FormField>
|
||||
<DynamicForm :fields="fields" v-model="formValues" />
|
||||
</div>
|
||||
|
||||
<template v-if="step === 2" #footer>
|
||||
<div class="step-footer">
|
||||
<div class="step-footer__left">
|
||||
<AppButton
|
||||
v-if="supportsOAuth"
|
||||
variant="primary"
|
||||
:disabled="oauthLoading || submitting"
|
||||
@click="handleOAuth"
|
||||
>
|
||||
{{ oauthLoading ? "正在获取…" : isEdit ? "重新获取 Token" : "自动获取 Token" }}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
v-if="supportsQRLogin"
|
||||
variant="primary"
|
||||
:disabled="submitting"
|
||||
@click="openQRLogin"
|
||||
>
|
||||
{{ isEdit ? "重新扫码获取" : "扫码获取授权" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
<div class="step-footer__right">
|
||||
<AppButton v-if="!isEdit" variant="secondary" @click="goPrevStep">← 上一步</AppButton>
|
||||
<AppButton variant="primary" :disabled="submitting" @click="submit">
|
||||
{{ submitting ? "测试中…" : isEdit ? "保存修改" : "添加账号" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</AppModal>
|
||||
|
||||
<QrLoginModal
|
||||
:open="qrOpen"
|
||||
:driver-type="driverType"
|
||||
@success="onQRSuccess"
|
||||
@close="qrOpen = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.step-badge {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-gradient-h);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dialog-head__title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.step-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
.step-footer__left {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.step-footer__right {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
:root[data-skin="brutal"] .step-badge {
|
||||
border: var(--brutal-bw) solid var(--brutal-ink);
|
||||
border-radius: 0;
|
||||
background: var(--brand);
|
||||
color: var(--text-on-brand);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,367 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { APP_NAME, APP_VERSION } from "@/version";
|
||||
|
||||
const props = defineProps<{
|
||||
compact?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ logout: [] }>();
|
||||
|
||||
const auth = useAuthStore();
|
||||
const open = ref(false);
|
||||
const wrapRef = ref<HTMLElement | null>(null);
|
||||
const menuPos = ref({ left: 0, bottom: 0 });
|
||||
|
||||
const displayName = computed(() => auth.username || "admin");
|
||||
const avatarLetter = computed(() => displayName.value.charAt(0).toUpperCase() || "A");
|
||||
|
||||
const compactMenuStyle = computed(() =>
|
||||
props.compact
|
||||
? {
|
||||
left: `${menuPos.value.left}px`,
|
||||
bottom: `${menuPos.value.bottom}px`,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
function updateMenuPos() {
|
||||
const el = wrapRef.value;
|
||||
if (!el || !props.compact) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
menuPos.value = {
|
||||
left: rect.right + 8,
|
||||
bottom: window.innerHeight - rect.bottom,
|
||||
};
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
open.value = !open.value;
|
||||
if (open.value && props.compact) {
|
||||
void nextTick(updateMenuPos);
|
||||
}
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
closeMenu();
|
||||
emit("logout");
|
||||
}
|
||||
|
||||
function handleDocumentClick(e: MouseEvent) {
|
||||
const el = e.target as HTMLElement | null;
|
||||
if (!open.value || !el) return;
|
||||
if (el.closest(".acct-wrap") || el.closest(".acct-menu")) return;
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function handleViewportChange() {
|
||||
if (open.value && props.compact) updateMenuPos();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.compact,
|
||||
() => {
|
||||
if (open.value && props.compact) void nextTick(updateMenuPos);
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", handleDocumentClick);
|
||||
window.addEventListener("resize", handleViewportChange);
|
||||
window.addEventListener("scroll", handleViewportChange, true);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("click", handleDocumentClick);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", handleViewportChange);
|
||||
window.removeEventListener("scroll", handleViewportChange, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="wrapRef" class="acct-wrap">
|
||||
<button
|
||||
class="acct-chip"
|
||||
:class="{ 'acct-chip--compact': compact }"
|
||||
type="button"
|
||||
:aria-expanded="open"
|
||||
@click.stop="toggleMenu"
|
||||
>
|
||||
<span class="acct-chip__avatar">{{ avatarLetter }}</span>
|
||||
<span class="acct-chip__name">
|
||||
{{ displayName }}
|
||||
<small class="acct-chip__sub">管理员</small>
|
||||
</span>
|
||||
<span class="acct-chip__chev" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Teleport to="body" :disabled="!compact">
|
||||
<div
|
||||
v-if="open"
|
||||
class="acct-menu"
|
||||
:class="{ 'acct-menu--compact': compact }"
|
||||
:style="compactMenuStyle"
|
||||
@click.stop
|
||||
>
|
||||
<div class="acct-menu__section acct-menu__item acct-menu__head">
|
||||
<span class="acct-menu__avatar">{{ avatarLetter }}</span>
|
||||
<span class="acct-menu__content">
|
||||
<span class="acct-menu__name">{{ displayName }}</span>
|
||||
<span class="acct-menu__role">管理员账户</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="acct-menu__section acct-menu__item acct-menu__about">
|
||||
<span class="acct-menu__icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 10v6" />
|
||||
<path d="M12 7h.01" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="acct-menu__content">
|
||||
<span class="acct-menu__label">关于 {{ APP_NAME }}</span>
|
||||
<span class="acct-menu__meta">{{ APP_VERSION }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button class="acct-menu__section acct-menu__item acct-menu__action" type="button" @click="handleLogout">
|
||||
<span class="acct-menu__icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="M16 17l5-5-5-5" />
|
||||
<path d="M21 12H9" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="acct-menu__label">退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.acct-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.acct-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border-radius: var(--radius-md);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.acct-chip:hover {
|
||||
background: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
|
||||
.acct-chip__avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.acct-chip__name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.acct-chip__sub {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.acct-chip__chev {
|
||||
flex: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.acct-chip__chev svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.acct-menu {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(100% + 6px);
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.65);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
z-index: 130;
|
||||
}
|
||||
|
||||
.acct-menu--compact {
|
||||
position: fixed;
|
||||
right: auto;
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.acct-menu__section {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.acct-menu__section + .acct-menu__section {
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.acct-menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.acct-menu__avatar,
|
||||
.acct-menu__icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: none;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.acct-menu__avatar {
|
||||
background: color-mix(in srgb, var(--brand) 12%, white);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.acct-menu__icon {
|
||||
background: rgba(15, 23, 42, 0.05);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.acct-menu__icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.acct-menu__content {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.acct-menu__name {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.acct-menu__role {
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.acct-menu__label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.acct-menu__meta {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.acct-menu__action {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.acct-chip--compact {
|
||||
justify-content: center;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.acct-chip--compact .acct-chip__name,
|
||||
.acct-chip--compact .acct-chip__chev {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.acct-menu__action:hover {
|
||||
background: rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.acct-menu__action:hover .acct-menu__icon,
|
||||
.acct-menu__action:hover .acct-menu__label {
|
||||
color: var(--brand);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import "@/styles/admin-shared.css";
|
||||
|
||||
defineProps<{
|
||||
icon?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-empty-state">
|
||||
<span v-if="icon" class="admin-empty-state__icon">{{ icon }}</span>
|
||||
<p v-if="title" class="admin-empty-state__title">{{ title }}</p>
|
||||
<p v-if="description" class="admin-empty-state__desc">{{ description }}</p>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
enabled: boolean;
|
||||
ariaLabel?: string;
|
||||
onLabel?: string;
|
||||
offLabel?: string;
|
||||
onTitle?: string;
|
||||
offTitle?: string;
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
ariaLabel: "任务启用切换",
|
||||
onLabel: "启",
|
||||
offLabel: "禁",
|
||||
onTitle: "启用",
|
||||
offTitle: "禁用",
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
defineEmits<{ enable: [boolean] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="admin-enable-toggle"
|
||||
role="group"
|
||||
:aria-label="ariaLabel"
|
||||
:class="{ 'admin-enable-toggle--disabled': disabled }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-enable-toggle__btn"
|
||||
:class="{ 'admin-enable-toggle__btn--active': enabled }"
|
||||
:title="onTitle"
|
||||
:disabled="disabled"
|
||||
@click="$emit('enable', true)"
|
||||
>
|
||||
{{ onLabel }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-enable-toggle__btn"
|
||||
:class="{ 'admin-enable-toggle__btn--active': !enabled }"
|
||||
:title="offTitle"
|
||||
:disabled="disabled"
|
||||
@click="$emit('enable', false)"
|
||||
>
|
||||
{{ offLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-enable-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 34px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-enable-toggle--disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.admin-enable-toggle__btn {
|
||||
min-width: 30px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.admin-enable-toggle__btn:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.admin-enable-toggle__btn--active {
|
||||
background: color-mix(in srgb, var(--brand) 12%, var(--surface));
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.admin-enable-toggle__btn:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import AdminNotificationBell from "@/components/admin/AdminNotificationBell.vue";
|
||||
import {
|
||||
getNextThemePref,
|
||||
getThemePref,
|
||||
getThemeToggleTitle,
|
||||
setThemePref,
|
||||
supportsThemeToggle,
|
||||
type ThemePref,
|
||||
} from "@/utils/theme";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
showHomeReturn?: boolean;
|
||||
}>(),
|
||||
{ showHomeReturn: true },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ goHome: [] }>();
|
||||
|
||||
const theme = ref<ThemePref>(getThemePref());
|
||||
const themeToggleTitle = computed(() => getThemeToggleTitle(theme.value));
|
||||
const showThemeToggle = computed(() => supportsThemeToggle());
|
||||
|
||||
function toggleTheme() {
|
||||
theme.value = getNextThemePref(theme.value);
|
||||
setThemePref(theme.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="global-actions" aria-label="全局操作区">
|
||||
<button
|
||||
v-if="showThemeToggle"
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
:title="themeToggleTitle"
|
||||
:aria-label="themeToggleTitle"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<svg v-if="theme === 'light'" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
</svg>
|
||||
<svg v-else-if="theme === 'dark'" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||
<path d="M8 21h8" />
|
||||
<path d="M12 17v4" />
|
||||
</svg>
|
||||
</button>
|
||||
<AdminNotificationBell variant="main" />
|
||||
<button
|
||||
v-if="showHomeReturn"
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="返回前台"
|
||||
aria-label="返回前台"
|
||||
@click="emit('goHome')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3 10.5 12 3l9 7.5" />
|
||||
<path d="M5 10v10h14V10" />
|
||||
<path d="M9 20v-6h6v6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.global-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.global-actions :deep(.icon-btn) {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.global-actions :deep(.icon-btn:hover),
|
||||
.global-actions :deep(.icon-btn.active) {
|
||||
color: var(--brand);
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.global-actions :deep(.icon-btn svg) {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.global-actions :deep(.icon-btn .badge) {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border: 2px solid var(--surface);
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
background: var(--danger);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
name: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<i :class="['fas', `fa-${name}`, 'admin-nav-icon']" aria-hidden="true"></i>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-nav-icon {
|
||||
flex: none;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,788 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import AppModal from "@/components/base/AppModal.vue";
|
||||
import { ackRetentionScopeWarn } from "@/api/cacheRetention";
|
||||
import {
|
||||
deleteAllNotifications,
|
||||
deleteNotification,
|
||||
fetchNotifications,
|
||||
fetchUnreadCount,
|
||||
isCacheScopeWarnNotification,
|
||||
isStrmScanWarnNotification,
|
||||
isStrmScrapeWarnNotification,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
parseStrmScanFailures,
|
||||
parseStrmScrapeFailures,
|
||||
strmScanFailureKindLabel,
|
||||
strmScrapeFailureStageLabel,
|
||||
type NotificationItem,
|
||||
} from "@/api/notifications";
|
||||
import { confirm } from "@/composables/useConfirm";
|
||||
import { formatTimeShort } from "@/utils/format";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: "main" | "sidebar";
|
||||
}>(),
|
||||
{ variant: "main" },
|
||||
);
|
||||
|
||||
const open = ref(false);
|
||||
const loading = ref(false);
|
||||
const unreadCount = ref(0);
|
||||
const items = ref<NotificationItem[]>([]);
|
||||
const detailItem = ref<NotificationItem | null>(null);
|
||||
const detailOpen = ref(false);
|
||||
const detailBusy = ref(false);
|
||||
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const isMain = computed(() => props.variant === "main");
|
||||
|
||||
const badgeText = computed(() => {
|
||||
if (unreadCount.value <= 0) return "";
|
||||
return unreadCount.value > 99 ? "99+" : String(unreadCount.value);
|
||||
});
|
||||
|
||||
const detailCanDismissScope = computed(() =>
|
||||
detailItem.value ? isCacheScopeWarnNotification(detailItem.value) : false,
|
||||
);
|
||||
|
||||
interface NotificationFailureRow {
|
||||
label: string;
|
||||
name: string;
|
||||
path: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
const detailFailures = computed(() => {
|
||||
const item = detailItem.value;
|
||||
if (!item) {
|
||||
return { enabled: false, summary: "", items: [] as NotificationFailureRow[] };
|
||||
}
|
||||
if (isStrmScanWarnNotification(item)) {
|
||||
const parsed = parseStrmScanFailures(item.message);
|
||||
return {
|
||||
enabled: true,
|
||||
summary: parsed.summary,
|
||||
items: parsed.items.map((row) => ({
|
||||
label: strmScanFailureKindLabel(row.kind),
|
||||
name: "",
|
||||
path: row.path,
|
||||
reason: row.reason,
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (isStrmScrapeWarnNotification(item)) {
|
||||
const parsed = parseStrmScrapeFailures(item.message);
|
||||
return {
|
||||
enabled: true,
|
||||
summary: parsed.summary,
|
||||
items: parsed.items.map((row) => ({
|
||||
label: strmScrapeFailureStageLabel(row.stage),
|
||||
name: row.name,
|
||||
path: row.path,
|
||||
reason: row.reason,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return { enabled: false, summary: "", items: [] as NotificationFailureRow[] };
|
||||
});
|
||||
|
||||
function levelIcon(level: string): string {
|
||||
switch (level) {
|
||||
case "error":
|
||||
return "✕";
|
||||
case "warning":
|
||||
return "!";
|
||||
case "success":
|
||||
return "✓";
|
||||
default:
|
||||
return "i";
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUnread() {
|
||||
try {
|
||||
const data = await fetchUnreadCount();
|
||||
unreadCount.value = data.count ?? 0;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await fetchNotifications({ limit: 40 });
|
||||
items.value = data.items ?? [];
|
||||
} catch {
|
||||
items.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleOpen() {
|
||||
open.value = !open.value;
|
||||
if (open.value) {
|
||||
await Promise.all([loadList(), refreshUnread()]);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkAll() {
|
||||
try {
|
||||
await markAllNotificationsRead();
|
||||
unreadCount.value = 0;
|
||||
items.value = items.value.map((it) => ({ ...it, is_read: true }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function handleClearAll() {
|
||||
if (items.value.length === 0) return;
|
||||
try {
|
||||
await confirm({
|
||||
title: "清空全部通知?",
|
||||
message: "将删除全部通知,且不可恢复。",
|
||||
confirmText: "清空",
|
||||
cancelText: "取消",
|
||||
danger: true,
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteAllNotifications();
|
||||
items.value = [];
|
||||
unreadCount.value = 0;
|
||||
if (detailOpen.value) closeDetail();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
detailOpen.value = false;
|
||||
detailItem.value = null;
|
||||
detailBusy.value = false;
|
||||
}
|
||||
|
||||
async function openDetail(item: NotificationItem) {
|
||||
detailItem.value = item;
|
||||
detailOpen.value = true;
|
||||
if (!item.is_read) {
|
||||
try {
|
||||
await markNotificationRead(item.id);
|
||||
item.is_read = true;
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function removeItem(id: number) {
|
||||
const wasUnread = items.value.find((it) => it.id === id && !it.is_read);
|
||||
items.value = items.value.filter((it) => it.id !== id);
|
||||
if (wasUnread) {
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1);
|
||||
}
|
||||
if (detailItem.value?.id === id) {
|
||||
closeDetail();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteItem(item: NotificationItem, e?: Event) {
|
||||
e?.stopPropagation();
|
||||
try {
|
||||
await confirm({
|
||||
title: "删除这条通知?",
|
||||
message: item.title,
|
||||
confirmText: "删除",
|
||||
cancelText: "取消",
|
||||
danger: true,
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteNotification(item.id);
|
||||
removeItem(item.id);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function handleDetailDelete() {
|
||||
if (!detailItem.value || detailBusy.value) return;
|
||||
try {
|
||||
await confirm({
|
||||
title: "删除这条通知?",
|
||||
message: detailItem.value.title,
|
||||
confirmText: "删除",
|
||||
cancelText: "取消",
|
||||
danger: true,
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
detailBusy.value = true;
|
||||
try {
|
||||
await deleteNotification(detailItem.value.id);
|
||||
removeItem(detailItem.value.id);
|
||||
} catch {
|
||||
detailBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDismissScopeWarn() {
|
||||
const item = detailItem.value;
|
||||
if (!item || detailBusy.value || !isCacheScopeWarnNotification(item)) return;
|
||||
const taskId = item.ref_id ?? 0;
|
||||
if (taskId <= 0) return;
|
||||
detailBusy.value = true;
|
||||
try {
|
||||
await ackRetentionScopeWarn(taskId);
|
||||
items.value = items.value.filter(
|
||||
(it) => !(isCacheScopeWarnNotification(it) && it.ref_id === taskId),
|
||||
);
|
||||
closeDetail();
|
||||
await refreshUnread();
|
||||
} catch {
|
||||
detailBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function notifyListMessage(item: NotificationItem): string {
|
||||
if (isStrmScanWarnNotification(item)) {
|
||||
return parseStrmScanFailures(item.message).summary || item.message;
|
||||
}
|
||||
if (isStrmScrapeWarnNotification(item)) {
|
||||
return parseStrmScrapeFailures(item.message).summary || item.message;
|
||||
}
|
||||
return item.message;
|
||||
}
|
||||
|
||||
function handleDocumentClick(e: MouseEvent) {
|
||||
const el = e.target as HTMLElement | null;
|
||||
if (!open.value || !el) return;
|
||||
if (el.closest(".notify-wrap")) return;
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshUnread();
|
||||
pollTimer = setInterval(refreshUnread, 30000);
|
||||
document.addEventListener("click", handleDocumentClick);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
document.removeEventListener("click", handleDocumentClick);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notify-wrap">
|
||||
<button
|
||||
type="button"
|
||||
:class="
|
||||
isMain
|
||||
? { 'icon-btn': true, active: open || unreadCount > 0 }
|
||||
: { 'bell-btn': true, 'bell-btn--sidebar': true, 'bell-btn--active': open }
|
||||
"
|
||||
title="通知"
|
||||
@click.stop="toggleOpen"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 7h18s-3 0-3-7" />
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||
</svg>
|
||||
<span v-if="badgeText" :class="isMain ? 'badge' : 'notify-badge'">{{ badgeText }}</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="open"
|
||||
class="notify-panel"
|
||||
:class="{ 'notify-panel--main': isMain, 'notify-panel--sidebar': !isMain }"
|
||||
@click.stop
|
||||
>
|
||||
<div class="notify-panel__head">
|
||||
<span class="notify-panel__title">通知</span>
|
||||
<div class="notify-panel__actions">
|
||||
<button
|
||||
v-if="unreadCount > 0"
|
||||
class="notify-panel__link"
|
||||
type="button"
|
||||
@click="handleMarkAll"
|
||||
>
|
||||
全部已读
|
||||
</button>
|
||||
<button
|
||||
v-if="items.length > 0"
|
||||
class="notify-panel__link notify-panel__link--danger"
|
||||
type="button"
|
||||
@click="handleClearAll"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="notify-panel__empty">加载中…</div>
|
||||
<div v-else-if="items.length === 0" class="notify-panel__empty">暂无通知</div>
|
||||
<ul v-else class="notify-list">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="notify-item"
|
||||
:class="[`notify-item--${item.level}`, { 'notify-item--unread': !item.is_read }]"
|
||||
@click="openDetail(item)"
|
||||
>
|
||||
<span class="notify-item__icon">{{ levelIcon(item.level) }}</span>
|
||||
<div class="notify-item__body">
|
||||
<div class="notify-item__title">{{ item.title }}</div>
|
||||
<div class="notify-item__msg">{{ notifyListMessage(item) }}</div>
|
||||
<div class="notify-item__time">{{ formatTimeShort(item.created_at) }}</div>
|
||||
</div>
|
||||
<button
|
||||
class="notify-item__delete"
|
||||
type="button"
|
||||
title="删除"
|
||||
aria-label="删除通知"
|
||||
@click="handleDeleteItem(item, $event)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<AppModal :open="detailOpen" size="md" @close="closeDetail">
|
||||
<template v-if="detailItem" #header>
|
||||
<div class="notify-detail__head">
|
||||
<span
|
||||
class="notify-detail__icon"
|
||||
:class="`notify-detail__icon--${detailItem.level}`"
|
||||
>
|
||||
{{ levelIcon(detailItem.level) }}
|
||||
</span>
|
||||
<h3 class="notify-detail__title">{{ detailItem.title }}</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="detailItem" class="notify-detail__body">
|
||||
<p v-if="detailFailures.enabled" class="notify-detail__message">
|
||||
{{ detailFailures.summary || detailItem.message }}
|
||||
</p>
|
||||
<p v-else class="notify-detail__message">{{ detailItem.message }}</p>
|
||||
|
||||
<div v-if="detailFailures.enabled && detailFailures.items.length" class="notify-detail__failures">
|
||||
<div class="notify-detail__failures-head">失败明细({{ detailFailures.items.length }})</div>
|
||||
<ul class="notify-detail__failure-list">
|
||||
<li v-for="(row, idx) in detailFailures.items" :key="`${row.path}-${idx}`">
|
||||
<span class="notify-detail__failure-kind">{{ row.label }}</span>
|
||||
<span class="notify-detail__failure-path" :title="row.path">
|
||||
{{ row.name ? `${row.name} · ${row.path}` : row.path }}
|
||||
</span>
|
||||
<span class="notify-detail__failure-reason">{{ row.reason }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="notify-detail__meta">{{ formatTimeShort(detailItem.created_at) }}</div>
|
||||
</div>
|
||||
|
||||
<template v-if="detailItem" #footer>
|
||||
<button class="btn btn--ghost" type="button" :disabled="detailBusy" @click="closeDetail">
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
v-if="detailCanDismissScope"
|
||||
class="btn btn--secondary"
|
||||
type="button"
|
||||
:disabled="detailBusy"
|
||||
@click="handleDismissScopeWarn"
|
||||
>
|
||||
不再提示
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--danger"
|
||||
type="button"
|
||||
:disabled="detailBusy"
|
||||
@click="handleDetailDelete"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</template>
|
||||
</AppModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notify-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bell-btn {
|
||||
position: relative;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.bell-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.bell-btn--sidebar {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bell-btn--sidebar:hover,
|
||||
.bell-btn--sidebar.bell-btn--active {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.notify-badge {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bell-btn--sidebar .notify-badge {
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.notify-panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: 320px;
|
||||
max-height: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.notify-panel--main {
|
||||
top: calc(100% + 8px);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
|
||||
.notify-panel--sidebar {
|
||||
left: 0;
|
||||
right: auto;
|
||||
bottom: calc(100% + 10px);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
|
||||
.notify-panel__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.notify-panel__title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.notify-panel__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.notify-panel__link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.notify-panel__link:hover {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.notify-panel__link--danger:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.notify-panel__empty {
|
||||
padding: 28px 14px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.notify-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 6px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.notify-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.notify-item:hover {
|
||||
background: var(--border-soft);
|
||||
}
|
||||
|
||||
.notify-item--unread {
|
||||
background: color-mix(in srgb, var(--brand) 5%, transparent);
|
||||
}
|
||||
|
||||
.notify-item__icon {
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.notify-item--error .notify-item__icon {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
.notify-item--warning .notify-item__icon {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #d97706;
|
||||
}
|
||||
.notify-item--success .notify-item__icon {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #059669;
|
||||
}
|
||||
.notify-item--info .notify-item__icon,
|
||||
.notify-item:not(.notify-item--error):not(.notify-item--warning):not(.notify-item--success)
|
||||
.notify-item__icon {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.notify-item__body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.notify-item__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.notify-item__msg {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
word-break: break-word;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notify-item__time {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.notify-item__delete {
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.notify-item:hover .notify-item__delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.notify-item__delete:hover {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
.notify-detail__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notify-detail__icon {
|
||||
flex: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.notify-detail__icon--error {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
.notify-detail__icon--warning {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #d97706;
|
||||
}
|
||||
.notify-detail__icon--success {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #059669;
|
||||
}
|
||||
.notify-detail__icon--info,
|
||||
.notify-detail__icon:not(.notify-detail__icon--error):not(.notify-detail__icon--warning):not(
|
||||
.notify-detail__icon--success
|
||||
) {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.notify-detail__title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.notify-detail__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.notify-detail__message {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.notify-detail__meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.notify-detail__failures {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notify-detail__failures-head {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-sunken);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.notify-detail__failure-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.notify-detail__failure-list li {
|
||||
display: grid;
|
||||
grid-template-columns: 52px minmax(0, 1fr);
|
||||
gap: 4px 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-soft, var(--border));
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.notify-detail__failure-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.notify-detail__failure-kind {
|
||||
grid-row: span 2;
|
||||
align-self: start;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.notify-detail__failure-path {
|
||||
color: var(--text);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.notify-detail__failure-reason {
|
||||
grid-column: 2;
|
||||
color: #d97706;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.notify-panel {
|
||||
width: min(320px, calc(100vw - 48px));
|
||||
}
|
||||
|
||||
.notify-item__delete {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label?: string;
|
||||
}>(),
|
||||
{
|
||||
label: "更多操作",
|
||||
},
|
||||
);
|
||||
|
||||
const open = ref(false);
|
||||
const triggerRef = ref<HTMLButtonElement | null>(null);
|
||||
const menuRef = ref<HTMLElement | null>(null);
|
||||
const menuStyle = ref<Record<string, string>>({});
|
||||
|
||||
function updateMenuPosition() {
|
||||
const trigger = triggerRef.value;
|
||||
if (!trigger) return;
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const menuHeight = menuRef.value?.offsetHeight ?? 0;
|
||||
const menuWidth = 156;
|
||||
const margin = 12;
|
||||
const left = Math.min(
|
||||
Math.max(margin, rect.right - menuWidth),
|
||||
window.innerWidth - menuWidth - margin,
|
||||
);
|
||||
const top = Math.max(margin, rect.top - menuHeight - 8);
|
||||
menuStyle.value = {
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
minWidth: `${menuWidth}px`,
|
||||
};
|
||||
}
|
||||
|
||||
async function toggleMenu(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
open.value = !open.value;
|
||||
if (open.value) {
|
||||
await nextTick();
|
||||
updateMenuPosition();
|
||||
}
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function handleDocumentClick(event: MouseEvent) {
|
||||
if (!open.value) return;
|
||||
const target = event.target as Node;
|
||||
if (triggerRef.value?.contains(target) || menuRef.value?.contains(target)) return;
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") closeMenu();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", handleDocumentClick);
|
||||
window.addEventListener("resize", updateMenuPosition);
|
||||
window.addEventListener("scroll", updateMenuPosition, true);
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("click", handleDocumentClick);
|
||||
window.removeEventListener("resize", updateMenuPosition);
|
||||
window.removeEventListener("scroll", updateMenuPosition, true);
|
||||
document.removeEventListener("keydown", handleKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-row-actions">
|
||||
<div class="admin-row-actions__desktop">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="admin-row-actions__mobile">
|
||||
<button
|
||||
ref="triggerRef"
|
||||
type="button"
|
||||
class="admin-row-actions__trigger"
|
||||
:aria-label="label"
|
||||
:aria-expanded="open"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
ref="menuRef"
|
||||
class="admin-row-actions__menu"
|
||||
:style="menuStyle"
|
||||
@click="closeMenu"
|
||||
>
|
||||
<slot name="menu" />
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-row-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.admin-row-actions__desktop {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.admin-row-actions__mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-row-actions__trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
gap: 3px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.admin-row-actions__trigger span {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.admin-row-actions__trigger:hover,
|
||||
.admin-row-actions__trigger[aria-expanded="true"] {
|
||||
border-color: color-mix(in srgb, var(--brand) 40%, var(--border-soft));
|
||||
color: var(--brand);
|
||||
background: color-mix(in srgb, var(--brand) 8%, var(--surface));
|
||||
}
|
||||
|
||||
.admin-row-actions__menu {
|
||||
position: fixed;
|
||||
z-index: 2200;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-popover, 0 14px 34px rgb(15 23 42 / 16%));
|
||||
}
|
||||
|
||||
:slotted(.admin-row-actions__item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-main);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:slotted(.admin-row-actions__item:hover) {
|
||||
background: color-mix(in srgb, var(--brand) 8%, transparent);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
:slotted(.admin-row-actions__item--danger) {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
:slotted(.admin-row-actions__item:disabled) {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:slotted(.admin-row-actions__item:disabled:hover) {
|
||||
background: transparent;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.admin-row-actions__desktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-row-actions__mobile {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
:global([data-skin="brutal"]) .admin-row-actions__trigger,
|
||||
:global([data-skin="brutal"]) .admin-row-actions__menu {
|
||||
border: var(--brutal-border-width, 2px) solid var(--text-main);
|
||||
border-radius: 0;
|
||||
box-shadow: var(--brutal-shadow, 3px 3px 0 var(--text-main));
|
||||
}
|
||||
|
||||
:global([data-skin="brutal"]) .admin-row-actions__trigger span {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
:global([data-skin="brutal"]) :slotted(.admin-row-actions__item) {
|
||||
border-radius: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import AdminStatusIcon from "@/components/admin/AdminStatusIcon.vue";
|
||||
import type { AdminRunStatusVariant } from "@/components/admin/adminRunStatus";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
primary: string;
|
||||
summary?: string;
|
||||
variant: AdminRunStatusVariant;
|
||||
live?: boolean;
|
||||
textLayout?: "hover" | "column";
|
||||
primaryTone?: "muted" | "default" | "strong";
|
||||
}>(),
|
||||
{
|
||||
live: false,
|
||||
textLayout: "hover",
|
||||
primaryTone: "muted",
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-run-status" :class="{ 'admin-run-status--live': live }" :title="title">
|
||||
<AdminStatusIcon :variant="variant" />
|
||||
<span
|
||||
class="admin-run-status__text"
|
||||
:class="{
|
||||
'admin-run-status__text--hover': textLayout === 'hover' && !live,
|
||||
'admin-run-status__text--live': textLayout === 'hover' && live,
|
||||
'admin-run-status__text--column': textLayout === 'column',
|
||||
}"
|
||||
>
|
||||
<span
|
||||
class="admin-run-status__primary"
|
||||
:class="{
|
||||
'admin-run-status__primary--default': primaryTone === 'default',
|
||||
'admin-run-status__primary--strong': primaryTone === 'strong',
|
||||
}"
|
||||
>
|
||||
{{ primary }}
|
||||
</span>
|
||||
<span v-if="summary" class="admin-run-status__summary">{{ summary }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-run-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-run-status--live {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.admin-run-status--live :deep(.admin-status-icon) {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.admin-run-status__text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-run-status__text--hover {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.admin-run-status__text--live,
|
||||
.admin-run-status__text--column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.admin-run-status__primary,
|
||||
.admin-run-status__summary {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.admin-run-status__primary {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.admin-run-status__primary--default,
|
||||
.admin-run-status__primary--strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.admin-run-status__primary--strong {
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.admin-run-status__summary {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-run-status__text--column .admin-run-status__summary {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.admin-run-status__text--hover .admin-run-status__primary,
|
||||
.admin-run-status__text--hover .admin-run-status__summary {
|
||||
grid-area: 1 / 1;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.admin-run-status__text--hover .admin-run-status__summary {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.admin-run-status__text--hover:hover .admin-run-status__summary {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.admin-run-status__text--hover:hover .admin-run-status__primary {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.admin-run-status__text--live .admin-run-status__primary,
|
||||
.admin-run-status__text--live .admin-run-status__summary {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,209 @@
|
||||
<script setup lang="ts">
|
||||
import AppButton from "@/components/base/AppButton.vue";
|
||||
|
||||
defineProps<{
|
||||
open: boolean;
|
||||
title: string;
|
||||
saving?: boolean;
|
||||
canSave?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
cancel: [];
|
||||
save: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="admin-settings-drawer" :class="{ 'admin-settings-drawer--open': open }">
|
||||
<div
|
||||
class="admin-settings-drawer__backdrop"
|
||||
aria-hidden="true"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
<aside
|
||||
class="admin-settings-drawer__panel"
|
||||
role="dialog"
|
||||
:aria-hidden="!open"
|
||||
:aria-label="title"
|
||||
>
|
||||
<div class="admin-settings-drawer__scroll">
|
||||
<div class="admin-settings-drawer__head">
|
||||
<button type="button" class="admin-settings-drawer__close" aria-label="返回" title="返回" @click="emit('close')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M5 12h14m-6-6 6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-settings-drawer__body">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="admin-settings-drawer__foot">
|
||||
<AppButton type="button" variant="secondary" @click="emit('cancel')">取消</AppButton>
|
||||
<AppButton type="button" variant="primary" :disabled="!canSave || saving" @click="emit('save')">
|
||||
{{ saving ? "保存中…" : "保存设置" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-settings-drawer {
|
||||
position: fixed;
|
||||
top: var(--admin-chrome-h, 44px);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: var(--sidebar-width, 220px);
|
||||
pointer-events: none;
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
.admin-settings-drawer--open {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.admin-settings-drawer__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
opacity: 0;
|
||||
transition: opacity 0.22s ease;
|
||||
}
|
||||
|
||||
.admin-settings-drawer--open .admin-settings-drawer__backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.admin-settings-drawer__panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-pop, 0 12px 40px rgba(15, 23, 42, 0.12));
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.24s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-settings-drawer--open .admin-settings-drawer__panel {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.admin-settings-drawer__scroll {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.admin-settings-drawer__head {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding: 10px 12px 4px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.admin-settings-drawer__head::before {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
top: 14px;
|
||||
left: 16px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #4c74df;
|
||||
content: "";
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: scale(0.7);
|
||||
}
|
||||
|
||||
.admin-settings-drawer__close {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-settings-drawer__close::before {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: 5px;
|
||||
border-radius: 50%;
|
||||
background: #4c74df;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.admin-settings-drawer__close svg {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.admin-settings-drawer__close:hover {
|
||||
color: #ffffff;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.admin-settings-drawer__close:hover::before {
|
||||
background: #4c74df;
|
||||
}
|
||||
|
||||
.admin-settings-drawer__close:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--brand) 58%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.admin-settings-drawer--open .admin-settings-drawer__head::before {
|
||||
animation: admin-settings-drawer-back-halo 1s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes admin-settings-drawer-back-halo {
|
||||
0% {
|
||||
opacity: 0.78;
|
||||
transform: scale(0.7);
|
||||
}
|
||||
70% {
|
||||
opacity: 0.28;
|
||||
transform: scale(1.55);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(1.55);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-settings-drawer__body {
|
||||
padding: 4px 16px 12px;
|
||||
}
|
||||
|
||||
.admin-settings-drawer__foot {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-settings-drawer {
|
||||
top: var(--admin-chrome-h, 42px);
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,529 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import AdminAccountChip from "@/components/admin/AdminAccountChip.vue";
|
||||
import AdminGlobalActions from "@/components/admin/AdminGlobalActions.vue";
|
||||
import AdminNavIcon from "@/components/admin/AdminNavIcon.vue";
|
||||
|
||||
interface NavItem {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = "litepan-admin-sidebar-collapsed";
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
nav: NavItem[];
|
||||
modelValue: string;
|
||||
pageTitle?: string;
|
||||
lockedKeys?: string[];
|
||||
homeReturnMode?: "sidebar" | "top_icon";
|
||||
}>(),
|
||||
{ homeReturnMode: "top_icon" },
|
||||
);
|
||||
const emit = defineEmits<{ "update:modelValue": [string]; logout: []; goHome: [] }>();
|
||||
|
||||
const sidebarCollapsed = ref(false);
|
||||
const mobileDrawerOpen = ref(false);
|
||||
const isMobile = ref(false);
|
||||
|
||||
const sidebarCompact = computed(() => !isMobile.value && sidebarCollapsed.value);
|
||||
|
||||
const sidebarToggleLabel = computed(() => {
|
||||
if (isMobile.value) return mobileDrawerOpen.value ? "关闭菜单" : "打开菜单";
|
||||
return sidebarCollapsed.value ? "展开侧栏" : "收起侧栏";
|
||||
});
|
||||
|
||||
function readCollapsedPref() {
|
||||
try {
|
||||
sidebarCollapsed.value = localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1";
|
||||
} catch {
|
||||
sidebarCollapsed.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function persistCollapsedPref() {
|
||||
try {
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, sidebarCollapsed.value ? "1" : "0");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function syncViewport() {
|
||||
isMobile.value = window.innerWidth <= MOBILE_BREAKPOINT;
|
||||
if (!isMobile.value) mobileDrawerOpen.value = false;
|
||||
}
|
||||
|
||||
function syncSidebarWidthVar() {
|
||||
const width = isMobile.value ? "0px" : sidebarCollapsed.value ? "64px" : "220px";
|
||||
document.documentElement.style.setProperty("--sidebar-width", width);
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
if (isMobile.value) {
|
||||
mobileDrawerOpen.value = !mobileDrawerOpen.value;
|
||||
return;
|
||||
}
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
persistCollapsedPref();
|
||||
}
|
||||
|
||||
function closeMobileDrawer() {
|
||||
mobileDrawerOpen.value = false;
|
||||
}
|
||||
|
||||
function selectNav(key: string) {
|
||||
emit("update:modelValue", key);
|
||||
closeMobileDrawer();
|
||||
}
|
||||
|
||||
function goHomeFromSidebar() {
|
||||
emit("goHome");
|
||||
closeMobileDrawer();
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && mobileDrawerOpen.value) closeMobileDrawer();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
readCollapsedPref();
|
||||
syncViewport();
|
||||
syncSidebarWidthVar();
|
||||
window.addEventListener("resize", syncViewport);
|
||||
window.addEventListener("keydown", onKeydown);
|
||||
});
|
||||
|
||||
watch([sidebarCollapsed, isMobile], () => {
|
||||
syncSidebarWidthVar();
|
||||
});
|
||||
|
||||
watch(mobileDrawerOpen, (open) => {
|
||||
if (typeof document === "undefined") return;
|
||||
document.body.style.overflow = open && isMobile.value ? "hidden" : "";
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", syncViewport);
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
document.body.style.overflow = "";
|
||||
document.documentElement.style.removeProperty("--sidebar-width");
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="admin"
|
||||
:class="{
|
||||
'admin--collapsed': sidebarCompact,
|
||||
'admin--drawer-open': isMobile && mobileDrawerOpen,
|
||||
'admin--mobile': isMobile,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="isMobile"
|
||||
class="sidebar-backdrop"
|
||||
:class="{ 'sidebar-backdrop--visible': mobileDrawerOpen }"
|
||||
aria-hidden="true"
|
||||
@click="closeMobileDrawer"
|
||||
/>
|
||||
|
||||
<aside class="sidebar">
|
||||
<header class="sidebar__header">
|
||||
<img
|
||||
:src="sidebarCompact ? '/static/img/logo-l.png' : '/static/img/logo.png'"
|
||||
alt="LitePan"
|
||||
class="sidebar__logo"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<nav class="sidebar__nav">
|
||||
<button
|
||||
v-for="item in nav"
|
||||
:key="item.key"
|
||||
class="nav-item"
|
||||
:class="{
|
||||
'nav-item--active': item.key === modelValue,
|
||||
'nav-item--locked': lockedKeys?.includes(item.key),
|
||||
}"
|
||||
:disabled="lockedKeys?.includes(item.key)"
|
||||
@click="selectNav(item.key)"
|
||||
>
|
||||
<AdminNavIcon :name="item.icon" class="nav-item__icon" />
|
||||
<span class="nav-item__label">{{ item.label }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="homeReturnMode === 'sidebar'"
|
||||
type="button"
|
||||
class="nav-item nav-item--home"
|
||||
@click="goHomeFromSidebar"
|
||||
>
|
||||
<AdminNavIcon name="home" class="nav-item__icon" />
|
||||
<span class="nav-item__label">返回首页</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<footer class="sidebar__footer">
|
||||
<AdminAccountChip :compact="sidebarCompact" @logout="emit('logout')" />
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<header class="global-chrome">
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-toggle"
|
||||
:class="{ 'sidebar-toggle--active': isMobile && mobileDrawerOpen }"
|
||||
:aria-label="sidebarToggleLabel"
|
||||
:aria-expanded="isMobile ? mobileDrawerOpen : !sidebarCollapsed"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<svg v-if="isMobile && mobileDrawerOpen" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m6 6 12 12M18 6 6 18" />
|
||||
</svg>
|
||||
<svg v-else-if="isMobile" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 7h16M4 12h16M4 17h16" />
|
||||
</svg>
|
||||
<svg v-else-if="sidebarCollapsed" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m10 6 6 6-6 6M4 6v12" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m14 6-6 6 6 6M20 6v12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-if="pageTitle" class="global-chrome__context">
|
||||
<span class="global-chrome__crumb">后台</span>
|
||||
<span class="global-chrome__sep">/</span>
|
||||
<span class="global-chrome__title">{{ pageTitle }}</span>
|
||||
</div>
|
||||
<div class="global-chrome__spacer" />
|
||||
<AdminGlobalActions
|
||||
:show-home-return="homeReturnMode === 'top_icon'"
|
||||
@go-home="emit('goHome')"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<main class="admin__body">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin {
|
||||
--admin-chrome-h: 44px;
|
||||
--sidebar-width: 220px;
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
|
||||
grid-template-rows: var(--admin-chrome-h) minmax(0, 1fr);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.admin--collapsed {
|
||||
--sidebar-width: 64px;
|
||||
}
|
||||
|
||||
.admin--mobile {
|
||||
--sidebar-width: 0px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / -1;
|
||||
z-index: 120;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--admin-sidebar-bg);
|
||||
border-right: 1px solid var(--admin-sidebar-border);
|
||||
box-shadow: var(--admin-sidebar-shadow);
|
||||
color: #fff;
|
||||
border-top-right-radius: var(--radius-lg);
|
||||
transition: transform 0.28s ease, box-shadow 0.28s ease;
|
||||
}
|
||||
|
||||
.sidebar__header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 98px;
|
||||
padding: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.sidebar__logo {
|
||||
max-width: 128px;
|
||||
max-height: 52px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
transition: max-width 0.2s ease, max-height 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar__nav {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 16px 16px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.35) transparent;
|
||||
}
|
||||
|
||||
.sidebar__nav::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.sidebar__nav::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.sidebar__nav::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar__footer {
|
||||
flex-shrink: 0;
|
||||
padding: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 50px;
|
||||
padding: 0 20px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-item:hover:not(.nav-item--active):not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-item--active,
|
||||
.nav-item--active:hover {
|
||||
background: #fff;
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.nav-item--home {
|
||||
text-decoration: none;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.nav-item--locked,
|
||||
.nav-item:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nav-item:disabled:hover {
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.nav-item__icon {
|
||||
margin-right: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-item__label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin--collapsed .sidebar__header {
|
||||
height: 98px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.admin--collapsed .sidebar__logo {
|
||||
max-width: 28px;
|
||||
max-height: 34px;
|
||||
}
|
||||
|
||||
.admin--collapsed .sidebar__nav {
|
||||
padding: 10px 8px 12px;
|
||||
}
|
||||
|
||||
.admin--collapsed .sidebar__footer {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.admin--collapsed .nav-item {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.admin--collapsed .nav-item__icon {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.admin--collapsed .nav-item__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.sidebar-toggle:hover,
|
||||
.sidebar-toggle--active {
|
||||
color: var(--brand);
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.sidebar-toggle svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.global-chrome {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: var(--admin-chrome-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-left: calc(var(--sidebar-width) + 8px);
|
||||
padding-right: 22px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.global-chrome__context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.global-chrome__crumb {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.global-chrome__sep {
|
||||
flex-shrink: 0;
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
.global-chrome__title {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.global-chrome__spacer {
|
||||
flex: 1;
|
||||
min-width: 12px;
|
||||
}
|
||||
|
||||
.admin__body {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
min-height: 0;
|
||||
padding: 24px;
|
||||
overflow-x: clip;
|
||||
overflow-y: auto;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 110;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.22s ease;
|
||||
}
|
||||
|
||||
.sidebar-backdrop--visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: min(260px, 82vw);
|
||||
height: 100vh;
|
||||
transform: translateX(-100%);
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.admin--drawer-open .sidebar {
|
||||
transform: translateX(0);
|
||||
box-shadow: 2px 0 16px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.global-chrome {
|
||||
--admin-chrome-h: 42px;
|
||||
grid-column: 1;
|
||||
padding-left: 14px;
|
||||
padding-right: 14px;
|
||||
}
|
||||
|
||||
.global-chrome__context {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin__body {
|
||||
grid-column: 1;
|
||||
padding: 16px 14px 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
seconds: number;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="seconds > 0" class="admin-startup-banner">
|
||||
任务启动中,预计 <strong>{{ seconds }}</strong> 秒后开始执行…
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-startup-banner {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
background: color-mix(in srgb, var(--brand) 10%, var(--surface));
|
||||
border: 1px solid color-mix(in srgb, var(--brand) 24%, var(--border-soft));
|
||||
color: color-mix(in srgb, var(--brand) 70%, var(--text));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import "@/styles/admin-shared.css";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-stats-grid">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import BusySpinner from "@/components/base/BusySpinner.vue";
|
||||
import type { AdminRunStatusVariant } from "@/components/admin/adminRunStatus";
|
||||
|
||||
defineProps<{
|
||||
variant: AdminRunStatusVariant;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="admin-status-icon" :class="`admin-status-icon--${variant}`" aria-hidden="true">
|
||||
<BusySpinner v-if="variant === 'running'" variant="notch" :size="14" />
|
||||
<svg
|
||||
v-else-if="variant === 'success'"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m3.5 8.5 3 3 6-7" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="variant === 'error'"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M8 4.25v4.5" />
|
||||
<circle cx="8" cy="11.75" r=".75" fill="currentColor" stroke="none" />
|
||||
<path d="M8 1.75 14 14.25H2L8 1.75Z" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="8" cy="8" r="5.75" />
|
||||
<path d="M8 5.25v3.25l2 1.25" />
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-status-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-status-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.admin-status-icon--success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.admin-status-icon--error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.admin-status-icon--pending {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.admin-status-icon--running {
|
||||
color: var(--brand);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
export type AdminStatusPillTone = "success" | "warning" | "brand" | "danger" | "muted";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
tone?: AdminStatusPillTone;
|
||||
}>(),
|
||||
{ tone: "muted" },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="admin-status-pill" :class="`admin-status-pill--${tone}`">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
min-width: 50px;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--radius-pill, 999px);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-status-pill--success {
|
||||
background: color-mix(in srgb, var(--success) 12%, transparent);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.admin-status-pill--warning {
|
||||
background: color-mix(in srgb, var(--warning) 12%, transparent);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.admin-status-pill--brand {
|
||||
background: color-mix(in srgb, var(--brand) 12%, transparent);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.admin-status-pill--danger {
|
||||
background: color-mix(in srgb, var(--danger) 12%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.admin-status-pill--muted {
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
type AdminTableActionIcon = "play" | "stop" | "edit" | "delete" | "log";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
icon: AdminTableActionIcon;
|
||||
title: string;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{ danger: false, disabled: false },
|
||||
);
|
||||
|
||||
defineEmits<{ click: [MouseEvent] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-table__action-btn"
|
||||
:class="{ 'admin-table__action-btn--danger': danger }"
|
||||
:title="title"
|
||||
:disabled="disabled"
|
||||
@click="$emit('click', $event)"
|
||||
>
|
||||
<svg
|
||||
v-if="icon === 'play'"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 2.5v11l9-5.5-9-5.5z" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="icon === 'stop'"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="4" y="4" width="8" height="8" rx="1" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="icon === 'edit'"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M2.5 13.5h3l8-8-3-3-8 8v3z" />
|
||||
<path d="M9.5 3.5l3 3" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="icon === 'log'"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="2" y="3" width="12" height="10" rx="1.5" />
|
||||
<path d="M5 6h6M5 8.5h4" stroke-linecap="round" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M3 4h10" stroke-linecap="round" />
|
||||
<path d="M6 4V3.2c0-.7.5-1.2 1.2-1.2h1.6c.7 0 1.2.5 1.2 1.2V4" />
|
||||
<path d="M5 6v7c0 .6.4 1 1 1h4c.6 0 1-.4 1-1V6" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import AppIconButton from "@/components/base/AppIconButton.vue";
|
||||
import StatCard from "@/components/base/StatCard.vue";
|
||||
import AdminStatsGrid from "@/components/admin/AdminStatsGrid.vue";
|
||||
import SettingsEntryCard from "@/components/admin/SettingsEntryCard.vue";
|
||||
import type { AdminTaskTabStat } from "@/components/admin/adminTaskTabHeader";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
stats?: AdminTaskTabStat[];
|
||||
settingsTitle: string;
|
||||
settingsHint?: string;
|
||||
refreshing?: boolean;
|
||||
}>(),
|
||||
{
|
||||
stats: () => [],
|
||||
refreshing: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
"open-settings": [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminStatsGrid>
|
||||
<slot />
|
||||
<StatCard
|
||||
v-for="(stat, index) in stats"
|
||||
:key="`${stat.label}-${index}`"
|
||||
:icon="stat.icon"
|
||||
:value="stat.value"
|
||||
:label="stat.label"
|
||||
:tone="stat.tone ?? 'blue'"
|
||||
>
|
||||
<template v-if="stat.refresh" #actions>
|
||||
<AppIconButton
|
||||
icon="fa-sync-alt"
|
||||
label="刷新"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
:disabled="refreshing"
|
||||
title="刷新任务列表"
|
||||
@click="emit('refresh')"
|
||||
/>
|
||||
</template>
|
||||
</StatCard>
|
||||
<SettingsEntryCard
|
||||
:title="settingsTitle"
|
||||
:hint="settingsHint"
|
||||
@click="emit('open-settings')"
|
||||
/>
|
||||
</AdminStatsGrid>
|
||||
</template>
|
||||
@@ -0,0 +1,583 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watchEffect } from "vue";
|
||||
import { getApiErrorMessage } from "@/api/client";
|
||||
import {
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
fetchApiKeys,
|
||||
rotateStrmKey,
|
||||
toggleApiKey,
|
||||
updateApiKey,
|
||||
type ApiKeyRecord,
|
||||
type StrmKeyInfo,
|
||||
} from "@/api/apiKeys";
|
||||
import AppButton from "@/components/base/AppButton.vue";
|
||||
import AppBadge from "@/components/base/AppBadge.vue";
|
||||
import AppModal from "@/components/base/AppModal.vue";
|
||||
import AppSelect from "@/components/base/AppSelect.vue";
|
||||
import AppStateBlock from "@/components/base/AppStateBlock.vue";
|
||||
import FormField from "@/components/base/FormField.vue";
|
||||
import AppInput from "@/components/base/AppInput.vue";
|
||||
import AdminEnableToggle from "@/components/admin/AdminEnableToggle.vue";
|
||||
import AdminRowActions from "@/components/admin/AdminRowActions.vue";
|
||||
import AdminTableActionBtn from "@/components/admin/AdminTableActionBtn.vue";
|
||||
import SettingsCard from "@/components/admin/SettingsCard.vue";
|
||||
import { useConfirm } from "@/composables/useConfirm";
|
||||
import { toast, copyTextToClipboard } from "@/composables/useToast";
|
||||
import "@/styles/admin-table.css";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
accent?: string;
|
||||
}>(),
|
||||
{ accent: "var(--brand)" },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"toolbar-state": [state: { loading: boolean; keyCount: number; maxKeys: number }];
|
||||
}>();
|
||||
|
||||
const { showConfirm } = useConfirm();
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const dialogOpen = ref(false);
|
||||
const createdDialogOpen = ref(false);
|
||||
const createdKeyValue = ref("");
|
||||
const createdKeyRecord = ref<ApiKeyRecord | null>(null);
|
||||
const editingKey = ref<ApiKeyRecord | null>(null);
|
||||
const keys = ref<ApiKeyRecord[]>([]);
|
||||
const maxKeys = ref(10);
|
||||
const strmKey = reactive<StrmKeyInfo>({
|
||||
name: "STRM 媒体访问",
|
||||
key: "",
|
||||
key_preview: "",
|
||||
format: "new",
|
||||
status: "active",
|
||||
system: true,
|
||||
deletable: false,
|
||||
disableable: false,
|
||||
});
|
||||
|
||||
const form = reactive({
|
||||
name: "",
|
||||
key_type: "task",
|
||||
expires_days: "90",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
const keyTypeOptions = [
|
||||
{ value: "task", label: "任务执行" },
|
||||
{ value: "readonly", label: "只读查询" },
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "active", label: "启用" },
|
||||
{ value: "disabled", label: "禁用" },
|
||||
];
|
||||
|
||||
const expiryOptions = computed(() => {
|
||||
const base = [
|
||||
{ value: "30", label: "30 天" },
|
||||
{ value: "90", label: "90 天" },
|
||||
{ value: "365", label: "365 天" },
|
||||
{ value: "0", label: "永不过期" },
|
||||
];
|
||||
return editingKey.value ? [{ value: "-1", label: "保持不变" }, ...base] : base;
|
||||
});
|
||||
|
||||
const keyCount = computed(() => keys.value.length);
|
||||
|
||||
watchEffect(() => {
|
||||
emit("toolbar-state", {
|
||||
loading: loading.value,
|
||||
keyCount: keys.value.length,
|
||||
maxKeys: maxKeys.value,
|
||||
});
|
||||
});
|
||||
|
||||
function keyTypeLabel(type: string): string {
|
||||
return type === "readonly" ? "只读查询" : "任务执行";
|
||||
}
|
||||
|
||||
function csvEscape(value: unknown): string {
|
||||
const text = String(value ?? "");
|
||||
return `"${text.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function downloadCreatedApiKeyCsv() {
|
||||
const record: Partial<ApiKeyRecord> = createdKeyRecord.value ?? {};
|
||||
const rows = [
|
||||
["名称", "秘钥", "类型", "状态", "有效期", "创建时间"],
|
||||
[
|
||||
record.name || "",
|
||||
createdKeyValue.value || "",
|
||||
keyTypeLabel(record.key_type || "task"),
|
||||
record.status === "disabled" ? "已禁用" : "启用",
|
||||
formatExpiry(record.expires_at),
|
||||
formatExpiry(record.created_at),
|
||||
],
|
||||
];
|
||||
const csv = rows.map((row) => row.map(csvEscape).join(",")).join("\n");
|
||||
const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
const safeName = String(record.name || "api-key").replace(/[\\/:*?"<>|]/g, "_");
|
||||
link.href = url;
|
||||
link.download = `${safeName}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function formatExpiry(value?: string): string {
|
||||
if (!value) return "永不过期";
|
||||
return value.replace("T", " ").replace("Z", "").slice(0, 16);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await fetchApiKeys();
|
||||
keys.value = data.keys ?? [];
|
||||
maxKeys.value = data.max_keys ?? 10;
|
||||
Object.assign(strmKey, data.strm_key ?? {});
|
||||
} catch (e) {
|
||||
toast.error(getApiErrorMessage(e, "加载 API 秘钥失败"));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.name = "";
|
||||
form.key_type = "task";
|
||||
form.expires_days = "90";
|
||||
form.status = "active";
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingKey.value = null;
|
||||
resetForm();
|
||||
dialogOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(key: ApiKeyRecord) {
|
||||
editingKey.value = key;
|
||||
form.name = key.name;
|
||||
form.key_type = key.key_type || "task";
|
||||
form.status = key.status || "active";
|
||||
form.expires_days = "-1";
|
||||
dialogOpen.value = true;
|
||||
}
|
||||
|
||||
async function saveKey() {
|
||||
const name = form.name.trim();
|
||||
if (!name) {
|
||||
toast.error("请填写名称");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const expiresDays = Number(form.expires_days);
|
||||
const payload = {
|
||||
name,
|
||||
key_type: form.key_type,
|
||||
status: form.status,
|
||||
expires_days: Number.isFinite(expiresDays) && expiresDays >= 0 ? expiresDays : undefined,
|
||||
};
|
||||
if (editingKey.value?.id) {
|
||||
await updateApiKey(editingKey.value.id, {
|
||||
...payload,
|
||||
expires_days: expiresDays < 0 ? -1 : payload.expires_days,
|
||||
});
|
||||
toast.success("秘钥已更新");
|
||||
dialogOpen.value = false;
|
||||
await load();
|
||||
} else {
|
||||
const created = await createApiKey(payload);
|
||||
dialogOpen.value = false;
|
||||
createdKeyValue.value = created.key || "";
|
||||
createdKeyRecord.value = created;
|
||||
createdDialogOpen.value = true;
|
||||
await load();
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(getApiErrorMessage(e, "保存秘钥失败"));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(key: ApiKeyRecord) {
|
||||
if (!key.id) return;
|
||||
try {
|
||||
await toggleApiKey(key.id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error(getApiErrorMessage(e, "切换状态失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(key: ApiKeyRecord) {
|
||||
if (!key.id) return;
|
||||
try {
|
||||
await showConfirm({
|
||||
title: "删除 API 秘钥",
|
||||
message: `确定删除「${key.name}」吗?删除后无法恢复。`,
|
||||
icon: "trash",
|
||||
confirmText: "删除",
|
||||
danger: true,
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteApiKey(key.id);
|
||||
toast.success("秘钥已删除");
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error(getApiErrorMessage(e, "删除失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRotateStrm() {
|
||||
try {
|
||||
const result = await showConfirm({
|
||||
title: "更换 STRM 媒体访问 Key",
|
||||
message: "更换后旧 Key 将无法继续用于播放鉴权。是否同时将新 Key 应用到已有 .strm 文件?",
|
||||
icon: "question",
|
||||
confirmText: "更换",
|
||||
danger: false,
|
||||
checkboxLabel: "将新 Key 应用到已有 strm 中",
|
||||
checkboxDefault: true,
|
||||
});
|
||||
const apply = result.checked !== false;
|
||||
const data = await rotateStrmKey(apply);
|
||||
Object.assign(strmKey, data.strm_key);
|
||||
const rep = data.replace_result;
|
||||
if (rep) {
|
||||
toast.success(`STRM Key 已更换,已更新 ${rep.updated}/${rep.matched} 个匹配文件`);
|
||||
} else {
|
||||
toast.success("STRM Key 已更换");
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "Modal closed") return;
|
||||
const msg = getApiErrorMessage(e, "");
|
||||
if (msg) toast.error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text: string) {
|
||||
await copyTextToClipboard(text);
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
defineExpose({ openCreate });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsCard title="秘钥列表" :accent="props.accent">
|
||||
<p class="api-keys__meta">
|
||||
STRM 媒体访问 Key 为系统内置播放鉴权令牌;普通秘钥供后续自动联动 Webhook 等外部调用。
|
||||
<span class="api-keys__meta-count">普通秘钥 {{ keyCount }}/{{ maxKeys }}</span>
|
||||
</p>
|
||||
|
||||
<AppStateBlock v-if="loading" message="加载秘钥中…" loading min-height="180px" />
|
||||
|
||||
<div v-else class="api-keys__table-scroll">
|
||||
<table class="api-keys__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>Key</th>
|
||||
<th>类型</th>
|
||||
<th>状态</th>
|
||||
<th>有效期</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="api-keys__row">
|
||||
<td>
|
||||
<div class="api-keys__name">
|
||||
<span class="api-keys__name-text">STRM 媒体访问</span>
|
||||
<AppBadge tone="info">系统内置</AppBadge>
|
||||
</div>
|
||||
</td>
|
||||
<td><code class="api-keys__token">{{ strmKey.key_preview || "—" }}</code></td>
|
||||
<td><AppBadge tone="info">播放鉴权</AppBadge></td>
|
||||
<td><AppBadge tone="success">启用</AppBadge></td>
|
||||
<td class="api-keys__muted">永不过期</td>
|
||||
<td>
|
||||
<AdminRowActions>
|
||||
<div class="api-keys__actions">
|
||||
<button type="button" class="admin-table__action-btn" title="复制" @click="copyText(strmKey.key)">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="5.5" y="5.5" width="8" height="8" rx="1.2" />
|
||||
<path d="M10.5 5.5V4.2c0-.7-.5-1.2-1.2-1.2H4.2c-.7 0-1.2.5-1.2 1.2v5.1c0 .7.5 1.2 1.2 1.2H5.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="admin-table__action-btn" title="更换 Key" :disabled="loading" @click="handleRotateStrm">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M13 3v4H9" />
|
||||
<path d="M3 13V9h4" />
|
||||
<path d="M11.7 6A4.5 4.5 0 0 0 4 5.5L3 7" />
|
||||
<path d="M4.3 10A4.5 4.5 0 0 0 12 10.5l1-1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<template #menu>
|
||||
<button type="button" class="admin-row-actions__item" @click="copyText(strmKey.key)">复制</button>
|
||||
<button type="button" class="admin-row-actions__item" :disabled="loading" @click="handleRotateStrm">更换 Key</button>
|
||||
</template>
|
||||
</AdminRowActions>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="key in keys" :key="key.id" class="api-keys__row">
|
||||
<td>
|
||||
<span class="api-keys__name-text">{{ key.name }}</span>
|
||||
</td>
|
||||
<td><code class="api-keys__token">{{ key.key_preview }}</code></td>
|
||||
<td><AppBadge tone="warning">{{ keyTypeLabel(key.key_type) }}</AppBadge></td>
|
||||
<td>
|
||||
<AppBadge :tone="key.status === 'active' ? 'success' : 'neutral'">
|
||||
{{ key.status === "active" ? "启用" : "已禁用" }}
|
||||
</AppBadge>
|
||||
</td>
|
||||
<td class="api-keys__muted">{{ formatExpiry(key.expires_at) }}</td>
|
||||
<td>
|
||||
<AdminRowActions>
|
||||
<div class="api-keys__actions">
|
||||
<AdminEnableToggle
|
||||
:enabled="key.status === 'active'"
|
||||
aria-label="API 秘钥启用切换"
|
||||
@enable="handleToggle(key)"
|
||||
/>
|
||||
<AdminTableActionBtn icon="edit" title="编辑" @click="openEdit(key)" />
|
||||
<AdminTableActionBtn icon="delete" title="删除" danger @click="handleDelete(key)" />
|
||||
</div>
|
||||
<template #menu>
|
||||
<button type="button" class="admin-row-actions__item" @click="handleToggle(key)">
|
||||
{{ key.status === "active" ? "禁用秘钥" : "启用秘钥" }}
|
||||
</button>
|
||||
<button type="button" class="admin-row-actions__item" @click="openEdit(key)">编辑</button>
|
||||
<button type="button" class="admin-row-actions__item admin-row-actions__item--danger" @click="handleDelete(key)">
|
||||
删除
|
||||
</button>
|
||||
</template>
|
||||
</AdminRowActions>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<AppModal :open="dialogOpen" size="account" :title="editingKey ? '修改 API 秘钥' : '新增 API 秘钥'" @close="dialogOpen = false">
|
||||
<div class="api-keys__form">
|
||||
<div class="api-keys__form-row">
|
||||
<FormField label="名称 / 用途">
|
||||
<AppInput v-model="form.name" placeholder="例如:自动联动 Webhook" />
|
||||
</FormField>
|
||||
<FormField label="类型">
|
||||
<AppSelect v-model="form.key_type" :options="keyTypeOptions" />
|
||||
</FormField>
|
||||
</div>
|
||||
<div class="api-keys__form-row">
|
||||
<FormField label="有效期">
|
||||
<AppSelect v-model="form.expires_days" :options="expiryOptions" />
|
||||
</FormField>
|
||||
<FormField label="状态">
|
||||
<AppSelect v-model="form.status" :options="statusOptions" />
|
||||
</FormField>
|
||||
</div>
|
||||
<div class="modal-form__footer">
|
||||
<AppButton type="button" variant="secondary" @click="dialogOpen = false">取消</AppButton>
|
||||
<AppButton type="button" variant="primary" :disabled="saving" @click="saveKey">
|
||||
{{ saving ? "保存中…" : editingKey ? "保存秘钥" : "创建秘钥" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</AppModal>
|
||||
|
||||
<AppModal :open="createdDialogOpen" size="account" title="秘钥已创建" @close="createdDialogOpen = false">
|
||||
<div class="api-keys__created">
|
||||
<p>请立即复制保存,关闭后将无法再次查看完整 Key。</p>
|
||||
<code class="api-keys__created-key">{{ createdKeyValue }}</code>
|
||||
<div class="modal-form__footer">
|
||||
<AppButton type="button" variant="primary" @click="copyText(createdKeyValue)">复制 Key</AppButton>
|
||||
<AppButton type="button" variant="secondary" @click="downloadCreatedApiKeyCsv">下载 CSV</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</AppModal>
|
||||
</SettingsCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.api-keys__meta {
|
||||
margin: 0 0 4px;
|
||||
padding-bottom: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.api-keys__meta-count {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-sunken);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.api-keys__table-scroll {
|
||||
overflow-x: auto;
|
||||
margin: 0 -22px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.api-keys__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.api-keys__table th:nth-child(1),
|
||||
.api-keys__table td:nth-child(1) {
|
||||
width: 18%;
|
||||
}
|
||||
|
||||
.api-keys__table th:nth-child(2),
|
||||
.api-keys__table td:nth-child(2) {
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
.api-keys__table th:nth-child(3),
|
||||
.api-keys__table td:nth-child(3) {
|
||||
width: 12%;
|
||||
}
|
||||
|
||||
.api-keys__table th:nth-child(4),
|
||||
.api-keys__table td:nth-child(4) {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.api-keys__table th:nth-child(5),
|
||||
.api-keys__table td:nth-child(5) {
|
||||
width: 18%;
|
||||
}
|
||||
|
||||
.api-keys__table th:last-child,
|
||||
.api-keys__table td:last-child {
|
||||
width: 20%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.api-keys__table th,
|
||||
.api-keys__table td {
|
||||
padding: 12px 22px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.api-keys__table th {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.api-keys__table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.api-keys__row {
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.api-keys__row:hover {
|
||||
background: color-mix(in srgb, var(--brand) 4%, transparent);
|
||||
}
|
||||
|
||||
.api-keys__name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.api-keys__name-text {
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.api-keys__token {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.api-keys__muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.api-keys__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.api-keys__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.api-keys__form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.api-keys__created {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.api-keys__created p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.api-keys__created-key {
|
||||
display: block;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-sunken);
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.api-keys__form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, nextTick, ref, watchEffect } from "vue";
|
||||
import AppButton from "@/components/base/AppButton.vue";
|
||||
import SectionTabBar from "@/components/admin/SectionTabBar.vue";
|
||||
import AdminSettingsDrawer from "@/components/admin/AdminSettingsDrawer.vue";
|
||||
import type StrmScrapePanelComponent from "@/components/admin/StrmScrapePanel.vue";
|
||||
import { useSectionTabRoute } from "@/composables/useSectionTabRoute";
|
||||
import { useSettingsPageDirty } from "@/composables/useSettingsPageDirty";
|
||||
import { readPanelSaving, type SettingsPanelExpose } from "@/composables/useSettingsForm";
|
||||
import "@/styles/admin-shared.css";
|
||||
|
||||
const StrmScrapePanel = defineAsyncComponent(() => import("@/components/admin/StrmScrapePanel.vue"));
|
||||
const StrmScrapeSettings = defineAsyncComponent(() => import("@/components/admin/StrmScrapeSettings.vue"));
|
||||
const ProxyToolsPanel = defineAsyncComponent(() => import("@/components/admin/ProxyToolsPanel.vue"));
|
||||
|
||||
const SCRAPE_TAB = "scrape";
|
||||
const PROXY_TAB = "proxy";
|
||||
const tabs = [
|
||||
{ key: SCRAPE_TAB, label: "STRM 刮削" },
|
||||
{ key: PROXY_TAB, label: "反代工具" },
|
||||
];
|
||||
|
||||
const settingsDrawerOpen = ref(false);
|
||||
const scrapeSettingsVisited = ref(false);
|
||||
const scrapePanelRef = ref<InstanceType<typeof StrmScrapePanelComponent> | null>(null);
|
||||
const scrapeSettingsRef = ref<SettingsPanelExpose | null>(null);
|
||||
const scrapePanelDirty = ref(false);
|
||||
|
||||
watchEffect(() => {
|
||||
scrapePanelDirty.value = (scrapeSettingsRef.value as SettingsPanelExpose | null)?.isDirty?.() ?? false;
|
||||
});
|
||||
|
||||
const settingsPageDirty = computed(() => settingsDrawerOpen.value && scrapePanelDirty.value);
|
||||
|
||||
function revertDrawerSettings() {
|
||||
scrapeSettingsRef.value?.revert?.();
|
||||
}
|
||||
|
||||
const { confirmDiscardChanges } = useSettingsPageDirty(settingsPageDirty, revertDrawerSettings);
|
||||
|
||||
const { activeTab, setActiveTab } = useSectionTabRoute(
|
||||
SCRAPE_TAB,
|
||||
[SCRAPE_TAB, PROXY_TAB],
|
||||
{
|
||||
beforeTabChange: async () => {
|
||||
if (!settingsDrawerOpen.value) return true;
|
||||
const ok = await confirmDiscardChanges(() => scrapePanelDirty.value);
|
||||
if (!ok) return false;
|
||||
settingsDrawerOpen.value = false;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const drawerSaving = computed(() => readPanelSaving(scrapeSettingsRef.value?.saving));
|
||||
const isScrapeTab = computed(() => activeTab.value === SCRAPE_TAB);
|
||||
const isProxyTab = computed(() => activeTab.value === PROXY_TAB);
|
||||
|
||||
async function openScrapeSettings() {
|
||||
scrapeSettingsVisited.value = true;
|
||||
settingsDrawerOpen.value = true;
|
||||
await nextTick();
|
||||
if (scrapeSettingsRef.value && !scrapeSettingsRef.value.isDirty?.()) {
|
||||
await scrapeSettingsRef.value.reload?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function closeSettingsDrawer() {
|
||||
if (!(await confirmDiscardChanges(() => scrapePanelDirty.value))) return;
|
||||
settingsDrawerOpen.value = false;
|
||||
}
|
||||
|
||||
async function handleDrawerSave() {
|
||||
await scrapeSettingsRef.value?.save?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings">
|
||||
<SectionTabBar :model-value="activeTab" :tabs="tabs" @update:model-value="setActiveTab">
|
||||
<template v-if="isScrapeTab" #actions>
|
||||
<AppButton
|
||||
v-if="scrapePanelRef?.running"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@click="scrapePanelRef?.stopScrape()"
|
||||
>
|
||||
停止刮削
|
||||
</AppButton>
|
||||
<AppButton v-else type="button" variant="primary" @click="scrapePanelRef?.startScrape()">
|
||||
开始刮削
|
||||
</AppButton>
|
||||
</template>
|
||||
</SectionTabBar>
|
||||
|
||||
<StrmScrapePanel v-if="isScrapeTab" ref="scrapePanelRef" @open-settings="openScrapeSettings" />
|
||||
<ProxyToolsPanel v-else-if="isProxyTab" />
|
||||
|
||||
<AdminSettingsDrawer
|
||||
:open="settingsDrawerOpen"
|
||||
title="STRM 刮削设置"
|
||||
:saving="drawerSaving"
|
||||
:can-save="scrapePanelDirty"
|
||||
@close="closeSettingsDrawer"
|
||||
@cancel="closeSettingsDrawer"
|
||||
@save="handleDrawerSave"
|
||||
>
|
||||
<StrmScrapeSettings v-if="scrapeSettingsVisited" ref="scrapeSettingsRef" />
|
||||
</AdminSettingsDrawer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,891 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
computed,
|
||||
onActivated,
|
||||
onDeactivated,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
reactive,
|
||||
ref,
|
||||
watchEffect,
|
||||
} from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { getApiErrorMessage } from "@/api/client";
|
||||
import {
|
||||
SCAN_DEPTH_OPTIONS,
|
||||
createCacheRetentionTask,
|
||||
deleteCacheRetentionTask,
|
||||
fetchCacheRetentionDefaults,
|
||||
fetchCacheRetentionStats,
|
||||
fetchCacheRetentionTasks,
|
||||
forceStopCacheRetentionTask,
|
||||
refreshCacheRetentionTask,
|
||||
toggleCacheRetentionTask,
|
||||
updateCacheRetentionTask,
|
||||
type CacheRetentionTask,
|
||||
type CacheRetentionTaskInput,
|
||||
type RetentionRunResult,
|
||||
} from "@/api/cacheRetention";
|
||||
import FormField from "@/components/base/FormField.vue";
|
||||
import AppButton from "@/components/base/AppButton.vue";
|
||||
import AppIconButton from "@/components/base/AppIconButton.vue";
|
||||
import AppInput from "@/components/base/AppInput.vue";
|
||||
import AppModal from "@/components/base/AppModal.vue";
|
||||
import AppSelect from "@/components/base/AppSelect.vue";
|
||||
import AppStateBlock from "@/components/base/AppStateBlock.vue";
|
||||
import StatCard from "@/components/base/StatCard.vue";
|
||||
import TimeWheelPicker from "@/components/base/TimeWheelPicker.vue";
|
||||
import AdminStatsGrid from "@/components/admin/AdminStatsGrid.vue";
|
||||
import AccountFolderField from "@/components/admin/AccountFolderField.vue";
|
||||
import AdminEmptyState from "@/components/admin/AdminEmptyState.vue";
|
||||
import AdminEnableToggle from "@/components/admin/AdminEnableToggle.vue";
|
||||
import AdminRunStatusCell from "@/components/admin/AdminRunStatusCell.vue";
|
||||
import AdminStartupBanner from "@/components/admin/AdminStartupBanner.vue";
|
||||
import AdminStatusPill from "@/components/admin/AdminStatusPill.vue";
|
||||
import AdminTableActionBtn from "@/components/admin/AdminTableActionBtn.vue";
|
||||
import AdminRowActions from "@/components/admin/AdminRowActions.vue";
|
||||
import type { AdminRunStatusVariant } from "@/components/admin/adminRunStatus";
|
||||
import { normalizeRunStatusVariant } from "@/components/admin/adminRunStatus";
|
||||
import FolderPickerModal from "@/components/file/FolderPickerModal.vue";
|
||||
import { useAccountPathLabel } from "@/composables/useAccountPathLabel";
|
||||
import { useConditionalPolling } from "@/composables/useConditionalPolling";
|
||||
import { liveElapsedMs, useLiveElapsedClock } from "@/composables/useLiveElapsedClock";
|
||||
import {
|
||||
applyTimeWindowFromTask,
|
||||
timeWindowPayload,
|
||||
useTimeWindowSchedule,
|
||||
} from "@/composables/useTimeWindowSchedule";
|
||||
import { confirm } from "@/composables/useConfirm";
|
||||
import { useStartupCountdown } from "@/composables/useStartupCountdown";
|
||||
import { toast } from "@/composables/useToast";
|
||||
import { useAccountsStore } from "@/stores/accounts";
|
||||
import { formatCompactDuration, formatElapsedMs, formatRelativeTimeAgo } from "@/utils/format";
|
||||
import "@/styles/admin-table.css";
|
||||
|
||||
const MAX_CONFIGS = 6;
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
hideStats?: boolean;
|
||||
}>(),
|
||||
{ hideStats: false },
|
||||
);
|
||||
|
||||
const accountsStore = useAccountsStore();
|
||||
const { accounts } = storeToRefs(accountsStore);
|
||||
|
||||
const tasks = ref<CacheRetentionTask[]>([]);
|
||||
const refreshing = ref(false);
|
||||
const listReady = ref(false);
|
||||
const { remainingDisplay: startupRemainingDisplay, applyStartupRemaining } = useStartupCountdown();
|
||||
const executingIds = ref<number[]>([]);
|
||||
const pendingIds = ref<number[]>([]);
|
||||
|
||||
const statsTotal = computed(() => tasks.value.length);
|
||||
const statsRunning = computed(() => tasks.value.filter((t) => t.status === "running").length);
|
||||
const statsPaused = computed(() => tasks.value.filter((t) => t.status !== "running").length);
|
||||
|
||||
const activeAccounts = computed(() => accounts.value.filter((a) => a.is_active));
|
||||
|
||||
const dialogOpen = ref(false);
|
||||
const editingId = ref<number | null>(null);
|
||||
const submitting = ref(false);
|
||||
const pickerOpen = ref(false);
|
||||
const timePickerVisible = ref(false);
|
||||
|
||||
const defaults = reactive({
|
||||
api_interval: 200,
|
||||
refresh_interval: 60,
|
||||
scan_depth: 4,
|
||||
});
|
||||
|
||||
type TaskForm = CacheRetentionTaskInput & {
|
||||
time_window_mode: "always" | "custom";
|
||||
};
|
||||
|
||||
const emptyForm = (): TaskForm => ({
|
||||
account_id: 0,
|
||||
parent_id: "0",
|
||||
path: "",
|
||||
scan_depth: defaults.scan_depth,
|
||||
api_interval: defaults.api_interval,
|
||||
refresh_interval: defaults.refresh_interval,
|
||||
time_window_enabled: false,
|
||||
time_start: "00:00",
|
||||
time_end: "23:59",
|
||||
time_window_mode: "always",
|
||||
});
|
||||
|
||||
const form = reactive(emptyForm());
|
||||
|
||||
const scanDepthOptions = SCAN_DEPTH_OPTIONS.map((o) => ({ value: o.value, label: o.label }));
|
||||
|
||||
const { display: sourceDirDisplay, title: sourceDirTitle } = useAccountPathLabel({
|
||||
accountId: computed(() => form.account_id),
|
||||
path: computed(() => form.path),
|
||||
accounts,
|
||||
});
|
||||
|
||||
const { timeWindowDisplay, onTimeWheelConfirm } = useTimeWindowSchedule(form, {
|
||||
pickerVisible: timePickerVisible,
|
||||
});
|
||||
|
||||
function isRetentionBusy() {
|
||||
return (
|
||||
executingIds.value.length > 0 ||
|
||||
pendingIds.value.length > 0 ||
|
||||
tasks.value.some((t) => t.is_running || t.is_pending)
|
||||
);
|
||||
}
|
||||
|
||||
const listPolling = useConditionalPolling({
|
||||
intervalMs: 2500,
|
||||
onTick: () => refreshAll(true),
|
||||
shouldPoll: isRetentionBusy,
|
||||
});
|
||||
|
||||
const elapsedClock = useLiveElapsedClock();
|
||||
|
||||
watchEffect(() => {
|
||||
elapsedClock.sync(isRetentionBusy());
|
||||
});
|
||||
|
||||
function isTaskEnabled(task: CacheRetentionTask): boolean {
|
||||
return task.status === "running";
|
||||
}
|
||||
|
||||
function isTaskRunning(task: CacheRetentionTask): boolean {
|
||||
if (!task.id) return false;
|
||||
return Boolean(task.is_running) || executingIds.value.includes(task.id);
|
||||
}
|
||||
|
||||
function isTaskQueued(task: CacheRetentionTask): boolean {
|
||||
if (!task.id || isTaskRunning(task)) return false;
|
||||
return Boolean(task.is_pending) || pendingIds.value.includes(task.id);
|
||||
}
|
||||
|
||||
function scanDepthLabel(depth: number): string {
|
||||
return SCAN_DEPTH_OPTIONS.find((o) => o.value === depth)?.label ?? `${depth} 层`;
|
||||
}
|
||||
|
||||
function formatInterval(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes} 分钟`;
|
||||
if (minutes < 1440) {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const rest = minutes % 60;
|
||||
return rest ? `${hours} 小时 ${rest} 分钟` : `${hours} 小时`;
|
||||
}
|
||||
const days = Math.floor(minutes / 1440);
|
||||
const hours = Math.floor((minutes % 1440) / 60);
|
||||
return hours ? `${days} 天 ${hours} 小时` : `${days} 天`;
|
||||
}
|
||||
|
||||
function formatFileCount(count: number): string {
|
||||
if (!count) return "-";
|
||||
return count.toLocaleString();
|
||||
}
|
||||
|
||||
function taskMeta(task: CacheRetentionTask): string {
|
||||
const fileText = task.last_refresh ? `${formatFileCount(task.file_count)} 个文件` : "待统计";
|
||||
return [
|
||||
task.account_name || "未知账号",
|
||||
formatInterval(task.refresh_interval || 0),
|
||||
scanDepthLabel(task.scan_depth),
|
||||
fileText,
|
||||
].join(" · ");
|
||||
}
|
||||
|
||||
function refreshStatusClass(task: CacheRetentionTask): string {
|
||||
if (isTaskRunning(task) || isTaskQueued(task)) return "executing";
|
||||
return task.last_refresh_status || "pending";
|
||||
}
|
||||
|
||||
function formatRetryAfter(seconds?: number): string {
|
||||
if (!seconds || seconds <= 0) return "稍后";
|
||||
if (seconds < 60) return `${seconds} 秒`;
|
||||
const min = Math.floor(seconds / 60);
|
||||
const sec = seconds % 60;
|
||||
if (sec === 0) return `${min} 分钟`;
|
||||
return `${min} 分 ${sec} 秒`;
|
||||
}
|
||||
|
||||
function refreshStatusVariant(task: CacheRetentionTask): AdminRunStatusVariant {
|
||||
return normalizeRunStatusVariant(refreshStatusClass(task));
|
||||
}
|
||||
|
||||
function refreshPrimaryText(task: CacheRetentionTask): string {
|
||||
if (isTaskRunning(task)) {
|
||||
const dir = task.current_dir?.trim();
|
||||
if (dir) return `正在扫描「${dir}」`;
|
||||
return "正在扫描…";
|
||||
}
|
||||
if (isTaskQueued(task)) {
|
||||
if (startupRemainingDisplay.value > 0) {
|
||||
return `排队中 — 启动退避 ${startupRemainingDisplay.value}s`;
|
||||
}
|
||||
return "排队中 — 等待执行";
|
||||
}
|
||||
return formatRelativeTimeAgo(task.last_refresh, "从未刷新");
|
||||
}
|
||||
|
||||
function refreshSummary(task: CacheRetentionTask): string {
|
||||
if (isTaskRunning(task)) {
|
||||
const dirs = Number(task.scanned_dirs || 0);
|
||||
const files = Number(task.scanned_files || 0);
|
||||
const parts: string[] = [];
|
||||
if (dirs > 0 || files > 0) {
|
||||
parts.push(`已扫 ${dirs} 目录 / ${formatFileCount(files)} 文件`);
|
||||
}
|
||||
void elapsedClock.tick.value;
|
||||
const fromStart = liveElapsedMs(task.started_at, elapsedClock.tick.value);
|
||||
const elapsed = formatElapsedMs(fromStart || task.current_duration_ms);
|
||||
if (elapsed) parts.push(elapsed);
|
||||
return parts.length ? parts.join(" · ") : "正在刷新…";
|
||||
}
|
||||
if (isTaskQueued(task)) return "即将开始扫描…";
|
||||
if (!task.last_refresh) return "待执行";
|
||||
const parts = [formatFileCount(task.file_count) + " 个文件"];
|
||||
if (task.last_duration_ms > 0) parts.push(formatCompactDuration(task.last_duration_ms));
|
||||
parts.push(formatInterval(task.refresh_interval || 0));
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function refreshTitle(task: CacheRetentionTask): string {
|
||||
return [refreshPrimaryText(task), refreshSummary(task), taskMeta(task)].join("\n");
|
||||
}
|
||||
|
||||
function showRetentionRunToast(result?: RetentionRunResult) {
|
||||
switch (result?.state) {
|
||||
case "queued_startup":
|
||||
toast.info(`已加入执行队列,启动退避结束后(约 ${result.startup_remaining} 秒)自动执行`);
|
||||
break;
|
||||
case "already_running":
|
||||
toast.info("任务已在执行中");
|
||||
break;
|
||||
case "blocked_by_strm":
|
||||
toast.info("同账号有其他任务正在占用(STRM 或媒体整理),已加入队列,占用结束后自动执行");
|
||||
break;
|
||||
case "cache_disabled":
|
||||
toast.warning("该账号目录缓存已关闭(TTL=0),缓存保持任务无法生效");
|
||||
break;
|
||||
case "too_soon":
|
||||
toast.info(
|
||||
result.cache_ttl_minutes && result.retry_after_seconds
|
||||
? `账号缓存 ${result.cache_ttl_minutes} 分钟,约 ${formatRetryAfter(result.retry_after_seconds)} 后可再试`
|
||||
: "缓存冷却中,请稍后再试",
|
||||
);
|
||||
break;
|
||||
default:
|
||||
toast.success("已触发执行");
|
||||
}
|
||||
}
|
||||
|
||||
function showRetentionCreateToast(result?: RetentionRunResult) {
|
||||
switch (result?.state) {
|
||||
case "queued_startup":
|
||||
toast.success(`配置已创建,启动退避结束后(约 ${result.startup_remaining} 秒)自动执行`);
|
||||
break;
|
||||
case "blocked_by_strm":
|
||||
toast.success("配置已创建,同账号任务占用结束后自动执行");
|
||||
break;
|
||||
case "running":
|
||||
toast.success("配置已创建,已触发执行");
|
||||
break;
|
||||
default:
|
||||
toast.success("配置已创建");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDefaults() {
|
||||
try {
|
||||
const data = await fetchCacheRetentionDefaults();
|
||||
defaults.api_interval = data.api_interval || 200;
|
||||
defaults.refresh_interval = data.refresh_interval || 60;
|
||||
defaults.scan_depth = data.scan_depth || 4;
|
||||
applyStartupRemaining(data.startup_remaining ?? 0);
|
||||
} catch {
|
||||
/* 使用本地兜底 */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTasks(silent = false) {
|
||||
if (!silent) refreshing.value = true;
|
||||
try {
|
||||
const data = await fetchCacheRetentionTasks();
|
||||
tasks.value = data.items ?? [];
|
||||
applyStartupRemaining(data.startup_remaining ?? 0);
|
||||
} catch (e) {
|
||||
if (!silent) toast.error(getApiErrorMessage(e, "加载缓存保持任务失败"));
|
||||
} finally {
|
||||
if (!silent) refreshing.value = false;
|
||||
listReady.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const data = await fetchCacheRetentionStats();
|
||||
executingIds.value = data.executing_task_ids ?? [];
|
||||
pendingIds.value = data.pending_task_ids ?? [];
|
||||
applyStartupRemaining(data.startup_remaining ?? 0);
|
||||
} catch {
|
||||
/* 统计失败不阻断列表 */
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAll(silent = false) {
|
||||
await Promise.all([loadTasks(silent), loadStats(), accountsStore.loadAccounts()]);
|
||||
}
|
||||
|
||||
function syncPolling() {
|
||||
listPolling.sync();
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(form, emptyForm());
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
if (tasks.value.length >= MAX_CONFIGS) {
|
||||
toast.warning(`最多只能添加 ${MAX_CONFIGS} 个配置`);
|
||||
return;
|
||||
}
|
||||
editingId.value = null;
|
||||
resetForm();
|
||||
dialogOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(task: CacheRetentionTask) {
|
||||
editingId.value = task.id ?? null;
|
||||
form.account_id = task.account_id;
|
||||
form.parent_id = task.parent_id;
|
||||
form.path = task.path;
|
||||
form.scan_depth = task.scan_depth || defaults.scan_depth;
|
||||
form.api_interval = task.api_interval ?? defaults.api_interval;
|
||||
form.refresh_interval = task.refresh_interval || defaults.refresh_interval;
|
||||
applyTimeWindowFromTask(form, task);
|
||||
dialogOpen.value = true;
|
||||
}
|
||||
|
||||
function onFolderPicked(payload: { accountId: number; parentId: string; path: string }) {
|
||||
form.account_id = payload.accountId;
|
||||
form.parent_id = payload.parentId;
|
||||
form.path = payload.path || "/";
|
||||
pickerOpen.value = false;
|
||||
}
|
||||
|
||||
function buildPayload(): CacheRetentionTaskInput {
|
||||
return {
|
||||
account_id: form.account_id,
|
||||
parent_id: form.parent_id,
|
||||
path: form.path,
|
||||
scan_depth: Number(form.scan_depth) || defaults.scan_depth,
|
||||
api_interval: Number(form.api_interval) || 0,
|
||||
refresh_interval: Number(form.refresh_interval) || defaults.refresh_interval,
|
||||
...timeWindowPayload(form),
|
||||
};
|
||||
}
|
||||
|
||||
async function submitTask() {
|
||||
if (!form.account_id) {
|
||||
toast.error("请选择账号及目录");
|
||||
return;
|
||||
}
|
||||
if (!form.path.trim()) {
|
||||
toast.error("请选择目录");
|
||||
return;
|
||||
}
|
||||
const apiInterval = Number(form.api_interval);
|
||||
if (apiInterval < 0 || apiInterval > 5000) {
|
||||
toast.error("API 额外补偿间隔必须在 0-5000 毫秒之间");
|
||||
return;
|
||||
}
|
||||
const refreshInterval = Number(form.refresh_interval);
|
||||
if (!refreshInterval || refreshInterval < 1 || refreshInterval > 1440) {
|
||||
toast.error("刷新间隔必须在 1-1440 分钟之间");
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const body = buildPayload();
|
||||
if (editingId.value) {
|
||||
await updateCacheRetentionTask(editingId.value, body);
|
||||
toast.success("配置已更新");
|
||||
} else {
|
||||
const created = await createCacheRetentionTask(body);
|
||||
showRetentionCreateToast(created.run);
|
||||
}
|
||||
dialogOpen.value = false;
|
||||
await refreshAll();
|
||||
syncPolling();
|
||||
} catch (e) {
|
||||
toast.error(getApiErrorMessage(e, "保存配置失败"));
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setTaskEnabled(task: CacheRetentionTask, enabled: boolean) {
|
||||
if (!task.id || enabled === isTaskEnabled(task)) return;
|
||||
try {
|
||||
const updated = await toggleCacheRetentionTask(task.id);
|
||||
const idx = tasks.value.findIndex((t) => t.id === task.id);
|
||||
if (idx >= 0) tasks.value[idx] = updated;
|
||||
toast.success(updated.status === "running" ? "已启用" : "已禁用");
|
||||
await loadStats();
|
||||
} catch (e) {
|
||||
toast.error(getApiErrorMessage(e, "切换状态失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRun(task: CacheRetentionTask) {
|
||||
if (!task.id) return;
|
||||
try {
|
||||
const result = await refreshCacheRetentionTask(task.id);
|
||||
showRetentionRunToast(result);
|
||||
if (result?.state !== "too_soon" && result?.state !== "cache_disabled") {
|
||||
listPolling.start();
|
||||
}
|
||||
await refreshAll(true);
|
||||
syncPolling();
|
||||
} catch (e) {
|
||||
toast.error(getApiErrorMessage(e, "执行失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleForceStop(task: CacheRetentionTask) {
|
||||
if (!task.id) return;
|
||||
try {
|
||||
await forceStopCacheRetentionTask(task.id);
|
||||
toast.success("任务已强制停止,下次调度不受影响");
|
||||
await refreshAll(true);
|
||||
syncPolling();
|
||||
} catch (e) {
|
||||
toast.error(getApiErrorMessage(e, "强制停止失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(task: CacheRetentionTask) {
|
||||
if (!task.id) return;
|
||||
try {
|
||||
await confirm({
|
||||
title: "确认删除",
|
||||
message: "确定要删除这个缓存保持配置吗?\n\n删除后该配置将停止缓存刷新,但已缓存的目录数据会保留。",
|
||||
confirmText: "删除",
|
||||
danger: true,
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteCacheRetentionTask(task.id);
|
||||
toast.success("配置已删除");
|
||||
await refreshAll();
|
||||
} catch (e) {
|
||||
toast.error(getApiErrorMessage(e, "删除失败"));
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDefaults();
|
||||
await refreshAll();
|
||||
syncPolling();
|
||||
});
|
||||
|
||||
let activatedOnce = false;
|
||||
|
||||
onActivated(() => {
|
||||
if (activatedOnce) void refreshAll(true);
|
||||
activatedOnce = true;
|
||||
syncPolling();
|
||||
});
|
||||
|
||||
function stopPageActivity() {
|
||||
listPolling.stop();
|
||||
}
|
||||
|
||||
onDeactivated(stopPageActivity);
|
||||
onUnmounted(stopPageActivity);
|
||||
|
||||
defineExpose({
|
||||
openCreate,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="retention-panel">
|
||||
<AdminStartupBanner :seconds="startupRemainingDisplay" />
|
||||
|
||||
<div v-if="tasks.length >= MAX_CONFIGS" class="retention-limit-banner">
|
||||
已达到最大配置数量({{ MAX_CONFIGS }} 个)。如需覆盖更多目录,请提高扫描层级或删除不需要的配置。
|
||||
</div>
|
||||
|
||||
<AdminStatsGrid v-if="!hideStats">
|
||||
<StatCard icon="fa-folder" :value="statsTotal" label="配置目录" tone="blue" />
|
||||
<StatCard icon="fa-play" :value="statsRunning" label="已启用" tone="purple" />
|
||||
<StatCard icon="fa-pause" :value="statsPaused" label="已暂停" tone="amber">
|
||||
<template #actions>
|
||||
<AppIconButton
|
||||
icon="fa-sync-alt"
|
||||
label="刷新"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
:disabled="refreshing"
|
||||
title="刷新任务列表"
|
||||
@click="() => refreshAll()"
|
||||
/>
|
||||
</template>
|
||||
</StatCard>
|
||||
</AdminStatsGrid>
|
||||
|
||||
<AppStateBlock
|
||||
v-if="(!listReady || refreshing) && !tasks.length"
|
||||
message="加载中…"
|
||||
loading
|
||||
min-height="160px"
|
||||
/>
|
||||
|
||||
<AdminEmptyState
|
||||
v-else-if="!tasks.length"
|
||||
icon="🔥"
|
||||
title="还没有缓存任务"
|
||||
description="添加目录后,系统会定期预热列表缓存,减少浏览时的 API 请求。"
|
||||
>
|
||||
<AppButton type="button" variant="primary" @click="openCreate">添加第一个任务</AppButton>
|
||||
</AdminEmptyState>
|
||||
|
||||
<template v-else>
|
||||
<div class="admin-panel-table-wrap retention-table-wrap">
|
||||
<table class="admin-table retention-table">
|
||||
<colgroup>
|
||||
<col class="retention-col-info" />
|
||||
<col class="retention-col-refresh" />
|
||||
<col class="retention-col-actions" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>目录信息</th>
|
||||
<th>最后刷新</th>
|
||||
<th class="retention-table__actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="task in tasks" :key="task.id" class="retention-row">
|
||||
<td>
|
||||
<div class="retention-main" :title="taskMeta(task)">
|
||||
<div class="retention-name">
|
||||
<span class="retention-path">{{ task.path }}</span>
|
||||
<AdminStatusPill :tone="isTaskEnabled(task) ? 'success' : 'warning'">
|
||||
{{ isTaskEnabled(task) ? "已启用" : "已禁用" }}
|
||||
</AdminStatusPill>
|
||||
</div>
|
||||
<div class="retention-meta">{{ taskMeta(task) }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<AdminRunStatusCell
|
||||
:title="refreshTitle(task)"
|
||||
:primary="refreshPrimaryText(task)"
|
||||
:summary="refreshSummary(task)"
|
||||
:variant="refreshStatusVariant(task)"
|
||||
:live="isTaskRunning(task) || isTaskQueued(task)"
|
||||
primary-tone="default"
|
||||
/>
|
||||
</td>
|
||||
<td class="admin-table__actions retention-table__action-cell">
|
||||
<AdminRowActions>
|
||||
<div class="retention-actions">
|
||||
<AdminEnableToggle
|
||||
:enabled="isTaskEnabled(task)"
|
||||
aria-label="缓存保持启用切换"
|
||||
@enable="setTaskEnabled(task, $event)"
|
||||
/>
|
||||
<AdminTableActionBtn
|
||||
v-if="isTaskRunning(task)"
|
||||
icon="stop"
|
||||
title="强制停止"
|
||||
danger
|
||||
@click="handleForceStop(task)"
|
||||
/>
|
||||
<AdminTableActionBtn
|
||||
v-else
|
||||
icon="play"
|
||||
title="立即执行"
|
||||
@click="handleRun(task)"
|
||||
/>
|
||||
<AdminTableActionBtn icon="edit" title="修改" @click="openEdit(task)" />
|
||||
<AdminTableActionBtn icon="delete" title="删除" danger @click="handleDelete(task)" />
|
||||
</div>
|
||||
<template #menu>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-row-actions__item"
|
||||
@click="setTaskEnabled(task, !isTaskEnabled(task))"
|
||||
>
|
||||
{{ isTaskEnabled(task) ? "禁用任务" : "启用任务" }}
|
||||
</button>
|
||||
<button
|
||||
v-if="isTaskRunning(task)"
|
||||
type="button"
|
||||
class="admin-row-actions__item admin-row-actions__item--danger"
|
||||
@click="handleForceStop(task)"
|
||||
>
|
||||
强制停止
|
||||
</button>
|
||||
<button v-else type="button" class="admin-row-actions__item" @click="handleRun(task)">
|
||||
立即执行
|
||||
</button>
|
||||
<button type="button" class="admin-row-actions__item" @click="openEdit(task)">修改</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-row-actions__item admin-row-actions__item--danger"
|
||||
@click="handleDelete(task)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</template>
|
||||
</AdminRowActions>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<AppModal
|
||||
:open="dialogOpen"
|
||||
size="account"
|
||||
:title="editingId ? '修改目录' : '添加目录'"
|
||||
@close="dialogOpen = false"
|
||||
>
|
||||
<div class="retention-form">
|
||||
<FormField label="选择账号及目录">
|
||||
<AccountFolderField
|
||||
:display="sourceDirDisplay"
|
||||
:title="sourceDirTitle"
|
||||
@browse="pickerOpen = true"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div class="retention-form__row">
|
||||
<FormField label="扫描层级">
|
||||
<AppSelect v-model="form.scan_depth" :options="scanDepthOptions" />
|
||||
</FormField>
|
||||
<FormField label="API 额外补偿间隔(毫秒)">
|
||||
<AppInput v-model="form.api_interval" type="number" min="0" max="5000" />
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div class="retention-form__row">
|
||||
<FormField label="刷新间隔(分钟)">
|
||||
<AppInput v-model="form.refresh_interval" type="number" min="1" max="1440" />
|
||||
</FormField>
|
||||
<FormField label="执行时间段">
|
||||
<button type="button" class="retention-time-display" @click="timePickerVisible = true">
|
||||
<span>{{ timeWindowDisplay }}</span>
|
||||
<svg class="retention-time-display__icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M12 7.5v5l3.2 2" />
|
||||
</svg>
|
||||
</button>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<AppButton type="button" variant="secondary" @click="dialogOpen = false">取消</AppButton>
|
||||
<AppButton type="button" variant="primary" :disabled="submitting" @click="submitTask">
|
||||
{{ submitting ? "保存中…" : editingId ? "更新配置" : "保存配置" }}
|
||||
</AppButton>
|
||||
</template>
|
||||
</AppModal>
|
||||
|
||||
<FolderPickerModal
|
||||
:open="pickerOpen"
|
||||
selectable-account
|
||||
:accounts="activeAccounts"
|
||||
:account-id="form.account_id || null"
|
||||
:initial-path="form.path"
|
||||
@close="pickerOpen = false"
|
||||
@resolve="onFolderPicked"
|
||||
/>
|
||||
|
||||
<TimeWheelPicker
|
||||
:visible="timePickerVisible"
|
||||
:start-time="form.time_start"
|
||||
:end-time="form.time_end"
|
||||
:all-day="form.time_window_mode === 'always'"
|
||||
@confirm="onTimeWheelConfirm"
|
||||
@cancel="timePickerVisible = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.retention-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.retention-limit-banner {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
background: color-mix(in srgb, #f59e0b 10%, var(--surface));
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 24%, var(--border-soft));
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.retention-table-wrap {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.retention-table {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.retention-col-info {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.retention-col-refresh {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.retention-col-actions {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.retention-table td:first-child {
|
||||
overflow: hidden;
|
||||
max-width: 0;
|
||||
}
|
||||
|
||||
.retention-table td:nth-child(2) {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.retention-table__actions {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.retention-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.retention-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.retention-path {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.retention-meta {
|
||||
margin-top: 4px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.retention-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.retention-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.retention-form__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.retention-time-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.retention-time-display__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.8;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.retention-col-info {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.retention-col-refresh {
|
||||
width: 42%;
|
||||
}
|
||||
|
||||
.retention-col-actions {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.retention-table th,
|
||||
.retention-table td {
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
.retention-table__actions,
|
||||
.retention-table__action-cell {
|
||||
width: 48px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.retention-table__action-cell {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.retention-name {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.retention-meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.retention-form__row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { getApiErrorMessage } from "@/api/client";
|
||||
import { clearCache, fetchCacheStats } from "@/api/cache";
|
||||
import AppCardActionButton from "@/components/base/AppCardActionButton.vue";
|
||||
import StatCard from "@/components/base/StatCard.vue";
|
||||
import { toast } from "@/composables/useToast";
|
||||
import { formatSize } from "@/utils/format";
|
||||
|
||||
const stats = reactive({
|
||||
totalKeys: 0,
|
||||
totalSize: 0,
|
||||
hitRate: 0,
|
||||
});
|
||||
|
||||
const refreshing = ref(false);
|
||||
const clearing = ref(false);
|
||||
|
||||
async function loadStats() {
|
||||
refreshing.value = true;
|
||||
try {
|
||||
const data = await fetchCacheStats();
|
||||
stats.totalKeys = data.total_keys ?? 0;
|
||||
stats.totalSize = data.total_size_bytes ?? 0;
|
||||
stats.hitRate = data.hit_rate ?? 0;
|
||||
} catch (e) {
|
||||
toast.error(getApiErrorMessage(e, "加载缓存统计失败"));
|
||||
} finally {
|
||||
refreshing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearCache() {
|
||||
clearing.value = true;
|
||||
try {
|
||||
const res = await clearCache();
|
||||
toast.success(`已清空 ${res.cleared_count} 条缓存`);
|
||||
await loadStats();
|
||||
} catch (e) {
|
||||
toast.error(getApiErrorMessage(e, "清空缓存失败"));
|
||||
} finally {
|
||||
clearing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadStats();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StatCard icon="fa-database" :value="stats.totalKeys" label="缓存条目" tone="blue" side-actions>
|
||||
<template #actions>
|
||||
<div class="cache-runtime-actions">
|
||||
<AppCardActionButton
|
||||
icon="fa-sync-alt"
|
||||
label="刷新"
|
||||
variant="secondary"
|
||||
:disabled="refreshing"
|
||||
title="刷新缓存统计"
|
||||
@click="loadStats"
|
||||
/>
|
||||
<AppCardActionButton
|
||||
icon-class="fas fa-trash-can"
|
||||
label="清理"
|
||||
variant="danger"
|
||||
:disabled="clearing"
|
||||
title="清空缓存"
|
||||
@click="handleClearCache"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</StatCard>
|
||||
<StatCard icon="fa-hdd" :value="formatSize(stats.totalSize)" label="缓存大小" tone="purple" />
|
||||
<StatCard icon="fa-bullseye" :value="`${stats.hitRate}%`" label="命中率" tone="amber" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cache-runtime-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { getApiErrorMessage } from "@/api/client";
|
||||
import { fetchSettings, saveSettings, type SettingItem } from "@/api/settings";
|
||||
import SettingsCard from "@/components/admin/SettingsCard.vue";
|
||||
import SettingsFormRow from "@/components/admin/SettingsFormRow.vue";
|
||||
import { bindSettingsPanelExpose, useSettingsKVForm } from "@/composables/useSettingsForm";
|
||||
import { useSettingsLoad } from "@/composables/useSettingsLoad";
|
||||
import { toast } from "@/composables/useToast";
|
||||
import { CACHE_SETTING_KEYS, WEBDAV_CACHE_SETTING_KEY } from "@/constants/cacheSettings";
|
||||
import "@/styles/admin-shared.css";
|
||||
|
||||
const CACHE_SETTINGS_ACCENT = "#4c74df";
|
||||
|
||||
const { loading, loaded, runLoad } = useSettingsLoad(false);
|
||||
const saving = ref(false);
|
||||
|
||||
const items = ref<SettingItem[]>([]);
|
||||
const { values: form, assignEntry, revertEntries, isEntryChanged } = useSettingsKVForm();
|
||||
|
||||
const cacheItems = computed(() => items.value.filter((it) => CACHE_SETTING_KEYS.has(it.key)));
|
||||
|
||||
const generalCacheItems = computed(() =>
|
||||
cacheItems.value.filter((it) => it.key !== WEBDAV_CACHE_SETTING_KEY),
|
||||
);
|
||||
|
||||
const webdavCacheItems = computed(() =>
|
||||
cacheItems.value.filter((it) => it.key === WEBDAV_CACHE_SETTING_KEY),
|
||||
);
|
||||
|
||||
function isChanged(it: SettingItem): boolean {
|
||||
return isEntryChanged(it.key, it.type);
|
||||
}
|
||||
|
||||
const settingsChanged = computed(() => cacheItems.value.some((it) => isChanged(it)));
|
||||
|
||||
function revertSettings() {
|
||||
revertEntries(cacheItems.value.map((it) => it.key));
|
||||
}
|
||||
|
||||
async function loadSettings(options?: { silent?: boolean }) {
|
||||
await runLoad(async () => {
|
||||
const payload = await fetchSettings();
|
||||
items.value = payload.items ?? [];
|
||||
for (const it of cacheItems.value) {
|
||||
assignEntry(it.key, it.value, it.type);
|
||||
}
|
||||
}, "加载缓存设置失败", options);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!settingsChanged.value) return;
|
||||
const changed: Record<string, string> = {};
|
||||
for (const it of cacheItems.value) {
|
||||
if (isChanged(it)) changed[it.key] = form[it.key];
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = await saveSettings(changed);
|
||||
items.value = payload.items ?? [];
|
||||
for (const it of cacheItems.value) {
|
||||
assignEntry(it.key, it.value, it.type);
|
||||
}
|
||||
toast.success("缓存设置已保存");
|
||||
} catch (e) {
|
||||
toast.error(getApiErrorMessage(e, "保存失败"));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadSettings({ silent: true });
|
||||
});
|
||||
|
||||
defineExpose(
|
||||
bindSettingsPanelExpose({
|
||||
isDirty: settingsChanged,
|
||||
saving,
|
||||
save,
|
||||
reload: () => loadSettings({ silent: loaded.value }),
|
||||
revert: revertSettings,
|
||||
}),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cache-settings">
|
||||
<div v-if="loading" class="settings-card__loading">加载中…</div>
|
||||
|
||||
<template v-else>
|
||||
<SettingsCard v-if="generalCacheItems.length" title="通用缓存" :accent="CACHE_SETTINGS_ACCENT">
|
||||
<SettingsFormRow
|
||||
v-for="it in generalCacheItems"
|
||||
:key="it.key"
|
||||
:item="it"
|
||||
:model-value="form[it.key]"
|
||||
:changed="isChanged(it)"
|
||||
@update:model-value="form[it.key] = $event"
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard v-if="webdavCacheItems.length" title="WebDAV 专用缓存" :accent="CACHE_SETTINGS_ACCENT">
|
||||
<SettingsFormRow
|
||||
v-for="it in webdavCacheItems"
|
||||
:key="it.key"
|
||||
:item="it"
|
||||
:model-value="form[it.key]"
|
||||
:changed="isChanged(it)"
|
||||
@update:model-value="form[it.key] = $event"
|
||||
/>
|
||||
</SettingsCard>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div class="notice-body">
|
||||
<div class="notice-card">
|
||||
<div class="notice-flow">
|
||||
<div class="notice-node">
|
||||
<div class="notice-icon"><SvgIcon name="folder" :size="26" /></div>
|
||||
<span>源文件指纹</span>
|
||||
</div>
|
||||
<div class="notice-arrow">→</div>
|
||||
<div class="notice-node wide">
|
||||
<div class="notice-icon"><SvgIcon name="monitor" :size="26" /></div>
|
||||
<span>LitePan 试探</span>
|
||||
</div>
|
||||
<div class="notice-arrow">→</div>
|
||||
<div class="notice-node">
|
||||
<div class="notice-icon"><SvgIcon name="cloud" :size="26" /></div>
|
||||
<span>目标网盘检测</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notice-tips">
|
||||
<div class="notice-tip">
|
||||
<span class="notice-tip-bar" aria-hidden="true"></span>
|
||||
<span>先试探哪些文件能秒传;若直接传输且选了「覆盖」,误覆盖后无法撤销。</span>
|
||||
</div>
|
||||
<div class="notice-tip">
|
||||
<span class="notice-tip-bar" aria-hidden="true"></span>
|
||||
<span>支持预判的网盘只查询文件是否命中,不会真的转存一遍。</span>
|
||||
</div>
|
||||
<div class="notice-tip">
|
||||
<span class="notice-tip-bar" aria-hidden="true"></span>
|
||||
<span>无法预判的网盘(如 123)才会在临时目录真实试传;结束后自动删除,文件可能留在回收站。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SvgIcon from "@/components/icons/SvgIcon.vue";
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notice-body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.notice-card {
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notice-flow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1.2fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.notice-node {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 8px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-regular);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notice-node.wide {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notice-icon {
|
||||
line-height: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.notice-arrow {
|
||||
color: var(--text-muted);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.notice-tips {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
color: var(--text-regular);
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.notice-tip {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.notice-tip-bar {
|
||||
flex-shrink: 0;
|
||||
width: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--brand-gradient);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.notice-flow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.notice-arrow {
|
||||
justify-self: center;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
:global(:root[data-skin="brutal"]) .notice-card,
|
||||
:global(:root[data-skin="brutal"]) .notice-node,
|
||||
:global(:root[data-skin="brutal"]) .notice-tip-bar {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
:global(:root[data-skin="brutal"]) .notice-card,
|
||||
:global(:root[data-skin="brutal"]) .notice-node {
|
||||
border: var(--brutal-bw) solid var(--brutal-ink);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<template v-for="node in nodes" :key="node.id + '_' + node.name">
|
||||
<div
|
||||
v-if="node.type === 'dir'"
|
||||
class="tnode folder"
|
||||
:style="{ paddingLeft: (6 + depth * 16) + 'px' }"
|
||||
@click="toggle(node)"
|
||||
>
|
||||
<span class="caret" :class="{ open: node.open }"><i class="fas fa-chevron-right"></i></span>
|
||||
<span class="t-ic dir"><i class="fas fa-folder"></i></span>
|
||||
<span class="t-name">{{ node.name }}</span>
|
||||
<span v-if="mode === 'src' && countOk(node) > 0" class="folder-count">{{ countOk(node) }} 个可秒传</span>
|
||||
</div>
|
||||
<CrossTransferTree
|
||||
v-if="node.type === 'dir' && node.open"
|
||||
:nodes="node.children || []"
|
||||
:mode="mode"
|
||||
:depth="depth + 1"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else-if="node.type === 'file'"
|
||||
class="tnode file"
|
||||
:class="{ probing: node.state === 'run' }"
|
||||
:data-rel-path="node.rel_path"
|
||||
:style="{ paddingLeft: (6 + depth * 16) + 'px' }"
|
||||
>
|
||||
<span class="caret-spacer"></span>
|
||||
<span class="t-ic file"><i class="fas fa-file"></i></span>
|
||||
<span class="t-name">{{ node.name }}</span>
|
||||
<span class="t-meta">{{ fmtSize(node.size) }}</span>
|
||||
<span v-if="mode === 'src'" class="tag" :class="statusClass(node)">
|
||||
<BusySpinner v-if="node.state === 'run'" :size="12" />
|
||||
<i v-else class="fas" :class="statusIcon(node)"></i>
|
||||
{{ statusText(node) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import BusySpinner from "@/components/base/BusySpinner.vue";
|
||||
|
||||
defineOptions({ name: 'CrossTransferTree' })
|
||||
|
||||
const props = defineProps({
|
||||
nodes: { type: Array, default: () => [] },
|
||||
mode: { type: String, default: 'src' },
|
||||
depth: { type: Number, default: 0 }
|
||||
})
|
||||
|
||||
const toggle = (node) => { node.open = !node.open }
|
||||
|
||||
const fmtSize = (b) => {
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let v = Number(b || 0); let i = 0
|
||||
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++ }
|
||||
return (i === 0 ? v.toFixed(0) : v.toFixed(2)) + ' ' + u[i]
|
||||
}
|
||||
|
||||
const countOk = (node) => {
|
||||
if (node.type === 'file') return node.reuse === true ? 1 : 0
|
||||
return (node.children || []).reduce((a, c) => a + countOk(c), 0)
|
||||
}
|
||||
|
||||
const statusClass = (n) => {
|
||||
if (n.transferred) return 'done'
|
||||
if (n.skipped) return 'skipped'
|
||||
if (n.relay) return 'run'
|
||||
if (n.state === 'run') return 'run'
|
||||
if (n.reuse === true) return 'ok'
|
||||
if (n.reuse === false) return 'no'
|
||||
return 'pending'
|
||||
}
|
||||
const statusIcon = (n) => {
|
||||
if (n.transferred) return 'fa-check'
|
||||
if (n.skipped) return 'fa-forward-step'
|
||||
if (n.relay) return 'fa-truck-fast'
|
||||
if (n.state === 'run') return ''
|
||||
if (n.reuse === true) return 'fa-bolt'
|
||||
if (n.reuse === false) return 'fa-ban'
|
||||
return 'fa-clock'
|
||||
}
|
||||
const statusText = (n) => {
|
||||
if (n.transferred) return '已转存'
|
||||
if (n.skipped) return '已跳过'
|
||||
if (n.relay) return '兜底传输中'
|
||||
if (n.state === 'run') return '验证中'
|
||||
if (n.reuse === true) return '可秒传'
|
||||
if (n.reuse === false) return '不可秒传'
|
||||
return '待试探'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tnode { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 10px; color: var(--text-main, var(--text)); }
|
||||
.tnode:hover { background: rgba(127,127,127,.1); }
|
||||
.tnode.file.probing { background: rgba(217,119,6,.12); outline: 1px solid rgba(217,119,6,.25); }
|
||||
.tnode.folder { cursor: pointer; }
|
||||
.caret { width: 14px; color: var(--text-secondary, var(--text-muted)); font-size: 12px; transition: transform .14s; flex: 0 0 auto; }
|
||||
.caret.open { transform: rotate(90deg); }
|
||||
.caret-spacer { width: 14px; flex: 0 0 auto; }
|
||||
.t-ic { width: 26px; height: 26px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 13px; flex: 0 0 auto; }
|
||||
.t-ic.dir { color: #f5b942; background: rgba(245,185,66,.16); }
|
||||
.t-ic.file { color: #7c93b3; background: rgba(124,147,179,.16); }
|
||||
.t-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
|
||||
.t-meta { color: var(--text-secondary, var(--text-muted)); font-size: 12px; white-space: nowrap; }
|
||||
.folder-count { font-size: 12px; padding: 2px 9px; border-radius: 999px; white-space: nowrap; border: 1px solid rgba(76,116,223,.22); background: rgba(76,116,223,.08); color: #1d4ed8; }
|
||||
.tag { font-size: 12px; font-weight: 600; padding: 3px 9px; border-radius: 999px; white-space: nowrap; display: inline-flex; align-items: center; gap: 5px; }
|
||||
.tag.ok { color: #16a34a; background: #dcfce7; }
|
||||
.tag.no { color: #94a3b8; background: #eef2f7; }
|
||||
.tag.run { color: #d97706; background: #fef3c7; }
|
||||
.tag.done { color: #16a34a; background: #dcfce7; }
|
||||
.tag.skipped { color: #64748b; background: #e2e8f0; }
|
||||
.tag.pending { color: #64748b; background: #f1f5f9; }
|
||||
|
||||
:global(:root[data-skin="brutal"] .cross-transfer) .tnode,
|
||||
:global(:root[data-skin="brutal"] .cross-transfer) .t-ic,
|
||||
:global(:root[data-skin="brutal"] .cross-transfer) .folder-count,
|
||||
:global(:root[data-skin="brutal"] .cross-transfer) .tag {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
:global(:root[data-skin="brutal"] .cross-transfer) .tag {
|
||||
border: 1px solid var(--brutal-ink);
|
||||
color: var(--brutal-ink);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,813 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import type { DriverInfo } from "@/api/types";
|
||||
import DriverIcon from "@/components/driver/DriverIcon.vue";
|
||||
import AppButton from "@/components/base/AppButton.vue";
|
||||
|
||||
const ITEM_H = 196;
|
||||
|
||||
type SlideItem = { driver: DriverInfo; key: string; realIndex: number };
|
||||
|
||||
const props = defineProps<{ drivers: DriverInfo[]; modelValue: string }>();
|
||||
const emit = defineEmits<{ "update:modelValue": [string]; next: [] }>();
|
||||
|
||||
const VIEW_KEY = "litepan_add_account_driver_view_mode";
|
||||
const saved = localStorage.getItem(VIEW_KEY);
|
||||
const viewMode = ref<"carousel" | "grid">(
|
||||
saved === "carousel" || saved === "grid" ? saved : "carousel",
|
||||
);
|
||||
const searchQuery = ref("");
|
||||
const trackIndex = ref(0);
|
||||
const trackTransition = ref(true);
|
||||
const trackResetting = ref(false);
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase();
|
||||
if (!q) return props.drivers;
|
||||
return props.drivers.filter(
|
||||
(d) =>
|
||||
d.display_name.toLowerCase().includes(q) ||
|
||||
d.name.toLowerCase().includes(q) ||
|
||||
(d.description || "").toLowerCase().includes(q),
|
||||
);
|
||||
});
|
||||
|
||||
const slides = computed((): SlideItem[] => {
|
||||
const list = filtered.value;
|
||||
if (list.length <= 1) {
|
||||
return list.map((driver, realIndex) => ({ driver, key: driver.name, realIndex }));
|
||||
}
|
||||
const first = list[0];
|
||||
const last = list[list.length - 1];
|
||||
return [
|
||||
{ driver: last, key: `clone-head-${last.name}`, realIndex: list.length - 1 },
|
||||
...list.map((driver, realIndex) => ({ driver, key: driver.name, realIndex })),
|
||||
{ driver: first, key: `clone-tail-${first.name}`, realIndex: 0 },
|
||||
];
|
||||
});
|
||||
|
||||
const positionCur = computed(() => {
|
||||
const slide = slides.value[trackIndex.value];
|
||||
return slide ? slide.realIndex + 1 : 0;
|
||||
});
|
||||
|
||||
function syncTrackFromModel() {
|
||||
const list = filtered.value;
|
||||
if (!list.length) {
|
||||
trackIndex.value = 0;
|
||||
emit("update:modelValue", "");
|
||||
return;
|
||||
}
|
||||
let idx = list.findIndex((d) => d.name === props.modelValue);
|
||||
if (idx < 0) idx = 0;
|
||||
trackIndex.value = list.length <= 1 ? idx : idx + 1;
|
||||
if (list[idx] && list[idx].name !== props.modelValue) {
|
||||
emit("update:modelValue", list[idx].name);
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.drivers, syncTrackFromModel, { immediate: true });
|
||||
watch(filtered, () => {
|
||||
syncTrackFromModel();
|
||||
});
|
||||
|
||||
function selectDriver(name: string, realIndex?: number) {
|
||||
const list = filtered.value;
|
||||
if (realIndex !== undefined) {
|
||||
trackIndex.value = list.length <= 1 ? realIndex : realIndex + 1;
|
||||
} else {
|
||||
const idx = list.findIndex((d) => d.name === name);
|
||||
if (idx >= 0) trackIndex.value = list.length <= 1 ? idx : idx + 1;
|
||||
}
|
||||
emit("update:modelValue", name);
|
||||
}
|
||||
|
||||
function move(delta: number) {
|
||||
const n = filtered.value.length;
|
||||
if (n <= 1 || trackResetting.value) return;
|
||||
trackTransition.value = true;
|
||||
trackIndex.value += delta;
|
||||
const slide = slides.value[trackIndex.value];
|
||||
if (slide) emit("update:modelValue", slide.driver.name);
|
||||
}
|
||||
|
||||
function resetTrackIndex(next: number) {
|
||||
trackResetting.value = true;
|
||||
trackTransition.value = false;
|
||||
trackIndex.value = next;
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
trackTransition.value = true;
|
||||
trackResetting.value = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function onTrackTransitionEnd(e: TransitionEvent) {
|
||||
if (trackResetting.value) return;
|
||||
if (e.target !== e.currentTarget || e.propertyName !== "transform") return;
|
||||
const n = filtered.value.length;
|
||||
if (n <= 1) return;
|
||||
if (trackIndex.value === 0) {
|
||||
resetTrackIndex(n);
|
||||
} else if (trackIndex.value === n + 1) {
|
||||
resetTrackIndex(1);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleView() {
|
||||
viewMode.value = viewMode.value === "carousel" ? "grid" : "carousel";
|
||||
localStorage.setItem(VIEW_KEY, viewMode.value);
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchQuery.value = "";
|
||||
}
|
||||
|
||||
const canNext = computed(() => Boolean(props.modelValue));
|
||||
|
||||
function driverDescription(driver: DriverInfo) {
|
||||
return driver.description || "云存储服务,支持文件浏览与管理";
|
||||
}
|
||||
|
||||
function driverCardTags(driver: DriverInfo) {
|
||||
return (driver.card_tags || []).map((tag) => String(tag).trim()).filter(Boolean).slice(0, 4);
|
||||
}
|
||||
|
||||
function driverAuthLabel(driver: DriverInfo) {
|
||||
if (driver.auth_label) return driver.auth_label;
|
||||
if (driver.supports_oauth) return "OAuth";
|
||||
if (driver.supports_qr_login) return "扫码";
|
||||
if (driver.auth_type === "cookie") return "Cookie";
|
||||
if (driver.auth_type === "token") return "Token";
|
||||
if (driver.auth_type === "none") return "本地";
|
||||
return "认证";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="picker">
|
||||
<div class="picker__viewport">
|
||||
<div v-show="viewMode === 'carousel'" class="carousel">
|
||||
<div
|
||||
class="carousel__track"
|
||||
:class="{ 'carousel__track--instant': !trackTransition }"
|
||||
:style="{ transform: `translateY(-${trackIndex * ITEM_H}px)` }"
|
||||
@transitionend="onTrackTransitionEnd"
|
||||
>
|
||||
<div
|
||||
v-for="slide in slides"
|
||||
:key="slide.key"
|
||||
class="carousel__item"
|
||||
@click="selectDriver(slide.driver.name, slide.realIndex)"
|
||||
>
|
||||
<div
|
||||
class="carousel__card"
|
||||
:class="{ selected: slide.driver.name === modelValue }"
|
||||
:style="{ '--driver-color': slide.driver.card_color }"
|
||||
>
|
||||
<div class="carousel__content">
|
||||
<DriverIcon
|
||||
:logo="slide.driver.card_logo"
|
||||
:color="slide.driver.card_color"
|
||||
:name="slide.driver.display_name"
|
||||
:size="68"
|
||||
/>
|
||||
<div class="carousel__info">
|
||||
<div class="carousel__title-row">
|
||||
<h3>{{ slide.driver.display_name }}</h3>
|
||||
<span class="carousel__auth">{{ driverAuthLabel(slide.driver) }}</span>
|
||||
</div>
|
||||
<p>{{ driverDescription(slide.driver) }}</p>
|
||||
<div v-if="driverCardTags(slide.driver).length" class="carousel__tags">
|
||||
<span
|
||||
v-for="tag in driverCardTags(slide.driver)"
|
||||
:key="tag"
|
||||
class="carousel__tag"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="filtered.length > 1" class="carousel__controls">
|
||||
<button type="button" class="nav-arrow" @click.stop="move(-1)">▲</button>
|
||||
<div class="position-indicator">
|
||||
<span class="position-text">{{ positionCur }}</span>
|
||||
<div class="position-divider" />
|
||||
<span class="total-text">{{ filtered.length }}</span>
|
||||
</div>
|
||||
<button type="button" class="nav-arrow" @click.stop="move(1)">▼</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="viewMode === 'grid'" class="grid">
|
||||
<button
|
||||
v-for="(driver, index) in filtered"
|
||||
:key="driver.name"
|
||||
type="button"
|
||||
class="mini-card"
|
||||
:class="{ selected: driver.name === modelValue }"
|
||||
@click="selectDriver(driver.name, index)"
|
||||
>
|
||||
<DriverIcon
|
||||
:logo="driver.card_logo"
|
||||
:color="driver.card_color"
|
||||
:name="driver.display_name"
|
||||
:size="30"
|
||||
/>
|
||||
<span class="mini-card__name">{{ driver.display_name }}</span>
|
||||
</button>
|
||||
<div v-if="!filtered.length" class="grid__empty">未找到匹配的驱动</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<div class="actions__search">
|
||||
<button
|
||||
type="button"
|
||||
class="view-toggle"
|
||||
:title="viewMode === 'carousel' ? '切换到卡片视图' : '切换到翻动视图'"
|
||||
@click="toggleView"
|
||||
>
|
||||
<svg v-if="viewMode === 'carousel'" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<rect x="1" y="1" width="6" height="6" rx="1" />
|
||||
<rect x="9" y="1" width="6" height="6" rx="1" />
|
||||
<rect x="1" y="9" width="6" height="6" rx="1" />
|
||||
<rect x="9" y="9" width="6" height="6" rx="1" />
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M2 5h12v1.5H2V5zm0 5h12V11.5H2V11z" />
|
||||
<path d="M5 2.5 2 5.5l3 3V2.5zm6 0v6l3-3-3-3z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="search-box">
|
||||
<span class="search-box__icon">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="M20 20l-4-4" />
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="search-box__input"
|
||||
placeholder="搜索驱动..."
|
||||
/>
|
||||
<button v-if="searchQuery" type="button" class="search-box__clear" @click="clearSearch">×</button>
|
||||
</div>
|
||||
<span v-if="searchQuery" class="search-hint">找到 {{ filtered.length }} 个匹配的驱动</span>
|
||||
</div>
|
||||
<AppButton variant="primary" :disabled="!canNext" @click="emit('next')">
|
||||
下一步 →
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.picker__viewport {
|
||||
height: 196px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.carousel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
isolation: isolate;
|
||||
}
|
||||
.carousel__track {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.42s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
.carousel__track--instant {
|
||||
transition: none;
|
||||
}
|
||||
.carousel__item {
|
||||
height: 196px;
|
||||
flex-shrink: 0;
|
||||
contain: paint;
|
||||
}
|
||||
.carousel__card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:
|
||||
radial-gradient(ellipse at 16% 28%, rgba(255, 255, 255, 0.94), transparent 24%),
|
||||
radial-gradient(ellipse at 76% 18%, color-mix(in srgb, var(--driver-color, var(--brand)) 9%, transparent), transparent 30%),
|
||||
radial-gradient(ellipse at 42% 92%, rgba(255, 255, 255, 0.7), transparent 24%),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--driver-color, var(--brand)) 4%, var(--surface)) 0%,
|
||||
color-mix(in srgb, var(--brand) 8%, var(--surface-sunken)) 58%,
|
||||
color-mix(in srgb, var(--driver-color, var(--brand)) 7%, var(--surface)) 100%
|
||||
);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px 38px;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.3s ease, background 0.3s ease, box-shadow 0.3s ease;
|
||||
overflow: hidden;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
contain: paint;
|
||||
}
|
||||
.carousel__card:hover {
|
||||
border-color: var(--brand);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.carousel__card.selected {
|
||||
border-color: var(--brand);
|
||||
}
|
||||
.carousel__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
min-width: 0;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
.carousel__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
.carousel__info h3 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
line-height: 1.15;
|
||||
}
|
||||
.carousel__auth {
|
||||
flex-shrink: 0;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: color-mix(in srgb, var(--driver-color, var(--brand)) 14%, rgba(255, 255, 255, 0.8));
|
||||
color: color-mix(in srgb, var(--driver-color, var(--brand)) 82%, var(--text));
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.carousel__info p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13.5px;
|
||||
line-height: 1.45;
|
||||
max-width: 460px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.carousel__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.carousel__tag {
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border: 1px solid color-mix(in srgb, var(--driver-color, var(--brand)) 16%, var(--border));
|
||||
color: color-mix(in srgb, var(--driver-color, var(--brand)) 62%, var(--text-muted));
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 12px rgba(30, 55, 95, 0.05);
|
||||
}
|
||||
.carousel__controls {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-left: 30px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.carousel__card:hover .carousel__controls {
|
||||
opacity: 1;
|
||||
}
|
||||
.nav-arrow {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
color: var(--brand);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow-soft);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.nav-arrow:hover {
|
||||
background: var(--brand);
|
||||
color: var(--text-on-brand);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.position-indicator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--brand);
|
||||
min-width: 20px;
|
||||
}
|
||||
.position-text {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--brand);
|
||||
line-height: 1;
|
||||
}
|
||||
.position-divider {
|
||||
width: 12px;
|
||||
height: 1px;
|
||||
background: color-mix(in srgb, var(--brand) 40%, transparent);
|
||||
margin: 2px 0;
|
||||
}
|
||||
.total-text {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--brand) 70%, var(--text-muted));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.grid {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-auto-rows: 60px;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
.mini-card {
|
||||
height: 60px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface-sunken);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 9px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.25s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mini-card:hover {
|
||||
border-color: var(--border);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.mini-card.selected {
|
||||
border-color: var(--brand);
|
||||
background: color-mix(in srgb, var(--brand) 8%, var(--surface));
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--brand) 12%, transparent);
|
||||
}
|
||||
.mini-card__name {
|
||||
min-width: 0;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.grid__empty {
|
||||
grid-column: 1 / -1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 12px;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.actions__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
.view-toggle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--brand);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.view-toggle:hover {
|
||||
border-color: var(--brand);
|
||||
background: color-mix(in srgb, var(--brand) 8%, var(--surface));
|
||||
}
|
||||
.search-box {
|
||||
position: relative;
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.search-box__icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
pointer-events: none;
|
||||
}
|
||||
.search-box__input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 10px 36px 10px 42px;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.search-box__input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.search-box__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 15%, transparent);
|
||||
}
|
||||
.search-box__clear {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--danger);
|
||||
color: var(--text-on-brand);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
.search-hint {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.picker {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.carousel__card {
|
||||
padding: 18px 48px 18px 22px;
|
||||
}
|
||||
|
||||
.carousel__content {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.carousel__content :deep(.driver-icon) {
|
||||
width: 58px !important;
|
||||
height: 58px !important;
|
||||
flex: 0 0 58px;
|
||||
}
|
||||
|
||||
.carousel__title-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.carousel__info h3 {
|
||||
font-size: 22px;
|
||||
line-height: 1.16;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.carousel__auth,
|
||||
.carousel__info p,
|
||||
.carousel__tags {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.carousel__controls {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
gap: 8px;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.nav-arrow {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-auto-rows: 56px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mini-card {
|
||||
height: 56px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 5px 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mini-card :deep(.driver-icon) {
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
flex: 0 0 24px;
|
||||
}
|
||||
|
||||
.mini-card__name {
|
||||
width: 100%;
|
||||
font-size: 10px;
|
||||
line-height: 1.15;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions__search {
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:global(:root[data-theme="dark"] .carousel__card) {
|
||||
background:
|
||||
radial-gradient(ellipse at 18% 30%, rgba(55, 79, 118, 0.28), transparent 28%),
|
||||
radial-gradient(ellipse at 74% 16%, color-mix(in srgb, var(--driver-color, var(--brand)) 16%, transparent), transparent 34%),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--driver-color, var(--brand)) 7%, var(--surface-sunken)) 0%,
|
||||
color-mix(in srgb, var(--brand) 8%, var(--surface)) 58%,
|
||||
color-mix(in srgb, var(--driver-color, var(--brand)) 9%, var(--surface-sunken)) 100%
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--brand) 76%, var(--border));
|
||||
}
|
||||
|
||||
:global(:root[data-theme="dark"] .carousel__info h3) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
:global(:root[data-theme="dark"] .carousel__info p) {
|
||||
color: color-mix(in srgb, var(--text-muted) 82%, #ffffff);
|
||||
}
|
||||
|
||||
:global(:root[data-theme="dark"] .carousel__auth) {
|
||||
background: color-mix(in srgb, var(--driver-color, var(--brand)) 22%, rgba(255, 255, 255, 0.1));
|
||||
color: color-mix(in srgb, var(--driver-color, var(--brand)) 52%, #dbeafe);
|
||||
}
|
||||
|
||||
:global(:root[data-theme="dark"] .carousel__tag) {
|
||||
background: color-mix(in srgb, var(--surface-muted) 86%, var(--driver-color, var(--brand)));
|
||||
border-color: color-mix(in srgb, var(--driver-color, var(--brand)) 28%, var(--border));
|
||||
color: color-mix(in srgb, var(--driver-color, var(--brand)) 42%, #d7e3f7);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:global(:root[data-theme="dark"] .nav-arrow) {
|
||||
background: color-mix(in srgb, var(--surface-muted) 92%, transparent);
|
||||
color: color-mix(in srgb, var(--brand) 78%, #ffffff);
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
:global(:root[data-theme="dark"] .mini-card) {
|
||||
background: var(--surface-sunken);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
:global(:root[data-theme="dark"] .mini-card:hover) {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
:global(:root[data-theme="dark"] .mini-card.selected) {
|
||||
background: color-mix(in srgb, var(--brand) 14%, var(--surface));
|
||||
}
|
||||
|
||||
:root[data-skin="brutal"] .carousel,
|
||||
:root[data-skin="brutal"] .carousel__card,
|
||||
:root[data-skin="brutal"] .nav-arrow,
|
||||
:root[data-skin="brutal"] .mini-card,
|
||||
:root[data-skin="brutal"] .grid__empty,
|
||||
:root[data-skin="brutal"] .view-toggle,
|
||||
:root[data-skin="brutal"] .search-box__input,
|
||||
:root[data-skin="brutal"] .search-box__clear {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
:root[data-skin="brutal"] .carousel__card,
|
||||
:root[data-skin="brutal"] .mini-card,
|
||||
:root[data-skin="brutal"] .grid__empty,
|
||||
:root[data-skin="brutal"] .view-toggle,
|
||||
:root[data-skin="brutal"] .search-box__input {
|
||||
border-color: var(--brutal-ink);
|
||||
}
|
||||
|
||||
:root[data-skin="brutal"] .carousel__card:hover,
|
||||
:root[data-skin="brutal"] .mini-card:hover,
|
||||
:root[data-skin="brutal"] .view-toggle:hover {
|
||||
background: var(--brutal-yellow);
|
||||
border-color: var(--brutal-ink);
|
||||
box-shadow: 4px 4px 0 var(--brutal-ink);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
:root[data-skin="brutal"] .carousel__card.selected,
|
||||
:root[data-skin="brutal"] .mini-card.selected {
|
||||
background: color-mix(in srgb, var(--brand) 12%, var(--surface));
|
||||
border-color: var(--brand);
|
||||
box-shadow: 4px 4px 0 color-mix(in srgb, var(--brand) 48%, var(--brutal-ink));
|
||||
}
|
||||
|
||||
:root[data-skin="brutal"] .nav-arrow:hover,
|
||||
:root[data-skin="brutal"] .search-box__clear:hover {
|
||||
background: var(--brutal-yellow);
|
||||
color: var(--brutal-ink);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
:root[data-skin="brutal"] .search-box__input:focus {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 4px 4px 0 color-mix(in srgb, var(--brand) 55%, var(--brutal-ink));
|
||||
}
|
||||
|
||||
:root[data-skin="brutal"] .view-toggle rect {
|
||||
rx: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ApiError } from "@/api/client";
|
||||
import AppButton from "@/components/base/AppButton.vue";
|
||||
import SectionTabBar from "@/components/admin/SectionTabBar.vue";
|
||||
import WebDAVSettings from "@/components/admin/WebDAVSettings.vue";
|
||||
// 本地挂载面板较大且非默认 tab,按需加载;type-only import 仅用于 ref 类型,不引入代码。
|
||||
import type FuseManagementComponent from "@/components/admin/FuseManagement.vue";
|
||||
const FuseManagement = defineAsyncComponent(() => import("@/components/admin/FuseManagement.vue"));
|
||||
import { useSectionTabRoute } from "@/composables/useSectionTabRoute";
|
||||
import { useSettingsPageDirty } from "@/composables/useSettingsPageDirty";
|
||||
import { toast } from "@/composables/useToast";
|
||||
import "@/styles/admin-shared.css";
|
||||
|
||||
const WEBDAV_TAB = "webdav";
|
||||
const FUSE_TAB = "fuse";
|
||||
const VALID_TABS = [WEBDAV_TAB, FUSE_TAB] as const;
|
||||
|
||||
const tabs = [
|
||||
{ key: WEBDAV_TAB, label: "WebDAV" },
|
||||
{ key: FUSE_TAB, label: "本地挂载" },
|
||||
];
|
||||
|
||||
const route = useRoute();
|
||||
const webdavSettingsRef = ref<InstanceType<typeof WebDAVSettings> | null>(null);
|
||||
const fuseMgmtRef = ref<InstanceType<typeof FuseManagementComponent> | null>(null);
|
||||
const savingWebdav = ref(false);
|
||||
|
||||
const webdavDirty = computed(() => Boolean(webdavSettingsRef.value?.getDirty?.()));
|
||||
const fuseDirty = computed(() => Boolean(fuseMgmtRef.value?.getDirty?.()));
|
||||
|
||||
function normalizeShareTab(tab: string): string {
|
||||
const trimmed = tab.trim();
|
||||
if (!trimmed) return WEBDAV_TAB;
|
||||
return VALID_TABS.includes(trimmed as (typeof VALID_TABS)[number]) ? trimmed : WEBDAV_TAB;
|
||||
}
|
||||
|
||||
function currentShareTab(): string {
|
||||
return normalizeShareTab(String(route.query.tab ?? WEBDAV_TAB));
|
||||
}
|
||||
|
||||
const pageDirty = computed(() => {
|
||||
const tab = currentShareTab();
|
||||
if (tab === WEBDAV_TAB) return webdavDirty.value;
|
||||
if (tab === FUSE_TAB) return fuseDirty.value;
|
||||
return false;
|
||||
});
|
||||
|
||||
function revertPageSettings() {
|
||||
const tab = currentShareTab();
|
||||
if (tab === WEBDAV_TAB) {
|
||||
webdavSettingsRef.value?.revert?.();
|
||||
return;
|
||||
}
|
||||
if (tab === FUSE_TAB) {
|
||||
fuseMgmtRef.value?.revertDrawer?.();
|
||||
fuseMgmtRef.value?.closeSettingsDrawerSilent?.();
|
||||
}
|
||||
}
|
||||
|
||||
const { confirmDiscardChanges } = useSettingsPageDirty(pageDirty, revertPageSettings);
|
||||
|
||||
const { activeTab, setActiveTab } = useSectionTabRoute(WEBDAV_TAB, VALID_TABS, {
|
||||
beforeTabChange: async (from, to) => {
|
||||
if (from === to) return true;
|
||||
const leavingDirty =
|
||||
(from === WEBDAV_TAB && webdavDirty.value) ||
|
||||
(from === FUSE_TAB && fuseDirty.value);
|
||||
if (!leavingDirty) return true;
|
||||
return confirmDiscardChanges(() => leavingDirty);
|
||||
},
|
||||
});
|
||||
|
||||
async function saveWebdav() {
|
||||
if (!webdavDirty.value || !webdavSettingsRef.value) return;
|
||||
savingWebdav.value = true;
|
||||
try {
|
||||
await webdavSettingsRef.value.saveSettings(false);
|
||||
} catch (e) {
|
||||
if (!(e instanceof ApiError)) {
|
||||
toast.error("保存失败");
|
||||
}
|
||||
} finally {
|
||||
savingWebdav.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings file-share-page">
|
||||
<SectionTabBar :model-value="activeTab" :tabs="tabs" @update:model-value="setActiveTab">
|
||||
<template #actions>
|
||||
<AppButton
|
||||
v-if="activeTab === WEBDAV_TAB"
|
||||
type="button"
|
||||
variant="primary"
|
||||
:disabled="!webdavDirty || savingWebdav"
|
||||
@click="saveWebdav"
|
||||
>
|
||||
{{ savingWebdav ? "保存中…" : "保存设置" }}
|
||||
</AppButton>
|
||||
<AppButton v-else-if="activeTab === FUSE_TAB" type="button" variant="primary" @click="fuseMgmtRef?.openCreate()">
|
||||
添加挂载点
|
||||
</AppButton>
|
||||
</template>
|
||||
</SectionTabBar>
|
||||
|
||||
<keep-alive>
|
||||
<WebDAVSettings v-if="activeTab === WEBDAV_TAB" ref="webdavSettingsRef" accent="#10b981" />
|
||||
|
||||
<FuseManagement v-else-if="activeTab === FUSE_TAB" ref="fuseMgmtRef" />
|
||||
</keep-alive>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-share-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import "@/styles/admin-shared.css";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="input-action-field">
|
||||
<div class="input-action-field__main">
|
||||
<slot />
|
||||
</div>
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,739 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
import { getApiErrorMessage } from "@/api/client";
|
||||
import {
|
||||
fetchMediaOrganizeSettings,
|
||||
saveMediaOrganizeSettings,
|
||||
testMediaOrganizeTmdb,
|
||||
type MediaOrganizeSettings,
|
||||
} from "@/api/mediaOrganize";
|
||||
import AppButton from "@/components/base/AppButton.vue";
|
||||
import AppInput from "@/components/base/AppInput.vue";
|
||||
import AppSelect from "@/components/base/AppSelect.vue";
|
||||
import SettingsBoolSegment from "@/components/admin/SettingsBoolSegment.vue";
|
||||
import SettingsCard from "@/components/admin/SettingsCard.vue";
|
||||
import SettingsHelpTooltip from "@/components/admin/SettingsHelpTooltip.vue";
|
||||
import SettingsRow from "@/components/admin/SettingsRow.vue";
|
||||
import TmdbHostsHelpTip from "@/components/admin/TmdbHostsHelpTip.vue";
|
||||
import { useSettingsForm, bindSettingsPanelExpose } from "@/composables/useSettingsForm";
|
||||
import { useSettingsLoad } from "@/composables/useSettingsLoad";
|
||||
import { toast } from "@/composables/useToast";
|
||||
import "@/styles/admin-shared.css";
|
||||
|
||||
const ORGANIZE_SETTINGS_ACCENT = "#10b981";
|
||||
const ALL_TAG_KEYS = ["screen_size", "frame_rate", "video_codec", "audio_codec", "audio_channels"] as const;
|
||||
const TAG_LABELS: Record<string, string> = {
|
||||
screen_size: "分辨率",
|
||||
frame_rate: "帧率",
|
||||
video_codec: "视频编码",
|
||||
audio_codec: "音频编码",
|
||||
audio_channels: "声道数",
|
||||
};
|
||||
|
||||
const tmdbLanguageOptions = [
|
||||
{ value: "zh-CN", label: "简体中文" },
|
||||
{ value: "zh-TW", label: "繁体中文" },
|
||||
{ value: "en-US", label: "English" },
|
||||
];
|
||||
|
||||
const conflictPolicyOptions = [
|
||||
{ value: "skip", label: "跳过(推荐)" },
|
||||
{ value: "overwrite", label: "覆盖" },
|
||||
];
|
||||
|
||||
const { loading, loaded, runLoad } = useSettingsLoad();
|
||||
const saving = ref(false);
|
||||
const tmdbTesting = ref(false);
|
||||
const draggingTagIndex = ref<number | null>(null);
|
||||
const insertIndex = ref<number | null>(null);
|
||||
const tagEditorRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const tagGhost = reactive({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
label: "",
|
||||
});
|
||||
|
||||
let pendingTagDrag: {
|
||||
index: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null = null;
|
||||
|
||||
const tagGhostStyle = computed(() => ({
|
||||
left: `${tagGhost.x}px`,
|
||||
top: `${tagGhost.y}px`,
|
||||
width: `${tagGhost.width}px`,
|
||||
height: `${tagGhost.height}px`,
|
||||
}));
|
||||
|
||||
const {
|
||||
settings,
|
||||
isDirty: settingsChanged,
|
||||
isFieldChanged,
|
||||
snapshotBaseline,
|
||||
revert: revertToBaseline,
|
||||
} = useSettingsForm<MediaOrganizeSettings>({
|
||||
proxy_enabled: false,
|
||||
proxy_url: "",
|
||||
proxy_username: "",
|
||||
proxy_password: "",
|
||||
tmdb_api_key: "",
|
||||
tmdb_language: "zh-CN",
|
||||
api_request_interval_ms: 300,
|
||||
tmdb_request_interval_ms: 250,
|
||||
min_confidence_threshold: 0,
|
||||
file_extensions: "",
|
||||
metadata_extensions: "",
|
||||
media_tag_order: "",
|
||||
align_media_tags: false,
|
||||
max_works_per_run: 50,
|
||||
overwrite_existing: false,
|
||||
});
|
||||
const tagOrder = reactive<string[]>([...ALL_TAG_KEYS]);
|
||||
|
||||
const disabledTags = computed(() => ALL_TAG_KEYS.filter((k) => !tagOrder.includes(k)));
|
||||
|
||||
const conflictPolicy = computed({
|
||||
get: () => (settings.overwrite_existing ? "overwrite" : "skip"),
|
||||
set: (val: string) => {
|
||||
settings.overwrite_existing = val === "overwrite";
|
||||
},
|
||||
});
|
||||
|
||||
function parseMediaTagOrder(raw: MediaOrganizeSettings["media_tag_order"]): string[] | null {
|
||||
if (Array.isArray(raw)) return raw.filter((k) => ALL_TAG_KEYS.includes(k as (typeof ALL_TAG_KEYS)[number]));
|
||||
if (typeof raw === "string" && raw.trim()) {
|
||||
try {
|
||||
let parsed: unknown = JSON.parse(raw);
|
||||
if (typeof parsed === "string") parsed = JSON.parse(parsed);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.filter((k) => ALL_TAG_KEYS.includes(k as (typeof ALL_TAG_KEYS)[number]));
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function syncTagsFromSettings() {
|
||||
const order = parseMediaTagOrder(settings.media_tag_order);
|
||||
tagOrder.splice(0, tagOrder.length, ...(order ?? [...ALL_TAG_KEYS]));
|
||||
}
|
||||
|
||||
function flushTagOrderToSettings() {
|
||||
settings.media_tag_order = JSON.stringify([...tagOrder]);
|
||||
}
|
||||
|
||||
function removeTag(key: string) {
|
||||
const idx = tagOrder.indexOf(key);
|
||||
if (idx >= 0) tagOrder.splice(idx, 1);
|
||||
flushTagOrderToSettings();
|
||||
}
|
||||
|
||||
function addTag(key: string) {
|
||||
if (!tagOrder.includes(key)) tagOrder.push(key);
|
||||
flushTagOrderToSettings();
|
||||
}
|
||||
|
||||
function startTagPointerDrag(index: number, e: PointerEvent) {
|
||||
if (e.button !== 0 || draggingTagIndex.value !== null) return;
|
||||
if ((e.target as HTMLElement | null)?.closest(".tag-chip__remove")) return;
|
||||
const chip = e.currentTarget as HTMLElement;
|
||||
const rect = chip.getBoundingClientRect();
|
||||
pendingTagDrag = {
|
||||
index,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
offsetX: e.clientX - rect.left,
|
||||
offsetY: e.clientY - rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
document.addEventListener("pointermove", handleTagPointerMove);
|
||||
document.addEventListener("pointerup", finishTagPointerDrag);
|
||||
document.addEventListener("pointercancel", cancelTagPointerDrag);
|
||||
}
|
||||
|
||||
function beginTagDrag(e: PointerEvent) {
|
||||
if (!pendingTagDrag) return;
|
||||
const index = pendingTagDrag.index;
|
||||
draggingTagIndex.value = index;
|
||||
insertIndex.value = index;
|
||||
tagGhost.visible = true;
|
||||
tagGhost.label = TAG_LABELS[tagOrder[index]] ?? tagOrder[index];
|
||||
tagGhost.width = pendingTagDrag.width;
|
||||
tagGhost.height = pendingTagDrag.height;
|
||||
tagGhost.x = e.clientX - pendingTagDrag.offsetX;
|
||||
tagGhost.y = e.clientY - pendingTagDrag.offsetY;
|
||||
document.body.classList.add("mo-tag-dragging");
|
||||
}
|
||||
|
||||
function handleTagPointerMove(e: PointerEvent) {
|
||||
if (!pendingTagDrag) return;
|
||||
const dx = Math.abs(e.clientX - pendingTagDrag.startX);
|
||||
const dy = Math.abs(e.clientY - pendingTagDrag.startY);
|
||||
if (draggingTagIndex.value === null) {
|
||||
if (dx < 4 && dy < 4) return;
|
||||
beginTagDrag(e);
|
||||
}
|
||||
e.preventDefault();
|
||||
tagGhost.x = e.clientX - pendingTagDrag.offsetX;
|
||||
tagGhost.y = e.clientY - pendingTagDrag.offsetY;
|
||||
updateInsertIndex(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
function updateInsertIndex(clientX: number, clientY: number) {
|
||||
const root = tagEditorRef.value;
|
||||
if (!root || draggingTagIndex.value === null) return;
|
||||
|
||||
const chips = Array.from(root.querySelectorAll<HTMLElement>(".tag-chip[data-tag-index]"));
|
||||
if (!chips.length) {
|
||||
insertIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const rowChips = chips.filter((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return clientY >= rect.top - 12 && clientY <= rect.bottom + 12;
|
||||
});
|
||||
const targets = (rowChips.length ? rowChips : chips).sort(
|
||||
(a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left,
|
||||
);
|
||||
|
||||
for (const el of targets) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const tagIndex = Number(el.dataset.tagIndex);
|
||||
if (Number.isNaN(tagIndex)) continue;
|
||||
if (clientX < rect.left + rect.width / 2) {
|
||||
insertIndex.value = tagIndex;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const lastIndex = Number(targets[targets.length - 1].dataset.tagIndex);
|
||||
insertIndex.value = Number.isNaN(lastIndex) ? tagOrder.length : lastIndex + 1;
|
||||
}
|
||||
|
||||
function applyTagMove(from: number, insert: number) {
|
||||
if (insert === from || insert === from + 1) return;
|
||||
const item = tagOrder.splice(from, 1)[0];
|
||||
const target = from < insert ? insert - 1 : insert;
|
||||
tagOrder.splice(target, 0, item);
|
||||
flushTagOrderToSettings();
|
||||
}
|
||||
|
||||
function finishTagPointerDrag() {
|
||||
if (draggingTagIndex.value !== null && insertIndex.value !== null) {
|
||||
applyTagMove(draggingTagIndex.value, insertIndex.value);
|
||||
}
|
||||
endTagDrag();
|
||||
cleanupTagPointerListeners();
|
||||
}
|
||||
|
||||
function cancelTagPointerDrag() {
|
||||
endTagDrag();
|
||||
cleanupTagPointerListeners();
|
||||
}
|
||||
|
||||
function cleanupTagPointerListeners() {
|
||||
pendingTagDrag = null;
|
||||
document.removeEventListener("pointermove", handleTagPointerMove);
|
||||
document.removeEventListener("pointerup", finishTagPointerDrag);
|
||||
document.removeEventListener("pointercancel", cancelTagPointerDrag);
|
||||
}
|
||||
|
||||
function endTagDrag() {
|
||||
draggingTagIndex.value = null;
|
||||
insertIndex.value = null;
|
||||
tagGhost.visible = false;
|
||||
document.body.classList.remove("mo-tag-dragging");
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupTagPointerListeners();
|
||||
endTagDrag();
|
||||
});
|
||||
|
||||
async function loadSettings(options?: { silent?: boolean }) {
|
||||
await runLoad(async () => {
|
||||
const data = await fetchMediaOrganizeSettings();
|
||||
Object.assign(settings, data);
|
||||
if (settings.max_works_per_run == null) settings.max_works_per_run = 50;
|
||||
syncTagsFromSettings();
|
||||
snapshotBaseline();
|
||||
}, "加载整理设置失败", options);
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
if (!settingsChanged.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
flushTagOrderToSettings();
|
||||
const data = await saveMediaOrganizeSettings({ ...settings });
|
||||
Object.assign(settings, data);
|
||||
syncTagsFromSettings();
|
||||
snapshotBaseline();
|
||||
toast.success("整理设置已保存");
|
||||
} catch (e) {
|
||||
toast.error(getApiErrorMessage(e, "保存整理设置失败"));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testTmdb() {
|
||||
tmdbTesting.value = true;
|
||||
try {
|
||||
const result = await testMediaOrganizeTmdb({
|
||||
tmdb_api_key: settings.tmdb_api_key,
|
||||
tmdb_language: settings.tmdb_language,
|
||||
proxy_enabled: settings.proxy_enabled,
|
||||
proxy_url: settings.proxy_url,
|
||||
proxy_username: settings.proxy_username,
|
||||
proxy_password: settings.proxy_password,
|
||||
});
|
||||
if (result.ok) {
|
||||
toast.success(`TMDB 连通正常(语言:${result.language ?? settings.tmdb_language})。请确认已点击「保存设置」,再重新生成整理计划。`);
|
||||
} else {
|
||||
toast.error("TMDB 连通测试失败");
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(getApiErrorMessage(e, "TMDB 测试失败"));
|
||||
} finally {
|
||||
tmdbTesting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadSettings();
|
||||
});
|
||||
|
||||
function revertPanelSettings() {
|
||||
revertToBaseline();
|
||||
syncTagsFromSettings();
|
||||
}
|
||||
|
||||
defineExpose(
|
||||
bindSettingsPanelExpose({
|
||||
isDirty: settingsChanged,
|
||||
saving,
|
||||
save: saveSettings,
|
||||
reload: () => loadSettings({ silent: loaded.value }),
|
||||
revert: revertPanelSettings,
|
||||
}),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mo-settings">
|
||||
<div v-if="loading" class="settings-card__loading">加载中…</div>
|
||||
|
||||
<template v-else>
|
||||
<SettingsCard title="代理设置" :accent="ORGANIZE_SETTINGS_ACCENT">
|
||||
<template #head-aside>
|
||||
<TmdbHostsHelpTip />
|
||||
</template>
|
||||
<SettingsRow :show-changed-badge="true" :changed="isFieldChanged('proxy_enabled')">
|
||||
<template #info>
|
||||
<div class="settings-row__label"><span>启用代理</span></div>
|
||||
</template>
|
||||
<template #control>
|
||||
<SettingsBoolSegment v-model="settings.proxy_enabled" label="启用代理" />
|
||||
</template>
|
||||
</SettingsRow>
|
||||
|
||||
<template v-if="settings.proxy_enabled">
|
||||
<SettingsRow :show-changed-badge="true" :changed="isFieldChanged('proxy_url')">
|
||||
<template #info>
|
||||
<div class="settings-row__label"><span>代理地址</span></div>
|
||||
</template>
|
||||
<template #control>
|
||||
<AppInput v-model="settings.proxy_url" placeholder="http://127.0.0.1:1080 或 socks5://127.0.0.1:1080" />
|
||||
</template>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow :show-changed-badge="true" :changed="isFieldChanged('proxy_username')">
|
||||
<template #info>
|
||||
<div class="settings-row__label"><span>代理用户名</span></div>
|
||||
</template>
|
||||
<template #control>
|
||||
<AppInput
|
||||
v-model="settings.proxy_username"
|
||||
autocomplete="off"
|
||||
placeholder="可选"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow :show-changed-badge="true" :changed="isFieldChanged('proxy_password')">
|
||||
<template #info>
|
||||
<div class="settings-row__label"><span>代理密码</span></div>
|
||||
</template>
|
||||
<template #control>
|
||||
<AppInput v-model="settings.proxy_password" type="password" autocomplete="new-password" placeholder="可选" />
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</template>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="TMDB 设置" :accent="ORGANIZE_SETTINGS_ACCENT">
|
||||
<template #head-actions>
|
||||
<AppButton type="button" variant="secondary" size="sm" :disabled="tmdbTesting" @click="testTmdb">
|
||||
{{ tmdbTesting ? "测试中…" : "测试连通性" }}
|
||||
</AppButton>
|
||||
</template>
|
||||
|
||||
<SettingsRow :show-changed-badge="true" :changed="isFieldChanged('tmdb_api_key')">
|
||||
<template #info>
|
||||
<div class="settings-row__label"><span>TMDB API Key</span></div>
|
||||
</template>
|
||||
<template #control>
|
||||
<AppInput v-model="settings.tmdb_api_key" placeholder="请填写 TMDB API Key(必填)" />
|
||||
</template>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow :show-changed-badge="true" :changed="isFieldChanged('tmdb_language')">
|
||||
<template #info>
|
||||
<div class="settings-row__label"><span>TMDB 语言(影响搜索和命名)</span></div>
|
||||
</template>
|
||||
<template #control>
|
||||
<AppSelect v-model="settings.tmdb_language" :options="tmdbLanguageOptions" />
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="API 请求节流" :accent="ORGANIZE_SETTINGS_ACCENT">
|
||||
<SettingsRow :show-changed-badge="true" :changed="isFieldChanged('api_request_interval_ms')">
|
||||
<template #info>
|
||||
<div class="settings-row__label"><span>API 额外补偿间隔(毫秒)</span></div>
|
||||
</template>
|
||||
<template #control>
|
||||
<AppInput v-model="settings.api_request_interval_ms" type="number" min="100" max="10000" />
|
||||
</template>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow :show-changed-badge="true" :changed="isFieldChanged('tmdb_request_interval_ms')">
|
||||
<template #info>
|
||||
<div class="settings-row__label"><span>TMDB 请求间隔(毫秒)</span></div>
|
||||
</template>
|
||||
<template #control>
|
||||
<AppInput v-model="settings.tmdb_request_interval_ms" type="number" min="100" max="5000" />
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="文件识别与整理规则" :accent="ORGANIZE_SETTINGS_ACCENT">
|
||||
<SettingsRow :show-changed-badge="true" :changed="isFieldChanged('file_extensions')">
|
||||
<template #info>
|
||||
<div class="settings-row__label"><span>媒体文件后缀(分号分隔)</span></div>
|
||||
</template>
|
||||
<template #control>
|
||||
<AppInput v-model="settings.file_extensions" placeholder="mkv;mp4;avi;ts;mov…" />
|
||||
</template>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow :show-changed-badge="true" :changed="isFieldChanged('metadata_extensions')">
|
||||
<template #info>
|
||||
<div class="settings-row__label"><span>元数据文件后缀(分号分隔)</span></div>
|
||||
</template>
|
||||
<template #control>
|
||||
<AppInput v-model="settings.metadata_extensions" placeholder="nfo;ass;srt;sub…" />
|
||||
</template>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow :show-changed-badge="true" :changed="isFieldChanged('max_works_per_run')">
|
||||
<template #info>
|
||||
<div class="settings-row__label">
|
||||
<span>每次最多整理作品数</span>
|
||||
<SettingsHelpTooltip title="分批整理说明">
|
||||
<p>每次生成计划最多包含这么多部作品(一部电影或一部剧集算 1 部),达到上限后停止扫描。</p>
|
||||
<p>已整理过的(带 tmdb 标识)不计入此数。</p>
|
||||
<p>执行完后再次生成计划即可处理剩余作品。0 表示不限制(不推荐用于大库)。</p>
|
||||
</SettingsHelpTooltip>
|
||||
</div>
|
||||
</template>
|
||||
<template #control>
|
||||
<AppInput v-model="settings.max_works_per_run" type="number" min="0" max="10000" placeholder="50" />
|
||||
</template>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow :show-changed-badge="true" :changed="isFieldChanged('overwrite_existing')">
|
||||
<template #info>
|
||||
<div class="settings-row__label">
|
||||
<span>同名冲突处理</span>
|
||||
<SettingsHelpTooltip title="同名冲突处理说明">
|
||||
<p>执行整理时,若目标目录已存在同名文件:</p>
|
||||
<p><b>跳过</b>:保留目标已有文件,跳过该项(推荐,更安全)</p>
|
||||
<p><b>覆盖</b>:先删除目标已有同名文件,再写入新文件</p>
|
||||
</SettingsHelpTooltip>
|
||||
</div>
|
||||
</template>
|
||||
<template #control>
|
||||
<AppSelect v-model="conflictPolicy" :options="conflictPolicyOptions" />
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="媒体信息标签排序" :accent="ORGANIZE_SETTINGS_ACCENT">
|
||||
<SettingsRow :show-changed-badge="true" :changed="isFieldChanged('align_media_tags')">
|
||||
<template #info>
|
||||
<div class="settings-row__label">
|
||||
<span>强迫症模式</span>
|
||||
<SettingsHelpTooltip title="强迫症模式说明">
|
||||
<p>开启后,同一后缀文件将保持媒体信息一致。</p>
|
||||
</SettingsHelpTooltip>
|
||||
</div>
|
||||
</template>
|
||||
<template #control>
|
||||
<SettingsBoolSegment v-model="settings.align_media_tags" label="强迫症模式" />
|
||||
</template>
|
||||
</SettingsRow>
|
||||
|
||||
<div class="mo-tag-row">
|
||||
<SettingsRow
|
||||
:show-changed-badge="true"
|
||||
:changed="isFieldChanged('media_tag_order')"
|
||||
>
|
||||
<template #info>
|
||||
<div class="settings-row__label">
|
||||
<span>媒体信息顺序</span>
|
||||
<SettingsHelpTooltip title="媒体信息顺序说明">
|
||||
<p>拖拽标签调整顺序,点击 × 移除。文件名按此顺序生成媒体信息。</p>
|
||||
</SettingsHelpTooltip>
|
||||
</div>
|
||||
</template>
|
||||
<template #control>
|
||||
<div class="mo-tag-editor">
|
||||
<div
|
||||
ref="tagEditorRef"
|
||||
class="mo-tag-editor__active"
|
||||
:class="{ 'mo-tag-editor__active--dragging': draggingTagIndex !== null }"
|
||||
>
|
||||
<template v-if="draggingTagIndex === null">
|
||||
<span
|
||||
v-for="(key, index) in tagOrder"
|
||||
:key="key"
|
||||
class="tag-chip"
|
||||
:data-tag-index="index"
|
||||
@pointerdown="startTagPointerDrag(index, $event)"
|
||||
>
|
||||
<span class="tag-chip__text">{{ TAG_LABELS[key] }}</span>
|
||||
<span class="tag-chip__remove" @click.stop="removeTag(key)">×</span>
|
||||
</span>
|
||||
<span v-if="tagOrder.length === 0" class="mo-tag-editor__placeholder">点击下方标签添加</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-for="slot in tagOrder.length + 1" :key="`tag-slot-${slot - 1}`">
|
||||
<span
|
||||
v-if="insertIndex === slot - 1"
|
||||
class="tag-insert-preview"
|
||||
:data-insert-index="slot - 1"
|
||||
/>
|
||||
<span
|
||||
v-if="slot - 1 < tagOrder.length && slot - 1 !== draggingTagIndex"
|
||||
:key="tagOrder[slot - 1]"
|
||||
class="tag-chip"
|
||||
:data-tag-index="slot - 1"
|
||||
>
|
||||
<span class="tag-chip__text">{{ TAG_LABELS[tagOrder[slot - 1]] }}</span>
|
||||
<span class="tag-chip__remove" @click.stop="removeTag(tagOrder[slot - 1])">×</span>
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<span
|
||||
v-if="tagGhost.visible"
|
||||
class="tag-chip tag-chip--ghost"
|
||||
:style="tagGhostStyle"
|
||||
>
|
||||
<span class="tag-chip__text">{{ tagGhost.label }}</span>
|
||||
<span class="tag-chip__remove tag-chip__remove--ghost" aria-hidden="true">×</span>
|
||||
</span>
|
||||
</Teleport>
|
||||
<div v-if="disabledTags.length" class="mo-tag-editor__pool">
|
||||
<span
|
||||
v-for="key in disabledTags"
|
||||
:key="key"
|
||||
class="tag-chip tag-chip--add"
|
||||
@click="addTag(key)"
|
||||
>
|
||||
<span class="tag-chip__addon">+</span>
|
||||
<span class="tag-chip__text">{{ TAG_LABELS[key] }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mo-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mo-settings__card-head {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin: -4px 0 8px;
|
||||
}
|
||||
|
||||
.mo-tag-row :deep(.settings-row) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.mo-tag-row :deep(.settings-row__info) {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.mo-tag-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mo-tag-editor__active {
|
||||
--tag-chip-width: calc(4em + 40px);
|
||||
--tag-chip-height: 32px;
|
||||
}
|
||||
|
||||
.mo-tag-editor__active,
|
||||
.mo-tag-editor__pool {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mo-tag-editor__active--dragging {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
:global(body.mo-tag-dragging) {
|
||||
cursor: grabbing;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mo-tag-editor__placeholder {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
box-sizing: border-box;
|
||||
flex: 0 0 var(--tag-chip-width, calc(4em + 40px));
|
||||
width: var(--tag-chip-width, calc(4em + 40px));
|
||||
min-height: var(--tag-chip-height, 32px);
|
||||
padding: 6px 10px;
|
||||
background: color-mix(in srgb, var(--brand) 10%, var(--surface));
|
||||
border: 1px solid color-mix(in srgb, var(--brand) 25%, var(--border));
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
color: var(--brand);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.tag-chip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.tag-insert-preview {
|
||||
display: inline-flex;
|
||||
box-sizing: border-box;
|
||||
flex: 0 0 var(--tag-chip-width, calc(4em + 40px));
|
||||
width: var(--tag-chip-width, calc(4em + 40px));
|
||||
height: var(--tag-chip-height, 32px);
|
||||
min-height: var(--tag-chip-height, 32px);
|
||||
border: 1px dashed var(--brand);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--brand) 6%, transparent);
|
||||
}
|
||||
|
||||
.tag-chip--ghost {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
cursor: grabbing;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 8px 20px color-mix(in srgb, var(--brand) 22%, transparent);
|
||||
}
|
||||
|
||||
.tag-chip__text {
|
||||
flex: 0 0 4em;
|
||||
width: 4em;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tag-chip__addon {
|
||||
flex: 0 0 12px;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tag-chip__remove {
|
||||
flex: 0 0 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tag-chip__remove:hover {
|
||||
background: color-mix(in srgb, var(--brand) 20%, transparent);
|
||||
}
|
||||
|
||||
.tag-chip__remove--ghost {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tag-chip--add {
|
||||
cursor: pointer;
|
||||
flex: 0 0 calc(4em + 28px);
|
||||
width: calc(4em + 28px);
|
||||
min-height: var(--tag-chip-height, 32px);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px dashed var(--border-soft);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tag-chip--add .tag-chip__text {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tag-chip--add:hover {
|
||||
background: color-mix(in srgb, var(--brand) 8%, var(--surface));
|
||||
border-color: color-mix(in srgb, var(--brand) 40%, var(--border));
|
||||
color: var(--brand);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,831 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { getApiErrorMessage } from "@/api/client";
|
||||
import {
|
||||
fetchEmbyConfig,
|
||||
refreshEmbyLibrary,
|
||||
saveEmbyConfig,
|
||||
testEmbyConfig,
|
||||
type EmbyConfig,
|
||||
} from "@/api/emby";
|
||||
import {
|
||||
fetchFnosConfig,
|
||||
saveFnosConfig,
|
||||
testFnosConfig,
|
||||
type FnosConfig,
|
||||
} from "@/api/fnos";
|
||||
import { toast, copyTextToClipboard } from "@/composables/useToast";
|
||||
import { useSettingsLoad } from "@/composables/useSettingsLoad";
|
||||
import AppButton from "@/components/base/AppButton.vue";
|
||||
import AppInput from "@/components/base/AppInput.vue";
|
||||
import SettingsHelpTooltip from "@/components/admin/SettingsHelpTooltip.vue";
|
||||
import embyLogo from "@/assets/proxy/embylogo.png";
|
||||
import fnosLogo from "@/assets/proxy/fnmovielogo.png";
|
||||
import "@/styles/admin-shared.css";
|
||||
|
||||
const { loading, runLoad } = useSettingsLoad();
|
||||
|
||||
const embyForm = reactive({
|
||||
enabled: false,
|
||||
emby_url: "",
|
||||
api_key: "",
|
||||
proxy_port: "",
|
||||
proxy_url: "",
|
||||
running: false,
|
||||
last_error: "",
|
||||
});
|
||||
const embyOriginal = reactive({
|
||||
enabled: false,
|
||||
emby_url: "",
|
||||
api_key: "",
|
||||
proxy_port: "",
|
||||
});
|
||||
|
||||
const fnosForm = reactive({
|
||||
enabled: false,
|
||||
fnos_url: "",
|
||||
proxy_port: "",
|
||||
strm_path_maps: "",
|
||||
strm_dir: "/app/strm",
|
||||
proxy_url: "",
|
||||
running: false,
|
||||
last_error: "",
|
||||
});
|
||||
const fnosOriginal = reactive({
|
||||
enabled: false,
|
||||
fnos_url: "",
|
||||
proxy_port: "",
|
||||
strm_path_maps: "",
|
||||
});
|
||||
|
||||
const savingEmby = ref(false);
|
||||
const savingFnos = ref(false);
|
||||
const testingEmby = ref(false);
|
||||
const testingFnos = ref(false);
|
||||
const refreshingEmby = ref(false);
|
||||
|
||||
const canTestEmby = computed(() => Boolean(embyForm.emby_url.trim() && embyForm.api_key.trim()));
|
||||
const canTestFnos = computed(() => Boolean(fnosForm.fnos_url.trim()));
|
||||
const canRefreshEmby = computed(() => Boolean(embyForm.emby_url.trim() && embyForm.api_key.trim()));
|
||||
|
||||
function applyEmbyConfig(config: EmbyConfig) {
|
||||
embyForm.enabled = Boolean(config.enabled);
|
||||
embyForm.emby_url = config.emby_url || "";
|
||||
embyForm.api_key = config.api_key || "";
|
||||
embyForm.proxy_port = config.proxy_port || "";
|
||||
embyForm.proxy_url = config.proxy_url || "";
|
||||
embyForm.running = Boolean(config.running);
|
||||
embyForm.last_error = config.last_error || "";
|
||||
embyOriginal.enabled = embyForm.enabled;
|
||||
embyOriginal.emby_url = embyForm.emby_url;
|
||||
embyOriginal.api_key = embyForm.api_key;
|
||||
embyOriginal.proxy_port = embyForm.proxy_port;
|
||||
}
|
||||
|
||||
function applyFnosConfig(config: FnosConfig) {
|
||||
fnosForm.enabled = Boolean(config.enabled);
|
||||
fnosForm.fnos_url = config.fnos_url || "";
|
||||
fnosForm.proxy_port = config.proxy_port || "";
|
||||
fnosForm.strm_path_maps = config.strm_path_maps || "";
|
||||
fnosForm.strm_dir = config.strm_dir || "/app/strm";
|
||||
fnosForm.proxy_url = config.proxy_url || "";
|
||||
fnosForm.running = Boolean(config.running);
|
||||
fnosForm.last_error = config.last_error || "";
|
||||
fnosOriginal.enabled = fnosForm.enabled;
|
||||
fnosOriginal.fnos_url = fnosForm.fnos_url;
|
||||
fnosOriginal.proxy_port = fnosForm.proxy_port;
|
||||
fnosOriginal.strm_path_maps = fnosForm.strm_path_maps;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
await runLoad(async () => {
|
||||
const [emby, fnos] = await Promise.all([fetchEmbyConfig(), fetchFnosConfig()]);
|
||||
applyEmbyConfig(emby);
|
||||
applyFnosConfig(fnos);
|
||||
}, "加载反代配置失败");
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
function resolveProxyURL(proxyURL: string, port: string) {
|
||||
const p = port.trim();
|
||||
if (!p) return proxyURL;
|
||||
try {
|
||||
const u = new URL(proxyURL || `http://127.0.0.1:${p}`);
|
||||
const pageHost = window.location.hostname;
|
||||
if (
|
||||
pageHost &&
|
||||
pageHost !== "127.0.0.1" &&
|
||||
pageHost !== "localhost" &&
|
||||
(u.hostname === "127.0.0.1" || u.hostname === "localhost")
|
||||
) {
|
||||
return `${window.location.protocol}//${pageHost}:${p}`;
|
||||
}
|
||||
if (!proxyURL) {
|
||||
return `${u.protocol}//${u.hostname}:${p}`;
|
||||
}
|
||||
} catch {}
|
||||
return proxyURL;
|
||||
}
|
||||
|
||||
function endpointText(enabled: boolean, running: boolean, proxyURL: string, port: string) {
|
||||
const resolved = resolveProxyURL(proxyURL, port);
|
||||
if (enabled && running && resolved) return resolved;
|
||||
if (enabled && port.trim()) return "已启用,保存后生成入口";
|
||||
if (enabled) return "已启用,填写端口后生成入口";
|
||||
return "启用并填写端口后生成";
|
||||
}
|
||||
|
||||
async function copyText(text: string) {
|
||||
if (!text || text.includes("生成")) {
|
||||
toast.error("暂无可复制的反代地址");
|
||||
return;
|
||||
}
|
||||
await copyTextToClipboard(text, {
|
||||
successMessage: "已复制反代地址",
|
||||
errorMessage: "复制失败,请手动选择地址",
|
||||
});
|
||||
}
|
||||
|
||||
async function saveEmby() {
|
||||
savingEmby.value = true;
|
||||
try {
|
||||
applyEmbyConfig(
|
||||
await saveEmbyConfig({
|
||||
enabled: embyForm.enabled,
|
||||
emby_url: embyForm.emby_url,
|
||||
api_key: embyForm.api_key,
|
||||
proxy_port: embyForm.proxy_port,
|
||||
}),
|
||||
);
|
||||
toast.success("Emby 反代配置已保存");
|
||||
} catch (e) {
|
||||
toast.error(getApiErrorMessage(e, "保存失败"));
|
||||
} finally {
|
||||
savingEmby.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFnos() {
|
||||
savingFnos.value = true;
|
||||
try {
|
||||
applyFnosConfig(
|
||||
await saveFnosConfig({
|
||||
enabled: fnosForm.enabled,
|
||||
fnos_url: fnosForm.fnos_url,
|
||||
proxy_port: fnosForm.proxy_port,
|
||||
strm_path_maps: fnosForm.strm_path_maps,
|
||||
}),
|
||||
);
|
||||
toast.success("飞牛影视反代配置已保存");
|
||||
} catch (e) {
|
||||
toast.error(getApiErrorMessage(e, "保存失败"));
|
||||
} finally {
|
||||
savingFnos.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testEmby() {
|
||||
if (!embyForm.emby_url.trim()) {
|
||||
toast.error("请先填写 Emby 地址");
|
||||
return;
|
||||
}
|
||||
if (!embyForm.api_key.trim()) {
|
||||
toast.error("请先填写 Emby API Key");
|
||||
return;
|
||||
}
|
||||
testingEmby.value = true;
|
||||
try {
|
||||
await testEmbyConfig({
|
||||
enabled: embyForm.enabled,
|
||||
emby_url: embyForm.emby_url,
|
||||
api_key: embyForm.api_key,
|
||||
proxy_port: embyForm.proxy_port,
|
||||
});
|
||||
toast.success("Emby 地址与 API Key 验证通过");
|
||||
} catch (e) {
|
||||
toast.error(getApiErrorMessage(e, "Emby 验证失败"));
|
||||
} finally {
|
||||
testingEmby.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testFnos() {
|
||||
if (!fnosForm.fnos_url.trim()) {
|
||||
toast.error("请先填写飞牛影视地址");
|
||||
return;
|
||||
}
|
||||
testingFnos.value = true;
|
||||
try {
|
||||
await testFnosConfig({
|
||||
enabled: fnosForm.enabled,
|
||||
fnos_url: fnosForm.fnos_url,
|
||||
proxy_port: fnosForm.proxy_port,
|
||||
strm_path_maps: fnosForm.strm_path_maps,
|
||||
});
|
||||
toast.success("飞牛影视地址可访问");
|
||||
} catch (e) {
|
||||
toast.error(getApiErrorMessage(e, "飞牛影视验证失败"));
|
||||
} finally {
|
||||
testingFnos.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshEmby() {
|
||||
refreshingEmby.value = true;
|
||||
try {
|
||||
await refreshEmbyLibrary();
|
||||
toast.success("已触发 Emby 刷库");
|
||||
} catch (e) {
|
||||
toast.error(getApiErrorMessage(e, "刷库失败"));
|
||||
} finally {
|
||||
refreshingEmby.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const embyEndpoint = computed(() =>
|
||||
endpointText(embyForm.enabled, embyForm.running, embyForm.proxy_url, embyForm.proxy_port),
|
||||
);
|
||||
const fnosEndpoint = computed(() =>
|
||||
endpointText(fnosForm.enabled, fnosForm.running, fnosForm.proxy_url, fnosForm.proxy_port),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="proxy-tools" :class="{ 'is-loading': loading }">
|
||||
<div class="proxy-grid">
|
||||
<article class="proxy-card" :class="{ 'is-on': embyForm.enabled }">
|
||||
<div class="card-head">
|
||||
<div class="brand-block">
|
||||
<img class="brand-logo" :src="embyLogo" alt="" width="44" height="44" />
|
||||
<div class="brand-meta">
|
||||
<h2>
|
||||
Emby 反代
|
||||
<SettingsHelpTooltip title="Emby 反代说明">
|
||||
<p>在播放器和 Emby 之间加一层:播放 STRM 时,把真实的网盘播放地址直接交给播放器。</p>
|
||||
<p>Infuse 等播放器连下方的「反代入口」,就能正常播放 STRM 影片。</p>
|
||||
</SettingsHelpTooltip>
|
||||
</h2>
|
||||
<span class="pill" :class="{ live: embyForm.running }">
|
||||
<span class="dot" />
|
||||
<span v-if="embyForm.running">运行中 · :{{ embyForm.proxy_port }}</span>
|
||||
<span v-else-if="embyForm.enabled">已启用 · 未监听</span>
|
||||
<span v-else>未启用</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="check-toggle"
|
||||
type="button"
|
||||
:class="{ on: embyForm.enabled }"
|
||||
:aria-label="embyForm.enabled ? '关闭 Emby 反代' : '启用 Emby 反代'"
|
||||
@click="embyForm.enabled = !embyForm.enabled"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
d="M3.5 8.5 6.5 11.5 12.5 4.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<div class="endpoint-label">
|
||||
反代入口(给客户端)
|
||||
<SettingsHelpTooltip title="反代入口说明">
|
||||
<p>在播放器里添加服务器时,填这个地址。</p>
|
||||
<p>注意不是下面的「Emby 地址」,别填混了。</p>
|
||||
</SettingsHelpTooltip>
|
||||
</div>
|
||||
<div class="endpoint-row">
|
||||
<div class="endpoint-url" :class="{ muted: !embyForm.running || !embyEndpoint.startsWith('http') }">
|
||||
{{ embyEndpoint }}
|
||||
</div>
|
||||
<button
|
||||
class="ghost-btn"
|
||||
type="button"
|
||||
:disabled="!embyForm.running || !embyEndpoint.startsWith('http')"
|
||||
@click="copyText(embyEndpoint)"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="params">
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
Emby 地址<span class="req">*</span>
|
||||
<SettingsHelpTooltip title="Emby 地址说明">
|
||||
<p>你的 Emby 服务器地址,例如 http://192.168.1.10:8096。</p>
|
||||
<p>给 LitePan 连 Emby 用的,播放器里不要填这个。</p>
|
||||
</SettingsHelpTooltip>
|
||||
</div>
|
||||
<div class="control">
|
||||
<AppInput
|
||||
v-model="embyForm.emby_url"
|
||||
placeholder="http://192.168.1.10:8096"
|
||||
autocomplete="off"
|
||||
ignore-autofill
|
||||
/>
|
||||
<AppButton
|
||||
type="button"
|
||||
class="test-btn"
|
||||
variant="secondary"
|
||||
:disabled="testingEmby || !canTestEmby"
|
||||
@click="testEmby"
|
||||
>
|
||||
{{ testingEmby ? "…" : "测试" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
API Key<span class="req">*</span>
|
||||
<SettingsHelpTooltip title="API Key 说明">
|
||||
<p>在 Emby 后台「API 密钥」里生成一个,粘贴到这里,用来连接 Emby 和刷库。</p>
|
||||
</SettingsHelpTooltip>
|
||||
</div>
|
||||
<div class="control">
|
||||
<AppInput
|
||||
v-model="embyForm.api_key"
|
||||
placeholder="Emby API Key"
|
||||
autocomplete="off"
|
||||
ignore-autofill
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
反代端口
|
||||
<SettingsHelpTooltip title="反代端口说明">
|
||||
<p>反代用的端口,随便选一个没被占用的数字就行。</p>
|
||||
<p>留空则不启动反代。</p>
|
||||
</SettingsHelpTooltip>
|
||||
</div>
|
||||
<div class="control">
|
||||
<div class="mini">
|
||||
<AppInput
|
||||
v-model="embyForm.proxy_port"
|
||||
type="number"
|
||||
placeholder="留空不启用"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="embyForm.last_error" class="card-error">{{ embyForm.last_error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="card-foot">
|
||||
<AppButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
:disabled="refreshingEmby || !canRefreshEmby"
|
||||
@click="refreshEmby"
|
||||
>
|
||||
{{ refreshingEmby ? "刷库中…" : "刷库" }}
|
||||
</AppButton>
|
||||
<AppButton type="button" variant="primary" :disabled="savingEmby" @click="saveEmby">
|
||||
{{ savingEmby ? "保存中…" : "保存配置" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="proxy-card" :class="{ 'is-on': fnosForm.enabled }">
|
||||
<div class="card-head">
|
||||
<div class="brand-block">
|
||||
<img class="brand-logo" :src="fnosLogo" alt="" width="44" height="44" />
|
||||
<div class="brand-meta">
|
||||
<h2>
|
||||
飞牛影视反代
|
||||
<SettingsHelpTooltip title="飞牛影视反代说明">
|
||||
<p>在播放器和飞牛影视之间加一层:播放 STRM 时,把真实的网盘播放地址直接交给播放器。</p>
|
||||
<p>爆米花 / VidHub / SenPlayer 等连下方的「反代入口」,就能正常播放 STRM 影片。</p>
|
||||
</SettingsHelpTooltip>
|
||||
</h2>
|
||||
<span class="pill" :class="{ live: fnosForm.running }">
|
||||
<span class="dot" />
|
||||
<span v-if="fnosForm.running">运行中 · :{{ fnosForm.proxy_port }}</span>
|
||||
<span v-else-if="fnosForm.enabled">已启用 · 未监听</span>
|
||||
<span v-else>未启用</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="check-toggle"
|
||||
type="button"
|
||||
:class="{ on: fnosForm.enabled }"
|
||||
:aria-label="fnosForm.enabled ? '关闭飞牛反代' : '启用飞牛反代'"
|
||||
@click="fnosForm.enabled = !fnosForm.enabled"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
d="M3.5 8.5 6.5 11.5 12.5 4.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<div class="endpoint-label">
|
||||
反代入口(给客户端)
|
||||
<SettingsHelpTooltip title="反代入口说明">
|
||||
<p>在播放器里添加飞牛服务器时,填这个地址。</p>
|
||||
<p>注意不是下面的「飞牛影视地址」,别填混了。</p>
|
||||
</SettingsHelpTooltip>
|
||||
</div>
|
||||
<div class="endpoint-row">
|
||||
<div class="endpoint-url" :class="{ muted: !fnosForm.running || !fnosEndpoint.startsWith('http') }">
|
||||
{{ fnosEndpoint }}
|
||||
</div>
|
||||
<button
|
||||
class="ghost-btn"
|
||||
type="button"
|
||||
:disabled="!fnosForm.running || !fnosEndpoint.startsWith('http')"
|
||||
@click="copyText(fnosEndpoint)"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="params">
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
飞牛影视地址<span class="req">*</span>
|
||||
<SettingsHelpTooltip title="飞牛影视地址说明">
|
||||
<p>你的飞牛影视地址,端口一般是 8005(不是 NAS 管理页的 5666)。</p>
|
||||
<p>给 LitePan 连飞牛用的,播放器里不要填这个。</p>
|
||||
</SettingsHelpTooltip>
|
||||
</div>
|
||||
<div class="control">
|
||||
<AppInput
|
||||
v-model="fnosForm.fnos_url"
|
||||
placeholder="http://192.168.1.50:8005"
|
||||
autocomplete="off"
|
||||
ignore-autofill
|
||||
/>
|
||||
<AppButton
|
||||
type="button"
|
||||
class="test-btn"
|
||||
variant="secondary"
|
||||
:disabled="testingFnos || !canTestFnos"
|
||||
@click="testFnos"
|
||||
>
|
||||
{{ testingFnos ? "…" : "测试" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
飞牛 STRM 目录
|
||||
<SettingsHelpTooltip title="飞牛 STRM 目录说明">
|
||||
<p>把 Docker 里映射到 <code>/app/strm</code> 的左边路径填到这里。</p>
|
||||
<p>例:<code>/vol1/1000/Strm/LitePanGO:/app/strm</code> → 填 <code>/vol1/1000/Strm/LitePanGO</code>。</p>
|
||||
<p>两边路径相同则可留空。</p>
|
||||
</SettingsHelpTooltip>
|
||||
</div>
|
||||
<div class="control">
|
||||
<AppInput
|
||||
v-model="fnosForm.strm_path_maps"
|
||||
placeholder="/vol1/1000/Strm/LitePanGO"
|
||||
autocomplete="off"
|
||||
ignore-autofill
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
反代端口
|
||||
<SettingsHelpTooltip title="反代端口说明">
|
||||
<p>反代用的端口,随便选一个没被占用的数字就行,别和 Emby 反代用同一个。</p>
|
||||
<p>留空则不启动反代。</p>
|
||||
</SettingsHelpTooltip>
|
||||
</div>
|
||||
<div class="control">
|
||||
<div class="mini">
|
||||
<AppInput
|
||||
v-model="fnosForm.proxy_port"
|
||||
type="number"
|
||||
placeholder="如 18997"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="fnosForm.last_error" class="card-error">{{ fnosForm.last_error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="card-foot">
|
||||
<AppButton type="button" variant="primary" :disabled="savingFnos" @click="saveFnos">
|
||||
{{ savingFnos ? "保存中…" : "保存配置" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.proxy-tools {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.proxy-tools.is-loading {
|
||||
opacity: 0.72;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.proxy-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.proxy-card {
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.proxy-card:hover {
|
||||
box-shadow: var(--shadow-pop);
|
||||
border-color: #dbe4f0;
|
||||
}
|
||||
.proxy-card.is-on {
|
||||
border-color: color-mix(in srgb, var(--brand) 35%, var(--border));
|
||||
}
|
||||
.proxy-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 3px;
|
||||
background: var(--brand-gradient);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 18px 20px 12px;
|
||||
min-height: 72px;
|
||||
}
|
||||
.brand-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.brand-logo {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.brand-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.brand-meta h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 550;
|
||||
line-height: 1;
|
||||
height: 22px;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.pill .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pill.live {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
.pill.live .dot {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.18);
|
||||
}
|
||||
|
||||
.check-toggle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
background: #e2e8f0;
|
||||
color: #94a3b8;
|
||||
transition: background 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
.check-toggle svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.check-toggle:hover {
|
||||
background: #d7dee8;
|
||||
}
|
||||
.check-toggle.on {
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.16);
|
||||
}
|
||||
.check-toggle.on:hover {
|
||||
background: color-mix(in srgb, var(--success) 88%, #000);
|
||||
}
|
||||
|
||||
.endpoint {
|
||||
margin: 0 20px 14px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 12px;
|
||||
background:
|
||||
radial-gradient(120% 80% at 0% 0%, rgba(76, 116, 223, 0.08), transparent 55%),
|
||||
var(--surface-sunken);
|
||||
border: 1px solid var(--border-soft);
|
||||
}
|
||||
.endpoint-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.endpoint-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.endpoint-url {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-regular);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.endpoint-url.muted {
|
||||
color: #94a3b8;
|
||||
}
|
||||
.ghost-btn {
|
||||
appearance: none;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text-regular);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ghost-btn:hover:not(:disabled) {
|
||||
border-color: #cbd5e1;
|
||||
background: #fff;
|
||||
}
|
||||
.ghost-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.params {
|
||||
padding: 2px 20px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
flex: 1;
|
||||
}
|
||||
.field-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-regular);
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
.field-label .req {
|
||||
color: var(--danger);
|
||||
margin-left: 1px;
|
||||
}
|
||||
.control {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.control > :deep(.app-input),
|
||||
.control > :not(.mini) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.control .mini {
|
||||
flex: 0 0 132px;
|
||||
max-width: 140px;
|
||||
}
|
||||
.control .test-btn {
|
||||
flex: 0 0 auto;
|
||||
min-width: 52px;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
.path-maps {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
min-height: 72px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
line-height: 1.5;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.path-maps:focus {
|
||||
outline: none;
|
||||
border-color: #93b4ff;
|
||||
box-shadow: 0 0 0 3px rgba(76, 116, 223, 0.15);
|
||||
}
|
||||
.path-maps::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
.card-error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.card-foot {
|
||||
margin-top: auto;
|
||||
padding: 14px 20px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
background: linear-gradient(180deg, transparent, rgba(248, 250, 252, 0.9));
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.proxy-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.page-head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,346 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from "vue";
|
||||
import { qrPoll, qrStart, type QrStatus } from "@/api/qr";
|
||||
import { toast } from "@/composables/useToast";
|
||||
import AppModal from "@/components/base/AppModal.vue";
|
||||
import BusySpinner from "@/components/base/BusySpinner.vue";
|
||||
|
||||
const props = defineProps<{ open: boolean; driverType: string }>();
|
||||
const emit = defineEmits<{ close: []; success: [credentials: Record<string, string>] }>();
|
||||
|
||||
type Phase = "loading" | "waiting" | "success" | "failed" | "expired" | "error";
|
||||
|
||||
const phase = ref<Phase>("loading");
|
||||
const qrImage = ref("");
|
||||
const message = ref("");
|
||||
const token = ref("");
|
||||
const expiresIn = ref(0);
|
||||
const panelTitle = ref("扫码登录");
|
||||
const hintText = ref("请使用对应网盘 App扫码,成功后授权信息将填入表单");
|
||||
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let errorStreak = 0;
|
||||
|
||||
const expireText = computed(() => {
|
||||
if (phase.value === "expired") return "二维码已过期";
|
||||
if (expiresIn.value <= 0) return "";
|
||||
return `二维码剩余有效时间: ${expiresIn.value} 秒`;
|
||||
});
|
||||
|
||||
function clearPoll() {
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearCountdown() {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdown(seconds: number) {
|
||||
clearCountdown();
|
||||
expiresIn.value = Math.max(0, seconds || 0);
|
||||
if (expiresIn.value <= 0) return;
|
||||
countdownTimer = setInterval(() => {
|
||||
expiresIn.value = Math.max(0, expiresIn.value - 1);
|
||||
if (expiresIn.value <= 0 && phase.value === "waiting") {
|
||||
phase.value = "expired";
|
||||
message.value = "二维码已过期,请重新获取";
|
||||
clearPoll();
|
||||
clearCountdown();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function scheduleNextPoll(delay = 2000) {
|
||||
clearPoll();
|
||||
pollTimer = setTimeout(() => void poll(), delay);
|
||||
}
|
||||
|
||||
async function start() {
|
||||
clearPoll();
|
||||
phase.value = "loading";
|
||||
qrImage.value = "";
|
||||
message.value = "";
|
||||
token.value = "";
|
||||
expiresIn.value = 0;
|
||||
panelTitle.value = "扫码登录";
|
||||
hintText.value = "请使用对应网盘 App扫码,成功后授权信息将填入表单";
|
||||
errorStreak = 0;
|
||||
try {
|
||||
const res = await qrStart(props.driverType);
|
||||
if (!res.success || !res.data?.token || !res.data.qr_image_base64) {
|
||||
throw new Error(res.message || "获取二维码失败");
|
||||
}
|
||||
qrImage.value = res.data.qr_image_base64;
|
||||
token.value = res.data.token;
|
||||
if (res.data.title?.trim()) panelTitle.value = res.data.title.trim();
|
||||
if (res.data.hint?.trim()) hintText.value = res.data.hint.trim();
|
||||
phase.value = "waiting";
|
||||
startCountdown(res.data.expires_in || 300);
|
||||
scheduleNextPoll(2000);
|
||||
} catch (e) {
|
||||
phase.value = "error";
|
||||
message.value = e instanceof Error ? e.message : "获取二维码失败";
|
||||
}
|
||||
}
|
||||
|
||||
const phaseByStatus: Record<QrStatus, Phase> = {
|
||||
waiting: "waiting",
|
||||
success: "success",
|
||||
failed: "failed",
|
||||
expired: "expired",
|
||||
};
|
||||
|
||||
async function poll() {
|
||||
if (!token.value) return;
|
||||
try {
|
||||
const res = await qrPoll(props.driverType, token.value);
|
||||
if (!res.success || !res.data?.status) {
|
||||
throw new Error(res.message || "轮询失败");
|
||||
}
|
||||
errorStreak = 0;
|
||||
const { status, cookie, access_token: accessToken, refresh_token: refreshToken, message: msg } = res.data;
|
||||
const credentials: Record<string, string> = {};
|
||||
if (cookie) credentials.cookie = cookie;
|
||||
if (accessToken) credentials.access_token = accessToken;
|
||||
if (refreshToken) credentials.refresh_token = refreshToken;
|
||||
if (status === "success" && Object.keys(credentials).length > 0) {
|
||||
phase.value = "success";
|
||||
clearPoll();
|
||||
clearCountdown();
|
||||
toast.success("扫码登录成功,授权信息已自动填入表单");
|
||||
emit("success", credentials);
|
||||
return;
|
||||
}
|
||||
if (status === "success") {
|
||||
phase.value = "failed";
|
||||
message.value = msg || "扫码成功但未获取到授权信息,请重试";
|
||||
clearPoll();
|
||||
clearCountdown();
|
||||
return;
|
||||
}
|
||||
if (status === "failed" || status === "expired") {
|
||||
phase.value = phaseByStatus[status];
|
||||
message.value = msg || (status === "expired" ? "二维码已过期" : "扫码登录失败");
|
||||
clearPoll();
|
||||
clearCountdown();
|
||||
return;
|
||||
}
|
||||
scheduleNextPoll(2000);
|
||||
} catch {
|
||||
errorStreak += 1;
|
||||
if (errorStreak >= 5) {
|
||||
phase.value = "error";
|
||||
message.value = "网络异常,请重试";
|
||||
clearPoll();
|
||||
clearCountdown();
|
||||
return;
|
||||
}
|
||||
scheduleNextPoll(3000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
clearPoll();
|
||||
clearCountdown();
|
||||
emit("close");
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open && props.driverType) {
|
||||
void start();
|
||||
} else {
|
||||
clearPoll();
|
||||
clearCountdown();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
clearPoll();
|
||||
clearCountdown();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppModal :open="open" bare @close="handleClose">
|
||||
<div class="qr-panel">
|
||||
<div class="qr-panel-header">
|
||||
<div class="qr-panel-title">
|
||||
<i class="fas fa-qrcode"></i>
|
||||
<span>{{ panelTitle }}</span>
|
||||
</div>
|
||||
<button class="qr-panel-close" type="button" aria-label="关闭" @click="handleClose">×</button>
|
||||
</div>
|
||||
|
||||
<div class="qr-panel-body">
|
||||
<div v-if="phase === 'loading'" class="qr-state qr-state--loading">
|
||||
<BusySpinner :size="28" color="var(--brand)" />
|
||||
<span>正在生成二维码...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="phase === 'waiting' || phase === 'success'" class="qr-state qr-state--waiting">
|
||||
<img class="qr-image" :src="qrImage" alt="扫码二维码" />
|
||||
<div class="qr-hint">{{ hintText }}</div>
|
||||
<div v-if="phase === 'waiting' && expireText" class="qr-countdown">{{ expireText }}</div>
|
||||
<div v-else class="qr-success">已获取授权信息</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="qr-state qr-state--failed">
|
||||
<i class="fas fa-circle-exclamation"></i>
|
||||
<div class="qr-result-title">{{ phase === "expired" ? "二维码已过期" : "扫码登录失败" }}</div>
|
||||
<div class="qr-hint">{{ message || "二维码已失效,请关闭后重新获取" }}</div>
|
||||
<button class="qr-retry" type="button" @click="start">重新获取</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.qr-panel {
|
||||
width: 360px;
|
||||
max-width: 92vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.qr-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
background: var(--panel-head-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-top-left-radius: 16px;
|
||||
border-top-right-radius: 16px;
|
||||
}
|
||||
|
||||
.qr-panel-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.qr-panel-title i {
|
||||
color: var(--brand);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.qr-panel-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.qr-panel-close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.qr-panel-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 320px;
|
||||
padding: 24px 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.qr-image {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.qr-state {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.qr-state--failed {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.qr-state i {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.qr-state--loading i {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.qr-hint {
|
||||
margin-top: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-countdown {
|
||||
margin-top: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-success {
|
||||
color: #16a34a;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.qr-result-title {
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.qr-retry {
|
||||
margin-top: 10px;
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.qr-retry:hover {
|
||||
color: var(--brand);
|
||||
border-color: color-mix(in srgb, var(--brand) 40%, var(--border));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import AppTabBar from "@/components/base/AppTabBar.vue";
|
||||
|
||||
interface Tab {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
defineProps<{ tabs: Tab[]; modelValue: string }>();
|
||||
const emit = defineEmits<{ "update:modelValue": [string] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppTabBar :tabs="tabs" :model-value="modelValue" @update:model-value="emit('update:modelValue', $event)">
|
||||
<template #actions>
|
||||
<slot name="actions" />
|
||||
</template>
|
||||
</AppTabBar>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import "@/styles/settings-panel.css";
|
||||
|
||||
const model = defineModel<boolean>({ default: false });
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label: string;
|
||||
offLabel?: string;
|
||||
onLabel?: string;
|
||||
}>(),
|
||||
{ offLabel: "关闭", onLabel: "开启" },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-segment" role="radiogroup" :aria-label="label">
|
||||
<button
|
||||
type="button"
|
||||
class="settings-segment__btn"
|
||||
:class="{ 'settings-segment__btn--active': model }"
|
||||
role="radio"
|
||||
:aria-checked="model"
|
||||
@click="model = true"
|
||||
>
|
||||
{{ onLabel }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-segment__btn"
|
||||
:class="{ 'settings-segment__btn--active': !model }"
|
||||
role="radio"
|
||||
:aria-checked="!model"
|
||||
@click="model = false"
|
||||
>
|
||||
{{ offLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import "@/styles/settings-panel.css";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title: string;
|
||||
accent?: string;
|
||||
}>(),
|
||||
{ accent: "var(--brand)" },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="settings-card" :style="{ '--accent': accent }">
|
||||
<div class="settings-card__head">
|
||||
<div class="settings-card__head-main">
|
||||
<h3 class="settings-card__title">{{ title }}</h3>
|
||||
<div v-if="$slots['head-aside']" class="settings-card__head-aside">
|
||||
<slot name="head-aside" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots['head-actions']" class="settings-card__head-actions">
|
||||
<slot name="head-actions" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-card__body">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string;
|
||||
hint?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button type="button" class="settings-entry-card">
|
||||
<div class="settings-entry-card__icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" focusable="false">
|
||||
<path
|
||||
d="M12 8.2a3.8 3.8 0 1 1 0 7.6 3.8 3.8 0 0 1 0-7.6Zm0 1.8a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z"
|
||||
/>
|
||||
<path
|
||||
d="M13.1 2.75c.5 0 .92.35 1.02.84l.32 1.56c.38.14.74.3 1.08.5l1.36-.86c.42-.27.98-.2 1.34.16l.83.83c.36.36.43.92.16 1.34l-.86 1.36c.2.34.36.7.5 1.08l1.56.32c.49.1.84.52.84 1.02v1.2c0 .5-.35.92-.84 1.02l-1.56.32c-.14.38-.3.74-.5 1.08l.86 1.36c.27.42.2.98-.16 1.34l-.83.83c-.36.36-.92.43-1.34.16l-1.36-.86c-.34.2-.7.36-1.08.5l-.32 1.56c-.1.49-.52.84-1.02.84h-1.2c-.5 0-.92-.35-1.02-.84l-.32-1.56c-.38-.14-.74-.3-1.08-.5l-1.36.86c-.42.27-.98.2-1.34-.16l-.83-.83c-.36-.36-.43-.92-.16-1.34l.86-1.36c-.2-.34-.36-.7-.5-1.08l-1.56-.32a1.05 1.05 0 0 1-.84-1.02v-1.2c0-.5.35-.92.84-1.02l1.56-.32c.14-.38.3-.74.5-1.08l-.86-1.36c-.27-.42-.2-.98.16-1.34l.83-.83c.36-.36.92-.43 1.34-.16l1.36.86c.34-.2.7-.36 1.08-.5l.32-1.56c.1-.49.52-.84 1.02-.84h1.2Zm-.5 1.8h-.2l-.36 1.72a.9.9 0 0 1-.63.68 5.62 5.62 0 0 0-1.38.57.9.9 0 0 1-.93-.04l-1.5-.95-.14.14.95 1.5c.18.29.18.65.04.93-.24.43-.43.9-.57 1.38a.9.9 0 0 1-.68.63l-1.72.36v.2l1.72.36c.33.07.6.31.68.63.14.48.33.95.57 1.38.16.28.14.64-.04.93l-.95 1.5.14.14 1.5-.95a.9.9 0 0 1 .93-.04c.43.24.9.43 1.38.57.32.09.56.35.63.68l.36 1.72h.2l.36-1.72c.07-.33.31-.6.63-.68.48-.14.95-.33 1.38-.57a.9.9 0 0 1 .93.04l1.5.95.14-.14-.95-1.5a.9.9 0 0 1-.04-.93c.24-.43.43-.9.57-1.38.09-.32.35-.56.68-.63l1.72-.36v-.2l-1.72-.36a.9.9 0 0 1-.68-.63 5.62 5.62 0 0 0-.57-1.38.9.9 0 0 1 .04-.93l.95-1.5-.14-.14-1.5.95a.9.9 0 0 1-.93.04 5.62 5.62 0 0 0-1.38-.57.9.9 0 0 1-.63-.68l-.36-1.72Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="settings-entry-card__main">
|
||||
<div class="settings-entry-card__title">{{ title }}</div>
|
||||
<div class="settings-entry-card__hint">
|
||||
<span class="settings-entry-card__hint-action">点击打开设置 ></span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-entry-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: 108px;
|
||||
padding: 18px 18px 18px 20px;
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid rgba(99, 121, 168, 0.16);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 255, 0.94)),
|
||||
var(--surface);
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.settings-entry-card:hover {
|
||||
border-color: rgba(99, 121, 168, 0.16);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 1), rgba(238, 246, 255, 0.98)),
|
||||
var(--surface);
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.settings-entry-card:focus-visible {
|
||||
outline: 3px solid rgba(76, 116, 223, 0.18);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.settings-entry-card__icon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: rgba(244, 248, 255, 0.92);
|
||||
border: 1px solid rgba(76, 116, 223, 0.18);
|
||||
color: var(--brand, #4c74df);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-entry-card__icon svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: block;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.settings-entry-card__main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.settings-entry-card__title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.settings-entry-card__hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-entry-card__hint-action {
|
||||
color: var(--brand, #4c74df);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(:root[data-theme="dark"] .settings-entry-card) {
|
||||
border-color: rgba(115, 135, 174, 0.24);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(30, 35, 43, 0.96), rgba(24, 29, 38, 0.94)),
|
||||
var(--surface);
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
:global(:root[data-theme="dark"] .settings-entry-card:hover) {
|
||||
border-color: rgba(115, 135, 174, 0.24);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(33, 39, 49, 0.98), rgba(25, 34, 47, 0.96)),
|
||||
var(--surface);
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
:global(:root[data-theme="dark"] .settings-entry-card__icon) {
|
||||
background: color-mix(in srgb, var(--brand) 10%, var(--surface-muted));
|
||||
border-color: color-mix(in srgb, var(--brand) 24%, var(--border));
|
||||
color: color-mix(in srgb, var(--brand) 72%, #ffffff);
|
||||
}
|
||||
|
||||
:global(:root[data-theme="dark"] .settings-entry-card__hint-action) {
|
||||
color: color-mix(in srgb, var(--brand) 72%, #ffffff);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.settings-entry-card {
|
||||
min-height: 96px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import type { SettingItem } from "@/api/settings";
|
||||
import AppInput from "@/components/base/AppInput.vue";
|
||||
import AppSelect from "@/components/base/AppSelect.vue";
|
||||
import SettingsBoolSegment from "@/components/admin/SettingsBoolSegment.vue";
|
||||
import SettingsRow from "@/components/admin/SettingsRow.vue";
|
||||
import SettingsRowLabel from "@/components/admin/SettingsRowLabel.vue";
|
||||
import "@/styles/settings-panel.css";
|
||||
|
||||
const props = defineProps<{
|
||||
item: SettingItem;
|
||||
modelValue: string;
|
||||
changed?: boolean;
|
||||
label?: string;
|
||||
helpTitle?: string;
|
||||
helpText?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ "update:modelValue": [string] }>();
|
||||
|
||||
function displayLabel(): string {
|
||||
const base = props.label ?? props.item.label;
|
||||
if (props.item.type === "int" && props.item.unit) return `${base}(${props.item.unit})`;
|
||||
return base;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsRow :changed="changed" :show-changed-badge="false">
|
||||
<template #info>
|
||||
<SettingsRowLabel
|
||||
:label="displayLabel()"
|
||||
:changed="changed"
|
||||
:help-title="helpTitle ?? (item.description ? `${displayLabel()}说明` : undefined)"
|
||||
:help-text="helpText ?? item.description"
|
||||
>
|
||||
<template v-if="$slots.help" #help>
|
||||
<slot name="help" />
|
||||
</template>
|
||||
</SettingsRowLabel>
|
||||
</template>
|
||||
<template #control>
|
||||
<div class="settings-row__field">
|
||||
<SettingsBoolSegment
|
||||
v-if="item.type === 'bool'"
|
||||
:model-value="modelValue === 'true'"
|
||||
:label="displayLabel()"
|
||||
@update:model-value="emit('update:modelValue', $event ? 'true' : 'false')"
|
||||
/>
|
||||
<div v-else-if="item.type === 'select'" class="field-select">
|
||||
<AppSelect
|
||||
:model-value="modelValue"
|
||||
:options="item.options || []"
|
||||
@update:model-value="emit('update:modelValue', String($event))"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="item.type === 'int'" class="field-num">
|
||||
<AppInput
|
||||
:model-value="modelValue"
|
||||
type="number"
|
||||
:placeholder="item.default"
|
||||
@update:model-value="emit('update:modelValue', String($event))"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="field-text">
|
||||
<AppInput
|
||||
:model-value="modelValue"
|
||||
:placeholder="item.default"
|
||||
autocomplete="off"
|
||||
@update:model-value="emit('update:modelValue', String($event))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref } from "vue";
|
||||
import "@/styles/settings-panel.css";
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
}>();
|
||||
|
||||
const visible = ref(false);
|
||||
const anchor = ref<HTMLElement | null>(null);
|
||||
const popoverStyle = ref<Record<string, string>>({});
|
||||
|
||||
async function show() {
|
||||
visible.value = true;
|
||||
await nextTick();
|
||||
updatePosition();
|
||||
}
|
||||
|
||||
function hide() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
function updatePosition() {
|
||||
const el = anchor.value;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const narrow = window.innerWidth <= 640;
|
||||
if (narrow) {
|
||||
popoverStyle.value = {
|
||||
top: `${rect.bottom + 10}px`,
|
||||
left: `${Math.max(12, rect.left)}px`,
|
||||
transform: "none",
|
||||
};
|
||||
return;
|
||||
}
|
||||
popoverStyle.value = {
|
||||
top: `${rect.top + rect.height / 2}px`,
|
||||
left: `${rect.right + 12}px`,
|
||||
transform: "translateY(-50%)",
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
ref="anchor"
|
||||
class="settings-help"
|
||||
@mouseenter="show"
|
||||
@mouseleave="hide"
|
||||
>
|
||||
<i class="fas fa-question-circle settings-help__icon" aria-hidden="true" />
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-show="visible"
|
||||
class="settings-help__popover settings-help__popover--portal"
|
||||
:style="popoverStyle"
|
||||
role="tooltip"
|
||||
>
|
||||
<div class="settings-help__title">{{ title }}</div>
|
||||
<div class="settings-help__body">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import "@/styles/settings-panel.css";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
changed?: boolean;
|
||||
showChangedBadge?: boolean;
|
||||
}>(),
|
||||
{ showChangedBadge: false },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-row" :class="{ 'settings-row--changed': changed }">
|
||||
<div class="settings-row__info">
|
||||
<slot name="info" />
|
||||
<span v-if="changed && showChangedBadge" class="settings-row__badge">已修改</span>
|
||||
</div>
|
||||
<div v-if="$slots.control" class="settings-row__control">
|
||||
<slot name="control" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import SettingsHelpTooltip from "@/components/admin/SettingsHelpTooltip.vue";
|
||||
import "@/styles/settings-panel.css";
|
||||
|
||||
defineProps<{
|
||||
label: string;
|
||||
changed?: boolean;
|
||||
helpTitle?: string;
|
||||
helpText?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-row__label">
|
||||
<span>{{ label }}</span>
|
||||
<span v-if="changed" class="settings-row__badge">已修改</span>
|
||||
<SettingsHelpTooltip v-if="helpTitle && ($slots.help || helpText)" :title="helpTitle">
|
||||
<slot name="help">
|
||||
<p v-if="helpText">{{ helpText }}</p>
|
||||
</slot>
|
||||
</SettingsHelpTooltip>
|
||||
<slot v-else name="help" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import "@/styles/settings-panel.css";
|
||||
|
||||
export interface SegmentOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const model = defineModel<string>({ required: true });
|
||||
|
||||
defineProps<{
|
||||
label: string;
|
||||
options: SegmentOption[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-segment" role="radiogroup" :aria-label="label">
|
||||
<button
|
||||
v-for="opt in options"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
class="settings-segment__btn"
|
||||
:class="{ 'settings-segment__btn--active': model === opt.value }"
|
||||
role="radio"
|
||||
:aria-checked="model === opt.value"
|
||||
@click="model = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||