feat(admin): add tools panel and video archive controls
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-12 11:32:28 +08:00
parent 83f7f3d6d1
commit 7686999c79
2 changed files with 240 additions and 4 deletions

View File

@ -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<ImageAnalytics>(analyticsUrl, fetcher);
const archiveStatusUrl = activeTab === "tools" ? "/api/jobs/archive-videos" : null;
const {
data: archiveStatusData,
mutate: mutateArchiveStatus,
isLoading: isArchiveStatusLoading,
} = useSWR<VideoArchiveStatus>(archiveStatusUrl, fetcher);
// 网站设置状态
const [siteSettings, setSiteSettings] = useState<SiteSettings>({
@ -387,6 +423,9 @@ export function AdminSettingsClient() {
canvasDefaultModelId: null,
canvasAutoModeDefaultModelId: null,
});
const [savingToolName, setSavingToolName] = useState<string | null>(null);
const [isRunningArchiveJob, setIsRunningArchiveJob] = useState(false);
const [archiveJobResult, setArchiveJobResult] = useState<VideoArchiveRunResult | null>(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 (
<div className="container mx-auto p-6">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-11">
<TabsList className="grid w-full grid-cols-12">
<TabsTrigger value="users">
<User className="mr-2 h-4 w-4" />
用户管理
@ -1519,6 +1617,10 @@ export function AdminSettingsClient() {
<Bell className="mr-2 h-4 w-4" />
系统通知
</TabsTrigger>
<TabsTrigger value="tools">
<Wrench className="mr-2 h-4 w-4" />
工具与任务
</TabsTrigger>
<TabsTrigger value="ai">
<Bot className="mr-2 h-4 w-4" />
AI 模型
@ -3579,6 +3681,122 @@ export function AdminSettingsClient() {
</Card>
</TabsContent>
{/* 工具与任务 */}
<TabsContent value="tools" className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle>AI 工具开关</CardTitle>
<CardDescription>控制聊天工具是否可用,未配置默认启用。</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{AVAILABLE_TOOLS.map((tool) => {
const currentConfig = getToolConfig(tool.name);
const checked = isToolEnabled(tool.name);
const isSaving = savingToolName === tool.name;
return (
<div
key={tool.name}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="space-y-1">
<p className="font-medium">{tool.name}</p>
<p className="text-sm text-muted-foreground">{tool.description}</p>
{currentConfig?.updatedAt ? (
<p className="text-xs text-muted-foreground">
最近更新:{new Date(currentConfig.updatedAt).toLocaleString("zh-CN")}
</p>
) : null}
</div>
<div className="flex items-center gap-3">
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : null}
<Switch
checked={checked}
disabled={isSaving}
onCheckedChange={(value) =>
handleToggleTool(tool.name, value, tool.description)
}
/>
</div>
</div>
);
})}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>视频归档任务</CardTitle>
<CardDescription>可手动查看待归档视频并触发归档。</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<p className="font-medium">待归档视频</p>
<p className="text-sm text-muted-foreground">
{isArchiveStatusLoading
? "加载中..."
: `${archiveStatusData?.pendingCount ?? 0} 条待处理`}
</p>
{archiveStatusData?.timestamp ? (
<p className="text-xs text-muted-foreground">
状态时间:{new Date(archiveStatusData.timestamp).toLocaleString("zh-CN")}
</p>
) : null}
</div>
<Button
variant="outline"
size="sm"
onClick={() => mutateArchiveStatus()}
disabled={isArchiveStatusLoading}
>
<RefreshCw className="mr-2 h-4 w-4" />
刷新状态
</Button>
</div>
<div className="flex items-center gap-3">
<Button
onClick={handleRunArchiveJob}
disabled={isRunningArchiveJob}
>
{isRunningArchiveJob ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Video className="mr-2 h-4 w-4" />
)}
立即执行归档
</Button>
{archiveJobResult?.success ? (
<span className="inline-flex items-center text-sm text-green-600">
<Check className="mr-1 h-4 w-4" />
{archiveJobResult.message || "执行完成"}
</span>
) : null}
</div>
{archiveJobResult?.stats ? (
<div className="grid grid-cols-3 gap-3 rounded-lg border p-4 text-sm">
<div>
<p className="text-muted-foreground">总任务</p>
<p className="text-lg font-semibold">{archiveJobResult.stats.total}</p>
</div>
<div>
<p className="text-muted-foreground">成功</p>
<p className="text-lg font-semibold text-green-600">{archiveJobResult.stats.succeeded}</p>
</div>
<div>
<p className="text-muted-foreground">失败</p>
<p className="text-lg font-semibold text-red-600">{archiveJobResult.stats.failed}</p>
</div>
</div>
) : null}
</CardContent>
</Card>
</TabsContent>
{/* AI 模型配置 */}
<TabsContent value="ai" className="mt-6 space-y-6">
<Card>

View File

@ -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<boolean> {
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) {
);
}
}