feat(admin): add tools panel and video archive controls
This commit is contained in:
parent
83f7f3d6d1
commit
7686999c79
@ -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>
|
||||
|
||||
@ -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) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user