Files
ttstd-infinite-canvas/web/src/pages/admin/settings/libtv-settings-page.tsx
ddCat e345a1b3b3 refactor(admin): 管理后台 UI - 独立设计系统并与用户端隔离
把后台 token、Ant 主题、壳层控件和抽屉弹窗从用户工作台拆开,去掉树状侧栏装饰线,色板仍用原来的中性黑白台阶。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 21:29:35 +08:00

517 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { App, Button, Input, Skeleton } from "antd";
import { Switch } from "@/pages/admin/ui/controls";
import { AlertTriangle, BadgeCheck, CircleCheck, CloudDownload, KeyRound, RefreshCw, RotateCcw, Save, Server, ShieldCheck, Trash2, Wifi } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useBlocker } from "react-router";
import { cn } from "@/lib/utils";
import { getAdminLibTVSetting, testAdminLibTV, updateAdminLibTVSetting, type AdminLibTVSetting } from "@/services/api/libtv";
import { useAppearanceStore } from "@/stores/use-appearance-store";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "../components/admin-ui";
type ConnectionTestStatus = "idle" | "testing" | "success" | "error";
export default function LibTVSettingsPage() {
const { message, modal } = App.useApp();
const brandName = useAppearanceStore((state) => state.appearance.brandName);
const [setting, setSetting] = useState<AdminLibTVSetting | null>(null);
const [enabled, setEnabled] = useState(false);
const [token, setToken] = useState("");
const [clearTokenDraft, setClearTokenDraft] = useState(false);
const [testUuid, setTestUuid] = useState("");
const [testStatus, setTestStatus] = useState<ConnectionTestStatus>("idle");
const [testError, setTestError] = useState("");
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [saving, setSaving] = useState(false);
const [loadError, setLoadError] = useState("");
const [saveError, setSaveError] = useState("");
const requestVersionRef = useRef(0);
const navigationConfirmOpenRef = useRef(false);
const navigationTriggerRef = useRef<HTMLElement | null>(null);
const draftHasToken = Boolean(token.trim()) || Boolean(setting?.hasToken && !clearTokenDraft);
const dirty = Boolean(setting && (enabled !== setting.enabled || Boolean(token.trim()) || clearTokenDraft));
const enabledDirty = Boolean(setting && enabled !== setting.enabled);
const credentialDraftLabel = token.trim() ? (setting?.hasToken ? "待替换" : "待新增") : clearTokenDraft ? "待清除" : setting?.hasToken ? "已配置" : "未配置";
const load = useCallback(
async (initial = false, announce = false) => {
const requestVersion = ++requestVersionRef.current;
if (initial) setLoading(true);
else setRefreshing(true);
setLoadError("");
try {
const result = await getAdminLibTVSetting();
if (requestVersion !== requestVersionRef.current) return;
if (!isAdminLibTVSetting(result.setting)) throw new Error("服务端返回的 LibTV 配置格式无效");
setSetting(result.setting);
setEnabled(result.setting.enabled);
setToken("");
setClearTokenDraft(false);
setSaveError("");
setTestStatus("idle");
setTestError("");
if (announce) message.success("已重新读取第三方参数配置");
} catch (error) {
if (requestVersion !== requestVersionRef.current) return;
const errorMessage = error instanceof Error ? error.message : "读取第三方参数配置失败";
setLoadError(errorMessage);
if (!initial) message.error(errorMessage);
} finally {
if (requestVersion === requestVersionRef.current) {
setLoading(false);
setRefreshing(false);
}
}
},
[message],
);
useEffect(() => {
void load(true);
return () => {
requestVersionRef.current += 1;
};
}, [load]);
const blocker = useBlocker(dirty && !saving);
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
if (!dirty || saving) return;
event.preventDefault();
};
window.addEventListener("beforeunload", beforeUnload);
return () => window.removeEventListener("beforeunload", beforeUnload);
}, [dirty, saving]);
useEffect(() => {
if (blocker.state !== "blocked" || navigationConfirmOpenRef.current) return;
navigationConfirmOpenRef.current = true;
navigationTriggerRef.current = document.activeElement instanceof HTMLElement && document.activeElement !== document.body ? document.activeElement : null;
modal.confirm({
title: "放弃第三方参数调整?",
content: "当前页面有尚未保存的 LibTV 凭据或导入策略草稿,离开后这些内容会丢失。服务端正在使用的配置不会改变。",
okText: "放弃并离开",
cancelText: "继续编辑",
okButtonProps: { danger: true },
onOk: () => {
navigationConfirmOpenRef.current = false;
navigationTriggerRef.current = null;
blocker.proceed();
},
onCancel: () => {
navigationConfirmOpenRef.current = false;
blocker.reset();
window.requestAnimationFrame(() => {
const fallback = document.querySelector<HTMLButtonElement>(".admin-third-party-command-actions button");
const target = navigationTriggerRef.current?.isConnected ? navigationTriggerRef.current : fallback;
target?.focus();
navigationTriggerRef.current = null;
});
},
});
}, [blocker, modal]);
const resetDraft = () => {
if (!setting || saving) return;
setEnabled(setting.enabled);
setToken("");
setClearTokenDraft(false);
setSaveError("");
message.info("已撤销第三方参数的未保存调整");
};
const requestRefresh = () => {
if (!dirty) {
void load(false, true);
return;
}
modal.confirm({
title: "放弃调整并重新读取?",
content: "重新读取会丢弃当前 LibTV 凭据和导入开关草稿,并以服务端配置为准。",
okText: "放弃并刷新",
cancelText: "继续编辑",
okButtonProps: { danger: true },
onOk: () => load(false, true),
});
};
const changeToken = (nextToken: string) => {
setToken(nextToken);
if (nextToken.trim()) setClearTokenDraft(false);
setSaveError("");
};
const changeEnabled = (nextEnabled: boolean) => {
if (nextEnabled && !draftHasToken) {
message.warning("请先填写 LibTV Token,再启用画布导入");
return;
}
if (!setting || saving || nextEnabled === enabled) return;
setEnabled(nextEnabled);
setSaveError("");
};
const markTokenForRemoval = () => {
setToken("");
setClearTokenDraft(true);
setEnabled(false);
setSaveError("");
message.info("Token 已标记为待清除,点击保存修改前不会影响服务端配置");
};
const restoreTokenDraft = () => {
setClearTokenDraft(false);
setSaveError("");
};
async function save() {
if (!setting) return;
const nextEnabled = enabled;
const nextToken = token.trim();
const clearToken = clearTokenDraft;
const expectedHasToken = nextToken ? true : clearToken ? false : setting.hasToken;
if (nextEnabled && !expectedHasToken) {
message.error("启用 LibTV 画布导入前必须配置 Token");
return;
}
setSaving(true);
setSaveError("");
try {
const result = await updateAdminLibTVSetting({ enabled: nextEnabled, token: nextToken || undefined, clearToken: clearToken || undefined });
if (!isAdminLibTVSetting(result.setting) || result.setting.enabled !== nextEnabled || result.setting.hasToken !== expectedHasToken) {
throw new Error("服务端返回的 LibTV 配置与本次保存内容不一致,请重新读取后核对");
}
setSetting(result.setting);
setEnabled(result.setting.enabled);
setToken("");
setClearTokenDraft(false);
setTestStatus("idle");
setTestError("");
message.success("第三方参数配置已保存");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "保存第三方参数配置失败";
setSaveError(`${errorMessage}。未自动重试,请重新读取当前配置后再决定是否保存。`);
message.error(errorMessage);
throw error;
} finally {
setSaving(false);
}
}
const submitSave = async () => {
if (!setting || !dirty) return;
const nextToken = token.trim();
const expectedHasToken = nextToken ? true : clearTokenDraft ? false : setting.hasToken;
if (enabled && !expectedHasToken) {
message.error("启用 LibTV 画布导入前必须配置 Token");
return;
}
try {
await save();
} catch {
// 保存错误已在 save 中就地提示。
}
};
const test = async () => {
const uuid = testUuid.trim();
if (!setting?.hasToken) {
message.error("请先保存 LibTV Token,再验证连接");
return;
}
if (!uuid) {
message.error("请填写用于验证的 LibTV 画布 UUID");
return;
}
setTestStatus("testing");
setTestError("");
try {
await testAdminLibTV(uuid);
setTestStatus("success");
message.success("已保存的 LibTV Token 可以读取该画布");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "LibTV 连接验证失败";
setTestStatus("error");
setTestError(errorMessage);
message.error(errorMessage);
}
};
if (loading && !setting) {
return (
<AdminPageFrame title="第三方参数配置" description="先配置平台凭据,再决定是否开放用户导入" scroll>
<div className="admin-settings-stack admin-third-party-settings" aria-label="正在读取第三方参数配置" role="status">
<div className="admin-third-party-command-bar">
<Skeleton active title={{ width: 190 }} paragraph={false} />
</div>
<div className="admin-third-party-loading-card">
<Skeleton active paragraph={{ rows: 8 }} />
</div>
</div>
</AdminPageFrame>
);
}
if (!setting) {
return (
<AdminPageFrame title="第三方参数配置" description="先配置平台凭据,再决定是否开放用户导入" scroll>
<div className="admin-settings-stack admin-third-party-settings">
<div className="admin-third-party-load-error" role="alert">
<span className="admin-third-party-load-error-icon">
<AlertTriangle className="size-5" aria-hidden="true" />
</span>
<div>
<h2>无法读取第三方参数配置</h2>
<p>{loadError || "当前没有可显示的配置,请稍后重试。"}</p>
</div>
<Button icon={<RefreshCw className="size-4" />} loading={refreshing} onClick={() => void load(false, true)}>
重新读取
</Button>
</div>
</div>
</AdminPageFrame>
);
}
return (
<AdminPageFrame title="第三方参数配置" description="先配置平台凭据,再决定是否开放用户导入" scroll>
<div className="admin-settings-stack admin-third-party-settings">
<div className={cn("admin-third-party-command-bar", dirty && "is-dirty")}>
<div className="admin-third-party-command-copy" aria-live="polite">
<div>
<div className="flex flex-wrap items-center gap-2">
<strong>{dirty ? "第三方参数有调整待保存" : "第三方参数已与服务端同步"}</strong>
<AdminStatusBadge label={dirty ? "尚未生效" : "服务端当前值"} tone={dirty ? "warning" : "neutral"} />
</div>
<p>{dirty ? "凭据和导入开关只在本页暂存;保存前可撤销或重新读取。" : "当前仅接入 LibTV;凭据明文只提交到服务端,不会从接口回传。"}</p>
</div>
</div>
<div className="admin-third-party-command-actions">
{dirty ? (
<Button icon={<RotateCcw className="size-4" />} disabled={saving} onClick={resetDraft}>
撤销调整
</Button>
) : null}
<Button icon={<RefreshCw className="size-4" />} loading={refreshing} disabled={saving} onClick={requestRefresh}>
刷新状态
</Button>
</div>
</div>
{loadError || saveError ? (
<div className="admin-third-party-inline-alert" role="alert">
<AlertTriangle className="size-4 shrink-0" aria-hidden="true" />
<span>{saveError || `${loadError}。页面仍显示上一次成功读取的配置。`}</span>
</div>
) : null}
<div id="admin-third-party-libtv" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-third-party-section admin-third-party-credential-section"
icon={<KeyRound className="size-4" aria-hidden="true" />}
title="1. 配置 LibTV 服务端访问凭据"
description={`Token 只提交到${brandName}服务端,保存后浏览器不会再收到或显示明文。`}
status={<AdminStatusBadge label={credentialDraftLabel} tone={clearTokenDraft ? "warning" : draftHasToken ? "success" : "neutral"} />}
footer={!draftHasToken ? <ThirdPartySaveFooter setting={setting} dirty={dirty} saving={saving} refreshing={refreshing} onReset={resetDraft} onSave={submitSave} /> : undefined}
>
<div className="admin-third-party-provider">
<div className="admin-third-party-provider-heading">
<span className="admin-third-party-provider-mark">L</span>
<div>
<strong>LibTV</strong>
<p>画布项目读取接入</p>
</div>
</div>
<div className="admin-third-party-provider-tags">
<AdminStatusBadge label="服务端代理" tone="info" />
<AdminStatusBadge label="只读外部画布" tone="neutral" />
<AdminStatusBadge label={draftHasToken ? "凭据就绪" : "缺少凭据"} tone={draftHasToken ? "success" : "warning"} />
</div>
</div>
<div className="admin-third-party-credential-panel">
<label className="admin-third-party-field-label" htmlFor="admin-libtv-token">
LibTV Token
</label>
<Input.Password
id="admin-libtv-token"
value={token}
onChange={(event) => changeToken(event.target.value)}
placeholder={setting.hasToken && !clearTokenDraft ? configuredSecretText : clearTokenDraft ? "已标记为清除,可输入新 Token 改为替换" : "输入 LibTV Token"}
disabled={saving || refreshing}
autoComplete="new-password"
aria-describedby="admin-libtv-token-help"
/>
<div id="admin-libtv-token-help" className="admin-third-party-credential-state">
<span className={cn("admin-third-party-credential-indicator", draftHasToken && "is-ready", clearTokenDraft && "is-warning")}>
{draftHasToken ? <CircleCheck className="size-4" aria-hidden="true" /> : <AlertTriangle className="size-4" aria-hidden="true" />}
{credentialDraftLabel}
</span>
<p>
{token.trim()
? setting.hasToken
? "保存后替换服务端凭据;当前内容尚未提交。"
: "保存后新增服务端凭据;当前内容尚未提交。"
: clearTokenDraft
? "保存后清除 Token,并同时停用导入入口。"
: setting.hasToken
? "留空保存会保留服务端已有 Token。"
: "尚未配置 Token,无法启用或验证 LibTV 接入。"}
</p>
</div>
<div className="admin-third-party-credential-actions">
{clearTokenDraft ? (
<Button icon={<RotateCcw className="size-4" />} disabled={saving || refreshing} onClick={restoreTokenDraft}>
撤销清除
</Button>
) : setting.hasToken ? (
<Button danger icon={<Trash2 className="size-4" />} disabled={saving || refreshing} onClick={markTokenForRemoval}>
标记为清除
</Button>
) : null}
</div>
</div>
</SettingsSectionCard>
</div>
{draftHasToken ? (
<div id="admin-third-party-access" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-third-party-section admin-third-party-access-section"
icon={<CloudDownload className="size-4" aria-hidden="true" />}
title="2. 是否开放用户导入 LibTV 画布"
description="控制创作台是否允许普通用户通过 UUID 发起新的 LibTV 画布导入。"
status={<AdminStatusBadge label={enabledDirty ? (enabled ? "待开放" : "待关闭") : enabled ? "入口开放" : "入口关闭"} tone={enabledDirty ? "warning" : enabled ? "success" : "neutral"} />}
footer={<ThirdPartySaveFooter setting={setting} dirty={dirty} saving={saving} refreshing={refreshing} onReset={resetDraft} onSave={submitSave} />}
>
<div className="admin-third-party-policy-control">
<div>
<strong>{enabled ? "允许发起新的 LibTV 导入" : "不开放新的 LibTV 导入"}</strong>
<p>{enabled ? "保存后,服务端会使用已保存的 Token 读取用户指定的画布。" : "已有导入节点与连线继续保留,不会被删除或转换。"}</p>
</div>
<div className="admin-third-party-policy-switch">
<span>{enabled ? "开启" : "关闭"}</span>
<Switch aria-label="允许用户导入 LibTV 画布" checked={enabled} disabled={saving || refreshing} onChange={changeEnabled} />
</div>
</div>
<div className="admin-third-party-flow" aria-label="LibTV 画布导入链路">
<FlowStep icon={<KeyRound className="size-4" />} label="服务端凭据" detail="Token 不回传" />
<span aria-hidden="true">→</span>
<FlowStep icon={<Wifi className="size-4" />} label="读取画布" detail="按 UUID 请求" />
<span aria-hidden="true">→</span>
<FlowStep icon={<CloudDownload className="size-4" />} label={`导入${brandName}`} detail="生成节点与连线" />
</div>
</SettingsSectionCard>
</div>
) : null}
{setting.hasToken && !clearTokenDraft ? (
<div id="admin-third-party-test" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-third-party-section admin-third-party-test-card"
icon={<Wifi className="size-4" aria-hidden="true" />}
title="3. 验证已保存的 LibTV 凭据"
description={`使用服务端当前保存的 Token 发起一次只读请求,不会向 LibTV 或当前${brandName}画布写入内容。`}
status={<TestStatus status={testStatus} />}
>
<div className="admin-third-party-test-controls">
<div className="admin-third-party-test-input">
<label htmlFor="admin-libtv-test-uuid">可访问的 LibTV 画布 UUID</label>
<Input
id="admin-libtv-test-uuid"
value={testUuid}
onChange={(event) => {
setTestUuid(event.target.value);
setTestStatus("idle");
setTestError("");
}}
placeholder="输入画布 UUID,仅用于本次只读验证"
disabled={testStatus === "testing"}
/>
</div>
<Button icon={<Wifi className="size-4" />} loading={testStatus === "testing"} disabled={!setting.hasToken || saving || refreshing} onClick={() => void test()}>
验证连接
</Button>
</div>
<div className={cn("admin-third-party-test-note", testStatus === "error" && "is-error", testStatus === "success" && "is-success")} role={testStatus === "error" ? "alert" : "status"}>
{testStatus === "success" ? <BadgeCheck className="size-4" aria-hidden="true" /> : testStatus === "error" ? <AlertTriangle className="size-4" aria-hidden="true" /> : <Server className="size-4" aria-hidden="true" />}
<span>
{testStatus === "success"
? "验证成功:服务端已保存的 Token 可以读取该画布。"
: testStatus === "error"
? `验证失败:${testError}`
: token.trim()
? setting.hasToken
? "当前输入的新 Token 尚未保存,本次验证仍会使用服务端原有 Token。"
: "当前输入的 Token 尚未保存,请先保存修改后再验证连接。"
: "验证会产生一次真实的外部只读请求,但不会保存画布内容。"}
</span>
</div>
</SettingsSectionCard>
</div>
) : null}
</div>
</AdminPageFrame>
);
}
function ThirdPartySaveFooter({ setting, dirty, saving, refreshing, onReset, onSave }: { setting: AdminLibTVSetting; dirty: boolean; saving: boolean; refreshing: boolean; onReset: () => void; onSave: () => Promise<void> }) {
return (
<div className="admin-third-party-footer">
<div className="admin-third-party-footer-note">
<ShieldCheck className="size-4" aria-hidden="true" />
<span>{formatSettingTime(setting.updatedAt, "尚未保存 LibTV 配置")} · 保存不会读取外部画布</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{dirty ? (
<Button icon={<RotateCcw className="size-4" />} disabled={saving} onClick={onReset}>
撤销
</Button>
) : null}
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || refreshing} onClick={() => void onSave()}>
保存修改
</Button>
</div>
</div>
);
}
function FlowStep({ icon, label, detail }: { icon: ReactNode; label: string; detail: string }) {
return (
<div className="admin-third-party-flow-step">
<span>{icon}</span>
<div>
<strong>{label}</strong>
<small>{detail}</small>
</div>
</div>
);
}
function TestStatus({ status }: { status: ConnectionTestStatus }) {
if (status === "testing") return <AdminStatusBadge label="验证中" tone="info" />;
if (status === "success") return <AdminStatusBadge label="本次验证成功" tone="success" />;
if (status === "error") return <AdminStatusBadge label="本次验证失败" tone="error" />;
return <AdminStatusBadge label="本会话尚未验证" tone="neutral" />;
}
function isAdminLibTVSetting(value: unknown): value is AdminLibTVSetting {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<AdminLibTVSetting>;
return typeof candidate.enabled === "boolean" && typeof candidate.hasToken === "boolean" && (candidate.updatedAt === undefined || typeof candidate.updatedAt === "string");
}
function hasValidSettingTime(value?: string) {
if (!value) return false;
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) && timestamp > 0;
}
function formatSettingTime(value: string | undefined, fallback: string) {
if (!hasValidSettingTime(value)) return fallback;
return `更新于 ${new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value as string))}`;
}