Files
ttstd-infinite-canvas/web/src/pages/admin/settings/libtv-settings-page.tsx
T
ddCatandCursor 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

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))}`;
}