feat(canvas): 画布能力 - 完善 LibTV 导入与资源预览

This commit is contained in:
ddCat
2026-08-19 07:16:07 +08:00
parent 4b9d510129
commit d2a089f1ec
36 changed files with 1722 additions and 68 deletions

View File

@@ -8,6 +8,7 @@ import {
HardDrive,
Home,
Infinity as InfinityIcon,
KeyRound,
Layers3,
Mail,
MessageSquareText,
@@ -72,6 +73,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
{ path: "/admin/settings/email", label: "邮件服务", description: "注册验证码 SMTP", icon: <Mail className="size-4" /> },
{ path: "/admin/settings/storage", label: "存储服务", description: "对象存储与资源存储", icon: <HardDrive className="size-4" /> },
{ path: "/admin/settings/response-interception", label: "模型响应拦截", description: "替换用户可见的上游异常", icon: <ShieldAlert className="size-4" /> },
{ path: "/admin/settings/third-party", label: "第三方参数配置", description: "集中维护第三方平台凭证", icon: <KeyRound className="size-4" /> },
],
},
];

View File

@@ -0,0 +1,143 @@
import { App, Button, Input, Popconfirm, Switch } from "antd";
import { KeyRound, Save, Trash2, Wifi } from "lucide-react";
import { useEffect, useState } from "react";
import { getAdminLibTVSetting, testAdminLibTV, updateAdminLibTVSetting, type AdminLibTVSetting } from "@/services/api/libtv";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminStatusBadge, SettingsSectionCard } from "../components/admin-ui";
export default function LibTVSettingsPage() {
const { message } = App.useApp();
const [setting, setSetting] = useState<AdminLibTVSetting>({ enabled: false, hasToken: false });
const [enabled, setEnabled] = useState(false);
const [token, setToken] = useState("");
const [testUuid, setTestUuid] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [clearing, setClearing] = useState(false);
const [testing, setTesting] = useState(false);
useEffect(() => {
let cancelled = false;
void getAdminLibTVSetting()
.then(({ setting: next }) => {
if (cancelled) return;
setSetting(next);
setEnabled(next.enabled);
})
.catch((error) => {
if (!cancelled) message.error(error instanceof Error ? error.message : "读取 LibTV 配置失败");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [message]);
const save = async () => {
setSaving(true);
try {
const { setting: next } = await updateAdminLibTVSetting({ enabled, token: token.trim() || undefined });
setSetting(next);
setToken("");
setEnabled(next.enabled);
message.success("LibTV 配置已更新");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存 LibTV 配置失败");
} finally {
setSaving(false);
}
};
const clearToken = async () => {
setClearing(true);
try {
const { setting: next } = await updateAdminLibTVSetting({ enabled: false, clearToken: true });
setSetting(next);
setToken("");
setEnabled(false);
message.success("LibTV Token 已清空,导入功能已停用");
} catch (error) {
message.error(error instanceof Error ? error.message : "清空 LibTV Token 失败");
} finally {
setClearing(false);
}
};
const test = async () => {
if (!testUuid.trim()) {
message.error("请填写用于测试的 LibTV 画布 UUID");
return;
}
setTesting(true);
try {
await testAdminLibTV(testUuid.trim());
message.success("LibTV 连接测试成功");
} catch (error) {
message.error(error instanceof Error ? error.message : "LibTV 连接测试失败");
} finally {
setTesting(false);
}
};
return (
<AdminPageFrame title="第三方参数配置" description="集中维护外部平台的服务端凭证和连接状态,后续平台可在此继续扩展。">
<div className="pt-4">
<SettingsSectionCard
icon={<KeyRound className="size-4" />}
title="LibTV 配置"
description="Token 仅保存在服务端加密配置中,不会回传到浏览器。"
status={<AdminStatusBadge label={setting.hasToken ? "已配置" : "未配置"} tone={setting.hasToken ? "success" : "warning"} />}
footer={
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="text-xs text-foreground/50">{setting.hasToken ? "已保存 Token;输入新值可替换,留空保存会保留现有 Token。" : "请先配置 Token,再启用导入功能。"}</span>
<div className="flex items-center gap-2">
{setting.hasToken ? (
<Popconfirm title="确认清空 LibTV Token?" description="清空后将同时停用 LibTV 画布导入。" okText="清空" cancelText="取消" okButtonProps={{ danger: true }} onConfirm={clearToken}>
<Button danger icon={<Trash2 className="size-4" />} loading={clearing} disabled={loading || saving}>
清空 Token
</Button>
</Popconfirm>
) : null}
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={loading || clearing} onClick={() => void save()}>
保存配置
</Button>
</div>
</div>
}
>
<div className="space-y-5 px-4 py-4">
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
<div>
<label className="mb-2 block text-sm font-medium">LibTV Token</label>
<Input.Password value={token} onChange={(event) => setToken(event.target.value)} placeholder={setting.hasToken ? "已配置(输入新 Token 可替换)" : "输入 LibTV Token"} disabled={loading || saving || clearing} autoComplete="new-password" />
</div>
<div>
<label className="mb-2 block text-sm font-medium">启用导入</label>
<div className="flex h-8 items-center gap-3">
<Switch checked={enabled} onChange={setEnabled} disabled={loading || saving || clearing} />
<span className="text-sm text-foreground/60">{enabled ? "已启用" : "已停用"}</span>
</div>
</div>
</div>
<div>
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<Wifi className="size-4" />
测试连接
</div>
<div className="flex flex-wrap gap-3">
<Input className="min-w-64 flex-1" value={testUuid} onChange={(event) => setTestUuid(event.target.value)} placeholder="输入可访问的 LibTV 画布 UUID" />
<Button loading={testing} onClick={() => void test()}>
测试连接
</Button>
</div>
<div className="mt-2 text-xs text-foreground/50">用于验证当前 Token 是否可读取指定 LibTV 画布,不会向当前画布写入节点。</div>
</div>
</div>
</SettingsSectionCard>
</div>
</AdminPageFrame>
);
}