新版首次提交

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
KingandCursor committed 2026-07-27 19:31:43 +08:00
commit a69bb6e3c3
931 files changed
+139202

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
*.local
.DS_Store
*.tsbuildinfo
+7
View File
@@ -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;
}
+13
View File
@@ -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>
+2615
View File
File diff suppressed because it is too large. Load diff
+44
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+177
View File
@@ -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.
+21
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+56
View File
@@ -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`);
+11
View File
@@ -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>
+12
View File
@@ -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`),
};
+74
View File
@@ -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,
});
}
+95
View File
@@ -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);
}
+156
View File
@@ -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");
}
+19
View File
@@ -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", {});
}
+124
View File
@@ -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`, {});
}
+137
View File
@@ -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/") }),
};
+146
View File
@@ -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,
});
}
+6
View File
@@ -0,0 +1,6 @@
import { http } from "./client";
import type { DriverInfo } from "./types";
export const driversApi = {
list: () => http.get<DriverInfo[]>("/admin/drivers"),
};
+34
View File
@@ -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");
}
+256
View File
@@ -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);
},
};
+31
View File
@@ -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);
}
+85
View File
@@ -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`);
}
+73
View File
@@ -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"),
};
}
+221
View File
@@ -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);
}
+111
View File
@@ -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 "刮削";
}
+38
View File
@@ -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}`);
}
+90
View File
@@ -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,
});
},
};
+18
View File
@@ -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"),
};
+37
View File
@@ -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 });
}
+43
View File
@@ -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);
}
+276
View File
@@ -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,
});
}
+113
View File
@@ -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);
}
+173
View File
@@ -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;
}
+96
View File
@@ -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);
});
},
};
+1
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

+500
View File
@@ -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>
+228
View File
@@ -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>
+19
View File
@@ -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>
+529
View File
@@ -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>
+583
View File
@@ -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>
File diff suppressed because it is too large. Load diff
@@ -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>
File diff suppressed because it is too large. Load diff
@@ -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>
File diff suppressed because it is too large. Load diff
@@ -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>
File diff suppressed because it is too large. Load diff
@@ -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>
File diff suppressed because it is too large. Load diff
@@ -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>
+346
View File
@@ -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>
+30
View File
@@ -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">点击打开设置 &gt;</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>
+23
View File
@@ -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>
File diff suppressed because it is too large. Load diff
Loaded 100 of 931 files, more files were not shown because too many files have changed in this diff. Show more