From 7686999c79ff4782717594b8c6e052e9fd2b76e9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 12 Feb 2026 11:32:28 +0800 Subject: [PATCH] feat(admin): add tools panel and video archive controls --- .../settings/admin-settings-client.tsx | 220 +++++++++++++++++- app/api/jobs/archive-videos/route.ts | 24 +- 2 files changed, 240 insertions(+), 4 deletions(-) diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 8fddac0..5512337 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -93,6 +93,31 @@ type ToolConfig = { isEnabled: boolean; description?: string; config?: any; + updatedAt?: string; + updatedBy?: string; +}; + +type VideoArchiveStatus = { + pendingCount: number; + videos: Array<{ + id: string; + createdAt: string; + temporaryVideoUrl: string; + }>; + timestamp: string; + error?: string; +}; + +type VideoArchiveRunResult = { + success: boolean; + message?: string; + stats?: { + total: number; + succeeded: number; + failed: number; + }; + error?: string; + timestamp: string; }; type AiProvider = { @@ -270,6 +295,7 @@ const AVAILABLE_TOOLS = [ { name: "createDocument", description: "创建文档" }, { name: "updateDocument", description: "更新文档" }, { name: "requestSuggestions", description: "请求文档建议" }, + { name: "generateVideo", description: "生成视频任务" }, ]; const PROVIDER_TYPES = [ @@ -331,6 +357,10 @@ export function AdminSettingsClient() { "/api/admin/users/batch-limit", fetcher ); + const { data: toolConfigsData, mutate: mutateToolConfigs } = useSWR<{ configs: ToolConfig[] }>( + "/api/admin/tools", + fetcher + ); const { data: providersData, mutate: mutateProviders } = useSWR<{ providers: AiProvider[] }>("/api/admin/ai-providers", fetcher); const { data: modelsData, mutate: mutateModels } = useSWR<{ models: AiModel[] }>("/api/admin/ai-models", fetcher); const { data: storageData, mutate: mutateStorage } = useSWR<{ configs: StorageConfig[] }>("/api/admin/storage", fetcher); @@ -360,6 +390,12 @@ export function AdminSettingsClient() { ? `/api/admin/images/analytics?month=${selectedMonth}${selectedDate ? `&date=${selectedDate}` : ""}` : null; const { data: analyticsData } = useSWR(analyticsUrl, fetcher); + const archiveStatusUrl = activeTab === "tools" ? "/api/jobs/archive-videos" : null; + const { + data: archiveStatusData, + mutate: mutateArchiveStatus, + isLoading: isArchiveStatusLoading, + } = useSWR(archiveStatusUrl, fetcher); // 网站设置状态 const [siteSettings, setSiteSettings] = useState({ @@ -387,6 +423,9 @@ export function AdminSettingsClient() { canvasDefaultModelId: null, canvasAutoModeDefaultModelId: null, }); + const [savingToolName, setSavingToolName] = useState(null); + const [isRunningArchiveJob, setIsRunningArchiveJob] = useState(false); + const [archiveJobResult, setArchiveJobResult] = useState(null); useEffect(() => { if (siteSettingsData) { @@ -1483,10 +1522,69 @@ export function AdminSettingsClient() { } }; + const getToolConfig = (toolName: string) => + toolConfigsData?.configs?.find((tool) => tool.toolName === toolName); + + const isToolEnabled = (toolName: string) => { + const config = getToolConfig(toolName); + return config ? config.isEnabled : true; + }; + + const handleToggleTool = async ( + toolName: string, + isEnabled: boolean, + description?: string + ) => { + setSavingToolName(toolName); + try { + const response = await fetch("/api/admin/tools", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + toolName, + isEnabled, + description, + config: getToolConfig(toolName)?.config ?? null, + }), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data?.error || "更新工具状态失败"); + } + await mutateToolConfigs(); + toast.success(`${toolName} 已${isEnabled ? "启用" : "禁用"}`); + } catch (error) { + toast.error(error instanceof Error ? error.message : "更新工具状态失败"); + } finally { + setSavingToolName(null); + } + }; + + const handleRunArchiveJob = async () => { + setIsRunningArchiveJob(true); + setArchiveJobResult(null); + try { + const response = await fetch("/api/jobs/archive-videos", { + method: "POST", + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data?.error || "执行归档任务失败"); + } + setArchiveJobResult(data); + await mutateArchiveStatus(); + toast.success(data?.message || "归档任务执行成功"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "执行归档任务失败"); + } finally { + setIsRunningArchiveJob(false); + } + }; + return (
- + 用户管理 @@ -1519,6 +1617,10 @@ export function AdminSettingsClient() { 系统通知 + + + 工具与任务 + AI 模型 @@ -3579,6 +3681,122 @@ export function AdminSettingsClient() { + {/* 工具与任务 */} + + + + AI 工具开关 + 控制聊天工具是否可用,未配置默认启用。 + + + {AVAILABLE_TOOLS.map((tool) => { + const currentConfig = getToolConfig(tool.name); + const checked = isToolEnabled(tool.name); + const isSaving = savingToolName === tool.name; + + return ( +
+
+

{tool.name}

+

{tool.description}

+ {currentConfig?.updatedAt ? ( +

+ 最近更新:{new Date(currentConfig.updatedAt).toLocaleString("zh-CN")} +

+ ) : null} +
+
+ {isSaving ? ( + + ) : null} + + handleToggleTool(tool.name, value, tool.description) + } + /> +
+
+ ); + })} +
+
+ + + + 视频归档任务 + 可手动查看待归档视频并触发归档。 + + +
+
+

待归档视频

+

+ {isArchiveStatusLoading + ? "加载中..." + : `${archiveStatusData?.pendingCount ?? 0} 条待处理`} +

+ {archiveStatusData?.timestamp ? ( +

+ 状态时间:{new Date(archiveStatusData.timestamp).toLocaleString("zh-CN")} +

+ ) : null} +
+ +
+ +
+ + {archiveJobResult?.success ? ( + + + {archiveJobResult.message || "执行完成"} + + ) : null} +
+ + {archiveJobResult?.stats ? ( +
+
+

总任务

+

{archiveJobResult.stats.total}

+
+
+

成功

+

{archiveJobResult.stats.succeeded}

+
+
+

失败

+

{archiveJobResult.stats.failed}

+
+
+ ) : null} +
+
+
+ {/* AI 模型配置 */} diff --git a/app/api/jobs/archive-videos/route.ts b/app/api/jobs/archive-videos/route.ts index 120fbe0..cce535d 100644 --- a/app/api/jobs/archive-videos/route.ts +++ b/app/api/jobs/archive-videos/route.ts @@ -11,6 +11,7 @@ import { NextResponse } from "next/server"; import { runVideoArchiveJob } from "@/lib/jobs/video-archive-job"; import { getVideosToArchive } from "@/lib/jobs/video-archive-job"; +import { requireAdmin } from "@/lib/middleware/admin"; /** * 验证请求授权(防止未授权调用) @@ -31,6 +32,24 @@ function verifyAuthToken(request: Request): boolean { return token === cronSecret; } +/** + * 支持两种鉴权方式: + * 1. Cron Bearer Token(自动任务) + * 2. 管理员登录态(后台手动触发) + */ +async function isAuthorized(request: Request): Promise { + if (verifyAuthToken(request)) { + return true; + } + + try { + await requireAdmin(); + return true; + } catch { + return false; + } +} + /** * POST /api/jobs/archive-videos * 执行视频归档任务 @@ -38,7 +57,7 @@ function verifyAuthToken(request: Request): boolean { export async function POST(request: Request) { try { // 1. 验证授权 - if (!verifyAuthToken(request)) { + if (!(await isAuthorized(request))) { return NextResponse.json( { error: "Unauthorized" }, { status: 401 } @@ -80,7 +99,7 @@ export async function POST(request: Request) { export async function GET(request: Request) { try { // 验证授权 - if (!verifyAuthToken(request)) { + if (!(await isAuthorized(request))) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } @@ -109,4 +128,3 @@ export async function GET(request: Request) { ); } } -