把后台 token、Ant 主题、壳层控件和抽屉弹窗从用户工作台拆开,去掉树状侧栏装饰线,色板仍用原来的中性黑白台阶。 Co-authored-by: Cursor <cursoragent@cursor.com>
517 lines
28 KiB
TypeScript
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))}`;
|
||
}
|