feat(admin): 管理后台 - 完善配置运营页面并修复导航、筛选与积分计费

This commit is contained in:
ddCat
2026-08-30 11:10:52 +08:00
parent 90da7298b6
commit c8b60cea07
26 changed files with 2468 additions and 1578 deletions

View File

@@ -1,13 +1,12 @@
import { App, Button, Form, Input, Skeleton, Switch } from "antd";
import { AlertTriangle, BadgeCheck, CircleCheck, CloudUpload, Database, FolderOpen, ImageIcon, KeyRound, RefreshCw, RotateCcw, Save, Server, ShieldCheck } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { AlertTriangle, CloudUpload, KeyRound, RefreshCw, RotateCcw, Save } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useBlocker } from "react-router";
import { cn } from "@/lib/utils";
import { getAdminArkPrivateAssetSetting, updateAdminArkPrivateAssetSetting, type AdminArkPrivateAssetSetting } from "@/services/api/auth";
import { useAdminContext } from "../admin-context";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminStatTile, AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "../components/admin-ui";
import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "../components/admin-ui";
type ArkPrivateAssetForm = {
enabled: boolean;
@@ -21,20 +20,19 @@ type ArkPrivateAssetPayload = Pick<AdminArkPrivateAssetSetting, "enabled" | "reg
export default function ArkPrivateAssetsSettingsPage() {
const { message, modal } = App.useApp();
const { references } = useAdminContext();
const [setting, setSetting] = useState<AdminArkPrivateAssetSetting | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [draftEnabled, setDraftEnabled] = useState(false);
const [loadError, setLoadError] = useState("");
const [saveError, setSaveError] = useState("");
const [form] = Form.useForm<ArkPrivateAssetForm>();
const requestVersionRef = useRef(0);
const navigationConfirmOpenRef = useRef(false);
const navigationTriggerRef = useRef<HTMLElement | null>(null);
const userNameById = useMemo(() => new Map(references.users.map((user) => [user.id, user.displayName || user.username])), [references.users]);
const watchedValues = Form.useWatch([], form) as Partial<ArkPrivateAssetForm> | undefined;
const draftEnabled = watchedValues?.enabled === true;
const load = useCallback(
async (initial = false, announce = false) => {
@@ -74,9 +72,7 @@ export default function ArkPrivateAssetsSettingsPage() {
useEffect(() => {
if (loading || !setting) return;
const values = toFormValues(setting);
form.setFieldsValue(values);
setDraftEnabled(values.enabled);
form.setFieldsValue(toFormValues(setting));
}, [form, loading, setting]);
const blocker = useBlocker(dirty && !saving);
@@ -123,7 +119,6 @@ export default function ArkPrivateAssetsSettingsPage() {
const values = toFormValues(setting);
form.setFieldsValue(values);
form.setFields([]);
setDraftEnabled(values.enabled);
setDirty(false);
setSaveError("");
message.info("已撤销方舟素材库的未保存调整");
@@ -158,7 +153,6 @@ export default function ArkPrivateAssetsSettingsPage() {
setSetting(result.setting);
const nextValues = toFormValues(result.setting);
form.setFieldsValue(nextValues);
setDraftEnabled(nextValues.enabled);
setDirty(false);
message.success("方舟素材库配置已保存");
} catch (error) {
@@ -191,26 +185,6 @@ export default function ArkPrivateAssetsSettingsPage() {
}
};
const toggleEnabled = async (enabled: boolean) => {
if (!setting || saving || enabled === setting.enabled) return;
const values = (enabled ? { ...form.getFieldsValue(true), enabled } : { ...toFormValues(setting), enabled, accessKeySecret: "" }) as ArkPrivateAssetForm;
const validationError = validateArkPrivateAssetDraft(values, setting);
if (validationError) {
form.setFieldValue("enabled", setting.enabled);
setDraftEnabled(setting.enabled);
message.error(validationError);
return;
}
form.setFieldValue("enabled", enabled);
setDraftEnabled(enabled);
try {
await save(values);
} catch {
form.setFieldValue("enabled", setting.enabled);
setDraftEnabled(setting.enabled);
}
};
if (loading && !setting) {
return (
<AdminPageFrame title="方舟素材库" description="为 Seedance 参考图配置后端可信素材导入" scroll>
@@ -218,13 +192,6 @@ export default function ArkPrivateAssetsSettingsPage() {
<div className="admin-ark-command-bar">
<Skeleton active title={{ width: 190 }} paragraph={false} />
</div>
<div className="admin-ark-overview">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="admin-stat-tile">
<Skeleton active title={{ width: 96 }} paragraph={{ rows: 1 }} />
</div>
))}
</div>
<div className="admin-ark-loading-card">
<Skeleton active paragraph={{ rows: 7 }} />
</div>
@@ -254,27 +221,21 @@ export default function ArkPrivateAssetsSettingsPage() {
);
}
const currentValues = form.getFieldsValue(true);
const currentValues = watchedValues || form.getFieldsValue(true);
const normalizedDraft = normalizeArkPrivateAssetPayload(currentValues);
const usableSecret = hasUsableSecret(normalizedDraft, setting);
const configOperator = setting.updatedBy ? userNameById.get(setting.updatedBy) || setting.updatedBy : "系统默认";
const projectReady = Boolean(normalizedDraft.region && normalizedDraft.projectName);
const credentialsReady = Boolean(normalizedDraft.accessKeyId && usableSecret);
const prerequisitesReady = projectReady && credentialsReady;
return (
<AdminPageFrame title="方舟素材库" description="为 Seedance 参考图配置后端可信素材导入" scroll>
<div className="admin-settings-stack admin-ark-settings">
<div className={cn("admin-ark-command-bar", dirty && "is-dirty")}>
<div className="admin-ark-command-copy" aria-live="polite">
<span className="admin-ark-command-icon">
<CloudUpload className="size-4" aria-hidden="true" />
</span>
<div>
<div className="flex flex-wrap items-center gap-2">
<strong>{dirty ? "方舟素材库有调整待保存" : "方舟素材库配置已同步"}</strong>
<AdminStatusBadge label={dirty ? "尚未生效" : "服务端当前值"} tone={dirty ? "warning" : "neutral"} />
</div>
<p>{dirty ? "当前同步策略与 IAM 信息只在本页暂存;不会提前上传或创建素材。" : "页面显示可信参考素材的自动同步策略;已有方舟素材不受开关变更影响。"}</p>
<div className="flex flex-wrap items-center gap-2">
<strong>{dirty ? "方舟素材库有调整待保存" : "方舟素材库配置已同步"}</strong>
<AdminStatusBadge label={dirty ? "尚未生效" : "服务端配置"} tone={dirty ? "warning" : "neutral"} />
</div>
</div>
<div className="admin-ark-command-actions">
@@ -286,6 +247,9 @@ export default function ArkPrivateAssetsSettingsPage() {
<Button icon={<RefreshCw className="size-4" />} loading={refreshing} disabled={saving} onClick={requestRefresh}>
刷新状态
</Button>
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || loading || refreshing} onClick={() => void submitSave()}>
保存修改
</Button>
</div>
</div>
@@ -296,151 +260,76 @@ export default function ArkPrivateAssetsSettingsPage() {
</div>
) : null}
<div className="admin-ark-overview" aria-label="方舟素材库配置概览">
<AdminStatTile label="可信素材同步" value={draftEnabled ? "已启用" : "已停用"} detail={dirty ? "暂存状态预览" : formatSettingTime(setting.updatedAt, "使用系统默认值")} />
<AdminStatTile label="方舟项目" value={normalizedDraft.projectName || "未配置"} detail={normalizedDraft.region ? `Region · ${normalizedDraft.region}` : "尚未填写 Region"} />
<AdminStatTile label="IAM 凭据" value={credentialsReady ? "已配置" : "待配置"} detail={normalizedDraft.accessKeyId ? (usableSecret ? "AccessKey 与 SecretKey 可用" : "当前 AccessKey 缺少 SecretKey") : "尚未填写 AccessKey"} />
<AdminStatTile label="配置来源" value={hasValidSettingTime(setting.updatedAt) ? "管理员配置" : "系统默认"} detail={hasValidSettingTime(setting.updatedAt) ? configOperator : "尚未保存方舟素材库配置"} />
</div>
<div id="admin-ark-policy" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-ark-section admin-ark-policy-section"
icon={<ImageIcon className="size-4" aria-hidden="true" />}
title="可信参考素材同步"
description="控制符合条件的 Seedance 图片参考素材是否由后端导入方舟并等待审核。"
status={<AdminStatusBadge label={dirty ? (draftEnabled ? "待启用" : "待停用") : draftEnabled ? "已启用" : "已停用"} tone={dirty ? "warning" : draftEnabled ? "success" : "neutral"} />}
>
<div className="admin-ark-policy-control">
<span className="admin-ark-policy-icon">
<CloudUpload className="size-4" aria-hidden="true" />
</span>
<div className="admin-ark-policy-copy">
<strong>自动导入可信素材</strong>
<p>只有 Seedance / 方舟视频请求、用户允许自动同步、参考图属于当前用户且资源已就绪时才会触发。</p>
<span>{draftEnabled ? "符合条件的图片将在调用生成模型前上传并等待方舟审核。" : "新请求保持原参考地址,不再自动创建方舟可信素材。"}</span>
</div>
<div className="admin-ark-policy-switch">
<span>{draftEnabled ? "启用" : "停用"}</span>
<Switch checked={draftEnabled} disabled={loading || refreshing || saving} aria-label="启用可信素材同步,切换后立即保存" onChange={(checked) => void toggleEnabled(checked)} />
</div>
</div>
<div className="admin-ark-flow" aria-label="可信素材同步条件">
<FlowStep icon={<Server className="size-4" />} eyebrow="生成入口" title="方舟视频任务" description="仅处理 Seedance 或兼容方舟方案的视频请求。" />
<FlowStep icon={<ImageIcon className="size-4" />} eyebrow="资源边界" title="当前用户图片" description="必须是用户有权访问且上传完成的图片素材。" />
<FlowStep icon={<CircleCheck className="size-4" />} eyebrow="服务端处理" title="导入并等待审核" description="创建或复用绑定,审核通过后再传入 asset ID。" />
</div>
<div className="admin-ark-context-note">
<ShieldCheck className="size-4" aria-hidden="true" />
<span>停用只停止新的自动同步,不会删除已有方舟素材、审核结果或本地资源绑定;普通参考图仍按原地址继续生成。</span>
</div>
</SettingsSectionCard>
</div>
<div id="admin-ark-credentials" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-ark-section admin-ark-configuration-section"
icon={<KeyRound className="size-4" aria-hidden="true" />}
title="方舟项目与 IAM 凭据"
description="配置控制面 Region、Ark ProjectName,以及后端创建素材组和上传素材所用的 IAM AK/SK。"
status={<AdminStatusBadge label={dirty ? "有调整" : projectReady && credentialsReady ? "已配置" : "待配置"} tone={dirty ? "warning" : projectReady && credentialsReady ? "success" : "neutral"} />}
footer={
<>
<div className="admin-ark-footer-note">
<BadgeCheck className="size-4" aria-hidden="true" />
<span>{formatSettingTime(setting.updatedAt, "尚未保存方舟素材库配置")} · 保存不会连接方舟或上传测试素材</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{dirty ? (
<Button icon={<RotateCcw className="size-4" />} disabled={saving} onClick={resetDraft}>
撤销
</Button>
) : null}
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || loading || refreshing} onClick={() => void submitSave()}>
保存修改
</Button>
</div>
</>
<Form
form={form}
layout="vertical"
requiredMark={false}
disabled={loading || refreshing || saving}
onValuesChange={(_, values: ArkPrivateAssetForm) => {
let nextValues = values;
if (values.enabled && !arkPrivateAssetPrerequisitesReady(values, setting)) {
form.setFieldValue("enabled", false);
nextValues = { ...values, enabled: false };
}
>
<Form
form={form}
layout="vertical"
requiredMark={false}
disabled={loading || refreshing || saving}
onValuesChange={() => {
const values = form.getFieldsValue(true);
setDraftEnabled(Boolean(values.enabled));
setDirty(hasArkPrivateAssetChanges(values, setting));
setSaveError("");
}}
setDirty(hasArkPrivateAssetChanges(nextValues, setting));
setSaveError("");
}}
>
<div id="admin-ark-credentials" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-ark-section admin-ark-configuration-section"
icon={<KeyRound className="size-4" aria-hidden="true" />}
title="1. 配置方舟项目与 IAM 凭据"
description="凭据仅保存在服务端,用于创建素材组、上传图片和查询审核状态。"
status={<AdminStatusBadge label={prerequisitesReady ? "已配置" : "待配置"} tone={prerequisitesReady ? "success" : "neutral"} />}
>
<Form.Item name="enabled" valuePropName="checked" hidden>
<Switch />
</Form.Item>
<div className="admin-ark-form-section">
<FormSectionTitle icon={<FolderOpen className="size-4" />} title="项目范围" description="Region 决定方舟控制面地址,ProjectName 决定素材组和资源所属项目。" />
<div className="admin-ark-form-grid">
<Form.Item name="region" label="Region" extra="按火山方舟控制台显示值填写;启用同步时必填。">
<Input autoComplete="off" placeholder="例如:cn-beijing" />
</Form.Item>
<Form.Item name="projectName" label="Ark ProjectName" extra="必须与 IAM 凭据可访问的方舟项目一致。">
<Input autoComplete="off" placeholder="部署方的 Ark ProjectName" />
</Form.Item>
</div>
<div className="admin-ark-form-grid">
<Form.Item name="region" label="Region">
<Input autoComplete="off" placeholder="例如:cn-beijing" />
</Form.Item>
<Form.Item name="projectName" label="Ark ProjectName">
<Input autoComplete="off" placeholder="方舟项目名称" />
</Form.Item>
<Form.Item name="accessKeyId" label="IAM AccessKey">
<Input autoComplete="off" prefix={<KeyRound className="size-4 text-foreground/35" />} placeholder="仅保存在服务端" />
</Form.Item>
<Form.Item name="accessKeySecret" label={setting.hasAccessKeySecret ? `IAM SecretKey(${configuredSecretText})` : "IAM SecretKey"} extra={setting.hasAccessKeySecret ? "AccessKey 不变时留空可保留原密钥。" : undefined}>
<Input.Password autoComplete="new-password" placeholder={setting.hasAccessKeySecret ? "留空保留原密钥" : "仅保存在服务端"} />
</Form.Item>
</div>
</SettingsSectionCard>
</div>
<div className="admin-ark-form-section">
<FormSectionTitle icon={<KeyRound className="size-4" />} title="服务端 IAM 凭据" description="仅供当前后端创建素材组、上传图片和查询审核状态,不会下发到浏览器。" />
<div className="admin-ark-form-grid">
<Form.Item name="accessKeyId" label="IAM AccessKey" extra="更换 AccessKey 时必须同时提供匹配的 SecretKey 才能启用同步。">
<Input autoComplete="off" prefix={<KeyRound className="size-4 text-foreground/35" />} placeholder="仅保存在服务端" />
</Form.Item>
<Form.Item name="accessKeySecret" label={setting.hasAccessKeySecret ? `IAM SecretKey(${configuredSecretText})` : "IAM SecretKey"} extra="AccessKey 未变化时留空可保留原密钥;更换 AccessKey 后留空会清除原密钥。">
<Input.Password autoComplete="new-password" placeholder={setting.hasAccessKeySecret ? "留空保留原密钥" : "仅保存在服务端"} />
</Form.Item>
{prerequisitesReady || draftEnabled ? (
<div id="admin-ark-policy" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-ark-section admin-ark-policy-section"
icon={<CloudUpload className="size-4" aria-hidden="true" />}
title="2. 是否启用可信素材同步"
description="启用后,符合条件的 Seedance 参考图会在生成前导入方舟素材库。"
status={<AdminStatusBadge label={draftEnabled ? "已启用" : "已停用"} tone={draftEnabled ? "success" : "neutral"} />}
>
<div className="admin-ark-policy-control">
<div className="admin-ark-policy-copy">
<strong>自动导入可信参考素材</strong>
<p>关闭只停止新素材同步,不删除已有素材和绑定。</p>
</div>
<div className="admin-ark-policy-switch">
<span>{draftEnabled ? "启用" : "停用"}</span>
<Form.Item name="enabled" valuePropName="checked" noStyle>
<Switch aria-label="启用可信素材同步,保存修改后生效" />
</Form.Item>
</div>
</div>
<div className="admin-ark-scope-note">
<Database className="size-4" aria-hidden="true" />
<span>Region、ProjectName 或 AccessKey 改变后,服务端会清除缓存的默认素材组 ID,并在下一次真实同步时按新范围创建;已有素材绑定不会在保存时删除。</span>
</div>
</div>
</Form>
</SettingsSectionCard>
</div>
</SettingsSectionCard>
</div>
) : null}
</Form>
</div>
</AdminPageFrame>
);
}
function FormSectionTitle({ icon, title, description }: { icon: ReactNode; title: string; description: string }) {
return (
<div className="admin-ark-form-section-heading">
<span>{icon}</span>
<div>
<h3>{title}</h3>
<p>{description}</p>
</div>
</div>
);
}
function FlowStep({ icon, eyebrow, title, description }: { icon: ReactNode; eyebrow: string; title: string; description: string }) {
return (
<div className="admin-ark-flow-step">
<span className="admin-ark-flow-icon">{icon}</span>
<div>
<small>{eyebrow}</small>
<strong>{title}</strong>
<p>{description}</p>
</div>
</div>
);
}
function toFormValues(setting: AdminArkPrivateAssetSetting): ArkPrivateAssetForm {
return {
enabled: setting.enabled,
@@ -482,6 +371,11 @@ function hasUsableSecret(values: ArkPrivateAssetPayload, setting: AdminArkPrivat
return Boolean(values.accessKeySecret || (values.accessKeyId === setting.accessKeyId && setting.hasAccessKeySecret));
}
function arkPrivateAssetPrerequisitesReady(values: Partial<ArkPrivateAssetForm>, setting: AdminArkPrivateAssetSetting) {
const draft = normalizeArkPrivateAssetPayload(values);
return Boolean(draft.region && draft.projectName && draft.accessKeyId && hasUsableSecret(draft, setting));
}
function nextSecretPresence(values: ArkPrivateAssetPayload, setting: AdminArkPrivateAssetSetting) {
if (values.accessKeySecret) return true;
return values.accessKeyId === setting.accessKeyId && setting.hasAccessKeySecret;
@@ -496,14 +390,3 @@ function isAdminArkPrivateAssetSetting(value: unknown): value is AdminArkPrivate
const setting = value as Partial<AdminArkPrivateAssetSetting>;
return typeof setting.enabled === "boolean" && typeof setting.region === "string" && typeof setting.projectName === "string" && typeof setting.accessKeyId === "string" && typeof setting.hasAccessKeySecret === "boolean";
}
function hasValidSettingTime(value?: string) {
if (!value) return false;
const date = new Date(value);
return !Number.isNaN(date.getTime()) && date.getFullYear() >= 2000;
}
function formatSettingTime(value: string | undefined, fallback: string) {
if (!hasValidSettingTime(value)) return fallback;
return `更新于 ${new Date(value as string).toLocaleString("zh-CN", { hour12: false })}`;
}

View File

@@ -1,5 +1,5 @@
import { App, Button, Input, Skeleton, type InputRef } from "antd";
import { AlertTriangle, Brush, KeyRound, LockKeyhole, RefreshCw, RotateCcw, Save, Shapes, ShieldCheck } from "lucide-react";
import { AlertTriangle, Brush, KeyRound, RefreshCw, RotateCcw, Save, Shapes } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useBlocker } from "react-router";
@@ -7,9 +7,8 @@ import { drawingEngineLabel, isDrawingEngineAvailable, type CanvasDrawingEngine,
import { cn } from "@/lib/utils";
import { getAdminDrawingEngineSetting, updateAdminDrawingEngineSetting } from "@/services/api/auth";
import { useUserStore } from "@/stores/use-user-store";
import { useAdminContext } from "../admin-context";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminStatTile, AdminStatusBadge, SettingsSectionCard } from "../components/admin-ui";
import { AdminStatusBadge, SettingsSectionCard } from "../components/admin-ui";
type DrawingEngineDraft = {
defaultEngine: CanvasDrawingEngine;
@@ -18,30 +17,23 @@ type DrawingEngineDraft = {
const engineOptions: Array<{
value: CanvasDrawingEngine;
eyebrow: string;
title: string;
description: string;
note: string;
}> = [
{
value: "excalidraw",
eyebrow: "内置开源编辑器",
title: "Excalidraw",
description: "无需额外授权,适合快速草图、结构标注和轻量协作。",
note: "设为默认后,仅之后新建的绘图节点使用 Excalidraw。",
description: "无需授权,适合草图、结构标注和轻量协作。",
},
{
value: "tldraw",
eyebrow: "授权编辑器",
title: "tldraw",
description: "需要可用于浏览器端的 License Key;更改授权还会影响已有 tldraw 绘图。",
note: "设为默认后,仅之后新建的绘图节点使用 tldraw。",
description: "需要浏览器端 License Key,也用于打开已有 tldraw 绘图。",
},
];
export default function DrawingEngineSettingsPage() {
const { message, modal } = App.useApp();
const { references } = useAdminContext();
const setDrawingEngine = useUserStore((state) => state.setDrawingEngine);
const [savedSetting, setSavedSetting] = useState<CanvasDrawingEngineSetting | null>(null);
const [draft, setDraft] = useState<DrawingEngineDraft | null>(null);
@@ -54,7 +46,6 @@ export default function DrawingEngineSettingsPage() {
const navigationConfirmOpenRef = useRef(false);
const navigationTriggerRef = useRef<HTMLElement | null>(null);
const licenseInputRef = useRef<InputRef>(null);
const userNameById = useMemo(() => new Map(references.users.map((user) => [user.id, user.displayName || user.username])), [references.users]);
const load = useCallback(
async (initial = false, announce = false) => {
@@ -143,7 +134,6 @@ export default function DrawingEngineSettingsPage() {
const tldrawAvailable = draft ? isDrawingEngineAvailable("tldraw", draft.tldrawLicenseKey) : false;
const draftHasStoredLicense = Boolean(normalizedDraft?.tldrawLicenseKey);
const savedHasStoredLicense = Boolean((savedSetting?.tldrawLicenseKey || "").trim());
const usingEnvironmentLicense = tldrawAvailable && !draftHasStoredLicense;
const selectedEngineAvailable = draft ? isDrawingEngineAvailable(draft.defaultEngine, draft.tldrawLicenseKey) : false;
@@ -169,22 +159,10 @@ export default function DrawingEngineSettingsPage() {
});
};
const selectEngine = async (engine: CanvasDrawingEngine) => {
const selectEngine = (engine: CanvasDrawingEngine) => {
if (!draft || saving) return;
if (engine === savedSetting?.defaultEngine) return;
if (engine === "tldraw" && !tldrawAvailable) {
message.info("请先配置 tldraw License Key");
licenseInputRef.current?.focus();
return;
}
const next = { defaultEngine: engine, tldrawLicenseKey: engine === "tldraw" ? draft.tldrawLicenseKey : savedSetting?.tldrawLicenseKey || "" };
setDraft(next);
setDraft({ ...draft, defaultEngine: engine });
setSaveError("");
try {
await save(next);
} catch {
if (savedSetting) setDraft(toDraft(savedSetting));
}
};
async function save(override?: DrawingEngineDraft) {
@@ -233,13 +211,6 @@ export default function DrawingEngineSettingsPage() {
<div className="admin-drawing-command-bar">
<Skeleton active title={{ width: 180 }} paragraph={false} />
</div>
<div className="admin-drawing-overview">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="admin-stat-tile">
<Skeleton active title={{ width: 96 }} paragraph={{ rows: 1 }} />
</div>
))}
</div>
<div className="admin-drawing-loading-card">
<Skeleton active paragraph={{ rows: 5 }} />
</div>
@@ -269,8 +240,6 @@ export default function DrawingEngineSettingsPage() {
);
}
const updateActor = savedSetting.updatedBy ? userNameById.get(savedSetting.updatedBy) || savedSetting.updatedBy : "";
const updateDetail = savedSetting.configured ? `${formatTime(savedSetting.updatedAt)}${updateActor ? ` · ${updateActor}` : ""}` : "尚未由管理员保存,使用系统默认值";
const licenseStatus = draftHasStoredLicense ? "后台已配置" : usingEnvironmentLicense ? "部署环境提供" : "未配置";
return (
@@ -278,15 +247,9 @@ export default function DrawingEngineSettingsPage() {
<div className="admin-settings-stack admin-drawing-settings">
<div className={cn("admin-drawing-command-bar", dirty && "is-dirty")}>
<div className="admin-drawing-command-copy" aria-live="polite">
<span className="admin-drawing-command-icon">
<ShieldCheck className="size-4" aria-hidden="true" />
</span>
<div>
<div className="flex flex-wrap items-center gap-2">
<strong>{dirty ? `${dirtyFields.length} 项调整待保存` : "绘图工具状态已同步"}</strong>
<AdminStatusBadge label={dirty ? "尚未生效" : savedSetting.configured ? "已保存配置" : "系统默认"} tone={dirty ? "warning" : savedSetting.configured ? "success" : "neutral"} />
</div>
<p>{dirty ? "当前调整只在本页暂存;点击保存修改后生效。" : "默认编辑器与授权状态来自当前服务端配置。"}</p>
<div className="flex flex-wrap items-center gap-2">
<strong>{dirty ? `${dirtyFields.length} 项调整待保存` : "绘图工具配置已同步"}</strong>
<AdminStatusBadge label={dirty ? "尚未生效" : savedSetting.configured ? "服务端配置" : "系统默认"} tone={dirty ? "warning" : "neutral"} />
</div>
</div>
<div className="admin-drawing-command-actions">
@@ -298,11 +261,9 @@ export default function DrawingEngineSettingsPage() {
<Button icon={<RefreshCw className="size-4" />} loading={refreshing} disabled={saving} onClick={requestRefresh}>
刷新状态
</Button>
{dirty ? (
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!selectedEngineAvailable} onClick={() => void submitSave()}>
保存修改
</Button>
) : null}
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || !selectedEngineAvailable} onClick={() => void submitSave()}>
保存修改
</Button>
</div>
</div>
@@ -313,19 +274,12 @@ export default function DrawingEngineSettingsPage() {
</div>
) : null}
<div className="admin-drawing-overview" aria-label="绘图工具配置概览">
<AdminStatTile label="新建默认编辑器" value={drawingEngineLabel(draft.defaultEngine)} detail={dirtyFields.includes("defaultEngine") ? "暂存状态预览" : "仅影响之后新建的绘图"} />
<AdminStatTile label="tldraw 可用性" value={tldrawAvailable ? "可以使用" : "暂不可用"} detail={licenseStatus} />
<AdminStatTile label="已有 tldraw 绘图" value={tldrawAvailable ? "可以打开" : "当前受限"} detail={tldrawAvailable ? "授权允许打开和编辑" : "补充授权后恢复访问"} />
<AdminStatTile label="配置来源" value={savedSetting.configured ? "管理员配置" : "系统默认"} detail={updateDetail} />
</div>
<div className="admin-drawing-section-grid">
<div id="admin-drawing-default-engine" className="admin-settings-anchor">
<SettingsSectionCard
icon={<Brush className="size-4" aria-hidden="true" />}
title="新建绘图默认编辑器"
description="只决定之后新建的绘图节点使用哪个编辑器,不转换或覆盖已有绘图。"
title="1. 选择新建绘图默认编辑器"
description="仅影响之后新建的绘图,不转换已有节点。"
status={<AdminStatusBadge label={drawingEngineLabel(draft.defaultEngine)} tone={selectedEngineAvailable ? "info" : "warning"} />}
>
<div className="admin-drawing-engine-grid" role="group" aria-label="选择新建绘图默认编辑器">
@@ -337,25 +291,19 @@ export default function DrawingEngineSettingsPage() {
key={option.value}
type="button"
className={cn("admin-drawing-engine-choice", selected && "is-selected", !available && "is-unavailable")}
aria-label={`选择 ${option.title} 作为新建绘图默认编辑器${available ? "" : ",需要先配置授权"}`}
aria-label={`选择 ${option.title} 作为新建绘图默认编辑器${available ? "" : ",保存前需要配置授权"}`}
aria-pressed={selected}
aria-disabled={!available}
disabled={saving}
onClick={() => void selectEngine(option.value)}
onClick={() => selectEngine(option.value)}
>
<span className="admin-drawing-engine-choice-heading">
<span className="admin-drawing-engine-choice-icon">{option.value === "excalidraw" ? <Shapes className="size-4" aria-hidden="true" /> : <Brush className="size-4" aria-hidden="true" />}</span>
<span className="min-w-0 flex-1">
<span className="admin-drawing-engine-choice-eyebrow">{option.eyebrow}</span>
<strong>{option.title}</strong>
</span>
<AdminStatusBadge label={selected ? "当前选择" : available ? "可选择" : "需要授权"} tone={selected ? "info" : available ? "success" : "warning"} />
<AdminStatusBadge label={selected ? "已选择" : available ? "可用" : "需要授权"} tone={selected ? "info" : available ? "success" : "warning"} />
</span>
<span className="admin-drawing-engine-choice-description">{option.description}</span>
<span className="admin-drawing-engine-choice-note">
<ShieldCheck className="size-3.5" aria-hidden="true" />
{option.note}
</span>
</button>
);
})}
@@ -366,8 +314,8 @@ export default function DrawingEngineSettingsPage() {
<div id="admin-drawing-tldraw-license" className="admin-settings-anchor">
<SettingsSectionCard
icon={<KeyRound className="size-4" aria-hidden="true" />}
title="tldraw 授权"
description="授权决定当前部署能否打开和编辑 tldraw 绘图,包括已有节点。"
title="2. 配置 tldraw 授权(按需)"
description="选择 tldraw 或需要打开已有 tldraw 绘图时配置。"
status={<AdminStatusBadge label={licenseStatus} tone={tldrawAvailable ? "success" : "warning"} />}
>
<div className="admin-drawing-license-layout">
@@ -386,20 +334,7 @@ export default function DrawingEngineSettingsPage() {
autoComplete="new-password"
aria-describedby="admin-tldraw-license-help"
/>
<p id="admin-tldraw-license-help">系统只能判断密钥是否已提供,无法在保存前验证授权是否由 tldraw 接受。</p>
</div>
<div className={cn("admin-drawing-license-note", !tldrawAvailable && "is-warning")}>
<span className="admin-drawing-license-note-icon">{tldrawAvailable ? <LockKeyhole className="size-4" aria-hidden="true" /> : <AlertTriangle className="size-4" aria-hidden="true" />}</span>
<div>
<strong>{tldrawAvailable ? "当前部署具备 tldraw 授权" : "当前部署缺少 tldraw 授权"}</strong>
<p>
{tldrawAvailable
? usingEnvironmentLicense
? "当前由部署环境提供授权;后台字段可以保持为空。"
: "保存后授权会提供给已登录的浏览器端绘图编辑器使用。"
: "已有 tldraw 绘图将无法打开;如不使用 tldraw,请保持 Excalidraw 为默认编辑器。"}
</p>
</div>
<p id="admin-tldraw-license-help">留空时使用部署环境授权;两处都未配置时 tldraw 不可用。</p>
</div>
</div>
</SettingsSectionCard>
@@ -438,10 +373,3 @@ function parseDrawingEngineSetting(value: unknown): CanvasDrawingEngineSetting {
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : undefined,
};
}
function formatTime(value?: string) {
if (!value) return "更新时间未知";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "更新时间未知";
return date.toLocaleString("zh-CN", { hour12: false });
}

View File

@@ -1,12 +1,12 @@
import { App, Button, Input, Skeleton, Switch } from "antd";
import { AlertTriangle, BadgeCheck, Cable, CircleCheck, CloudDownload, KeyRound, RefreshCw, RotateCcw, Save, Server, ShieldCheck, Trash2, Wifi } from "lucide-react";
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 { AdminPageFrame } from "../components/admin-shell";
import { AdminStatTile, AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "../components/admin-ui";
import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "../components/admin-ui";
type ConnectionTestStatus = "idle" | "testing" | "success" | "error";
@@ -30,6 +30,7 @@ export default function LibTVSettingsPage() {
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(
@@ -141,19 +142,14 @@ export default function LibTVSettingsPage() {
setSaveError("");
};
const changeEnabled = async (nextEnabled: boolean) => {
const changeEnabled = (nextEnabled: boolean) => {
if (nextEnabled && !draftHasToken) {
message.warning("请先填写 LibTV Token,再启用画布导入");
return;
}
if (!setting || saving || nextEnabled === setting.enabled) return;
if (!setting || saving || nextEnabled === enabled) return;
setEnabled(nextEnabled);
setSaveError("");
try {
await save({ enabled: nextEnabled, includeDraft: nextEnabled });
} catch {
setEnabled(setting.enabled);
}
};
const markTokenForRemoval = () => {
@@ -169,12 +165,11 @@ export default function LibTVSettingsPage() {
setSaveError("");
};
async function save(override?: { enabled?: boolean; includeDraft?: boolean }) {
async function save() {
if (!setting) return;
const nextEnabled = override?.enabled ?? enabled;
const includeDraft = override?.includeDraft ?? true;
const nextToken = includeDraft ? token.trim() : "";
const clearToken = includeDraft && clearTokenDraft;
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");
@@ -245,18 +240,11 @@ export default function LibTVSettingsPage() {
if (loading && !setting) {
return (
<AdminPageFrame title="第三方参数配置" description="管理外部平台的服务端凭据与接入状态" scroll>
<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-overview">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="admin-stat-tile">
<Skeleton active title={{ width: 96 }} paragraph={{ rows: 1 }} />
</div>
))}
</div>
<div className="admin-third-party-loading-card">
<Skeleton active paragraph={{ rows: 8 }} />
</div>
@@ -267,7 +255,7 @@ export default function LibTVSettingsPage() {
if (!setting) {
return (
<AdminPageFrame title="第三方参数配置" description="管理外部平台的服务端凭据与接入状态" scroll>
<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">
@@ -287,13 +275,10 @@ export default function LibTVSettingsPage() {
}
return (
<AdminPageFrame title="第三方参数配置" description="管理外部平台的服务端凭据与接入状态" scroll>
<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">
<span className="admin-third-party-command-icon">
<Cable className="size-4" aria-hidden="true" />
</span>
<div>
<div className="flex flex-wrap items-center gap-2">
<strong>{dirty ? "第三方参数有调整待保存" : "第三方参数已与服务端同步"}</strong>
@@ -321,31 +306,14 @@ export default function LibTVSettingsPage() {
</div>
) : null}
<div className="admin-third-party-overview" aria-label="第三方参数配置概览">
<AdminStatTile label="已接入平台" value="1 个" detail="LibTV 画布导入" />
<AdminStatTile label="服务端凭据" value={credentialDraftLabel} detail={dirty ? "暂存状态预览" : setting.hasToken ? "明文不回传浏览器" : "需要管理员配置"} />
<AdminStatTile label="用户导入入口" value={enabled ? "已启用" : "已停用"} detail={dirty ? "暂存状态预览" : "服务端当前状态"} />
<AdminStatTile label="配置来源" value={hasValidSettingTime(setting.updatedAt) ? "管理员配置" : "系统默认"} detail={formatSettingTime(setting.updatedAt, "尚未保存第三方参数")} />
</div>
<div id="admin-third-party-libtv" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-third-party-section"
icon={<CloudDownload className="size-4" aria-hidden="true" />}
title="LibTV 画布导入"
description="由影策服务端读取指定 LibTV 画布,并转换为当前项目中的节点与连线。"
status={<AdminStatusBadge label={enabled ? "入口开放" : "入口停用"} tone={enabled ? "success" : "neutral"} />}
footer={
<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>
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || refreshing} onClick={() => void submitSave()}>
保存修改
</Button>
</div>
}
className="admin-third-party-section admin-third-party-credential-section"
icon={<KeyRound className="size-4" aria-hidden="true" />}
title="1. 配置 LibTV 服务端访问凭据"
description="Token 只提交到影策服务端,保存后浏览器不会再收到或显示明文。"
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">
@@ -361,101 +329,91 @@ export default function LibTVSettingsPage() {
<AdminStatusBadge label={draftHasToken ? "凭据就绪" : "缺少凭据"} tone={draftHasToken ? "success" : "warning"} />
</div>
</div>
<div className="admin-third-party-domain-grid">
<section className="admin-third-party-domain">
<div className="admin-third-party-domain-heading">
<span>
<CloudDownload className="size-4" aria-hidden="true" />
</span>
<div>
<h3>用户导入入口</h3>
<p>控制创作台是否允许普通用户通过 UUID 发起 LibTV 画布导入。</p>
</div>
</div>
<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={(checked) => void changeEnabled(checked)} />
</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="导入影策" detail="生成节点与连线" />
</div>
</section>
<section className="admin-third-party-domain">
<div className="admin-third-party-domain-heading">
<span>
<KeyRound className="size-4" aria-hidden="true" />
</span>
<div>
<h3>服务端访问凭据</h3>
<p>新 Token 只作为草稿保存在当前输入框,保存后明文不会再显示。</p>
</div>
</div>
<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>
</section>
</div>
<section className="admin-third-party-test-section">
<div className="admin-third-party-test-heading">
<span>
<Wifi className="size-4" aria-hidden="true" />
<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>
<div>
<h3>验证已保存凭据</h3>
<p>使用服务端当前保存的 Token 读取指定画布,只验证读取权限,不向 LibTV 或当前影策画布写入内容。</p>
</div>
<TestStatus status={testStatus} />
<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="导入影策" 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 或当前影策画布写入内容。"
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>
@@ -489,14 +447,35 @@ export default function LibTVSettingsPage() {
: "验证会产生一次真实的外部只读请求,但不会保存画布内容。"}
</span>
</div>
</section>
</SettingsSectionCard>
</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">

View File

@@ -6,7 +6,7 @@ import { useBlocker } from "react-router";
import { cn } from "@/lib/utils";
import { getAdminResponseInterceptionSetting, updateAdminResponseInterceptionSetting, type ResponseInterceptionRule, type ResponseInterceptionSetting } from "@/services/api/response-interception";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminStatTile, AdminStatusBadge, SettingsSectionCard } from "../components/admin-ui";
import { AdminStatusBadge, SettingsSectionCard } from "../components/admin-ui";
const MAX_RULES = 100;
const MAX_MATCH_RUNES = 200;
@@ -29,6 +29,7 @@ export default function ResponseInterceptionSettingsPage() {
const navigationTriggerRef = useRef<HTMLElement | null>(null);
const draft = useMemo(() => normalizeResponseInterceptionSetting({ enabled, rules }), [enabled, rules]);
const dirty = useMemo(() => Boolean(setting && !responseInterceptionSettingsEqual(draft, setting)), [draft, setting]);
const enabledDirty = Boolean(setting && enabled !== setting.enabled);
const duplicateRuleIndexes = useMemo(() => findDuplicateRuleIndexes(draft.rules), [draft.rules]);
const preview = useMemo(() => previewResponseInterception(previewInput, draft), [draft, previewInput]);
@@ -206,36 +207,19 @@ export default function ResponseInterceptionSettingsPage() {
}
};
const toggleEnabled = async (nextEnabled: boolean) => {
if (!setting || saving || nextEnabled === setting.enabled) return;
const expected = normalizeResponseInterceptionSetting({ enabled: nextEnabled, rules: nextEnabled ? rules : setting.rules });
const validationError = validateResponseInterceptionSetting(expected);
if (validationError) {
message.error(validationError);
return;
}
const changeEnabled = (nextEnabled: boolean) => {
if (!setting || saving || nextEnabled === enabled) return;
setEnabled(nextEnabled);
try {
await save(expected);
} catch {
setEnabled(setting.enabled);
}
setSaveError("");
};
if (loading && !setting) {
return (
<AdminPageFrame title="模型响应拦截" description="替换用户可见的上游异常" scroll>
<AdminPageFrame title="模型响应拦截" description="先决定是否替换用户错误,再配置规则与优先级" scroll>
<div className="admin-settings-stack admin-intercept-settings" aria-label="正在读取模型响应拦截配置" role="status">
<div className="admin-intercept-command-bar">
<Skeleton active title={{ width: 190 }} paragraph={false} />
</div>
<div className="admin-intercept-overview">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="admin-stat-tile">
<Skeleton active title={{ width: 96 }} paragraph={{ rows: 1 }} />
</div>
))}
</div>
<div className="admin-intercept-loading-card">
<Skeleton active paragraph={{ rows: 7 }} />
</div>
@@ -246,7 +230,7 @@ export default function ResponseInterceptionSettingsPage() {
if (!setting) {
return (
<AdminPageFrame title="模型响应拦截" description="替换用户可见的上游异常" scroll>
<AdminPageFrame title="模型响应拦截" description="先决定是否替换用户错误,再配置规则与优先级" scroll>
<div className="admin-settings-stack admin-intercept-settings">
<div className="admin-intercept-load-error" role="alert">
<span className="admin-intercept-load-error-icon">
@@ -266,7 +250,7 @@ export default function ResponseInterceptionSettingsPage() {
}
return (
<AdminPageFrame title="模型响应拦截" description="替换用户可见的上游异常" scroll>
<AdminPageFrame title="模型响应拦截" description="先决定是否替换用户错误,再配置规则与优先级" scroll>
<div className="admin-settings-stack admin-intercept-settings">
<div className={cn("admin-intercept-command-bar", dirty && "is-dirty")}>
<div className="admin-intercept-command-copy" aria-live="polite">
@@ -300,20 +284,33 @@ export default function ResponseInterceptionSettingsPage() {
</div>
) : null}
<div className="admin-intercept-overview" aria-label="模型响应拦截配置概览">
<AdminStatTile label="全局拦截" value={enabled ? "已启用" : "已停用"} detail={dirty ? "暂存状态预览" : enabled && !draft.rules.length ? "已启用但当前没有规则" : "服务端当前状态"} />
<AdminStatTile label="有效规则" value={`${draft.rules.length} 条`} detail={duplicateRuleIndexes.size ? `${duplicateRuleIndexes.size} 条存在重复匹配文案` : `上限 ${MAX_RULES} 条`} />
<AdminStatTile label="匹配方式" value="忽略大小写包含" detail="按列表顺序命中第一条" />
<AdminStatTile label="作用范围" value="用户可见错误" detail="原始响应与请求明细不改写" />
</div>
<div id="admin-intercept-policy" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-intercept-section admin-intercept-policy-section"
icon={<ShieldAlert className="size-4" aria-hidden="true" />}
title="全局拦截策略"
description="控制新产生的用户可见错误是否经过规则投影,规则本身在停用时仍会保留。"
status={<AdminStatusBadge label={dirty ? (enabled ? "待启用" : "待停用") : enabled ? "已启用" : "已停用"} tone={dirty ? "warning" : enabled ? "success" : "neutral"} />}
title="1. 是否替换用户可见的上游错误"
description="这是响应拦截的主开关。关闭时规则继续保留,但不会改写用户最终看到的错误文案。"
status={<AdminStatusBadge label={enabledDirty ? (enabled ? "待启用" : "待停用") : enabled ? "已启用" : "已停用"} tone={enabledDirty ? "warning" : enabled ? "success" : "neutral"} />}
footer={
!enabled ? (
<>
<div className="admin-intercept-footer-note">
<ShieldCheck className="size-4" aria-hidden="true" />
<span>关闭后规则仍保存在服务端,需要时可重新启用</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{dirty ? (
<Button icon={<RotateCcw className="size-4" />} disabled={saving} onClick={resetDraft}>
撤销
</Button>
) : null}
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || loading || refreshing} onClick={() => void submitSave()}>
保存修改
</Button>
</div>
</>
) : undefined
}
>
<div className="admin-intercept-policy-control">
<span className="admin-intercept-policy-icon">
@@ -326,7 +323,7 @@ export default function ResponseInterceptionSettingsPage() {
</div>
<div className="admin-intercept-policy-switch">
<span>{enabled ? "启用" : "停用"}</span>
<Switch checked={enabled} disabled={loading || refreshing || saving} aria-label="启用响应拦截规则,切换后立即保存" onChange={(checked) => void toggleEnabled(checked)} />
<Switch checked={enabled} disabled={loading || refreshing || saving} aria-label="启用响应拦截规则" onChange={changeEnabled} />
</div>
</div>
<div className="admin-intercept-context-note">
@@ -336,157 +333,161 @@ export default function ResponseInterceptionSettingsPage() {
</SettingsSectionCard>
</div>
<div id="admin-intercept-rules" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-intercept-section admin-intercept-rules-section"
icon={<ListOrdered className="size-4" aria-hidden="true" />}
title="规则与优先级"
description="越靠前的规则越先匹配;命中后停止继续检查。匹配文案最多 200 字,替换文案最多 500 字。"
status={<AdminStatusBadge label={`${draft.rules.length}/${MAX_RULES} 条`} tone={duplicateRuleIndexes.size ? "warning" : draft.rules.length ? "info" : "neutral"} />}
footer={
<>
<div className="admin-intercept-footer-note">
<BadgeCheck className="size-4" aria-hidden="true" />
<span>保存不会调用模型或制造测试错误 · 原始上游内容继续保留</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{dirty ? (
<Button icon={<RotateCcw className="size-4" />} disabled={saving} onClick={resetDraft}>
撤销
</Button>
) : null}
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || loading || refreshing} onClick={() => void submitSave()}>
保存修改
{enabled ? (
<>
<div id="admin-intercept-rules" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-intercept-section admin-intercept-rules-section"
icon={<ListOrdered className="size-4" aria-hidden="true" />}
title="2. 配置替换规则与优先级"
description="越靠前的规则越先匹配;命中后停止继续检查。匹配文案最多 200 字,替换文案最多 500 字。"
status={<AdminStatusBadge label={`${draft.rules.length}/${MAX_RULES} 条`} tone={duplicateRuleIndexes.size ? "warning" : draft.rules.length ? "info" : "neutral"} />}
footer={
<>
<div className="admin-intercept-footer-note">
<BadgeCheck className="size-4" aria-hidden="true" />
<span>保存不会调用模型或制造测试错误 · 原始上游内容继续保留</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{dirty ? (
<Button icon={<RotateCcw className="size-4" />} disabled={saving} onClick={resetDraft}>
撤销
</Button>
) : null}
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || loading || refreshing} onClick={() => void submitSave()}>
保存修改
</Button>
</div>
</>
}
>
<div className="admin-intercept-rule-toolbar">
<div>
<strong>按优先级从上到下排列</strong>
<p>重复或包含关系过宽的文案可能使后续规则无法命中,可通过箭头调整顺序并在下方本地预览。</p>
</div>
<Button icon={<Plus className="size-4" />} disabled={loading || refreshing || saving || rules.length >= MAX_RULES} onClick={addRule}>
添加规则
</Button>
</div>
</>
}
>
<div className="admin-intercept-rule-toolbar">
<div>
<strong>按优先级从上到下排列</strong>
<p>重复或包含关系过宽的文案可能使后续规则无法命中,可通过箭头调整顺序并在下方本地预览。</p>
</div>
<Button icon={<Plus className="size-4" />} disabled={loading || refreshing || saving || rules.length >= MAX_RULES} onClick={addRule}>
添加规则
</Button>
</div>
{duplicateRuleIndexes.size ? (
<div className="admin-intercept-duplicate-alert" role="alert">
<AlertTriangle className="size-4" aria-hidden="true" />
<span>存在忽略大小写后完全相同的匹配文案;相同文案只会命中最靠前的一条,请核对优先级或删除重复规则。</span>
</div>
) : null}
{duplicateRuleIndexes.size ? (
<div className="admin-intercept-duplicate-alert" role="alert">
<AlertTriangle className="size-4" aria-hidden="true" />
<span>存在忽略大小写后完全相同的匹配文案;相同文案只会命中最靠前的一条,请核对优先级或删除重复规则。</span>
</div>
) : null}
{rules.length ? (
<div className="admin-intercept-rule-table" aria-label="模型响应拦截规则">
<div className="admin-intercept-rule-header" aria-hidden="true">
<span>优先级</span>
<span>上游文案包含</span>
<span>整条替换为</span>
<span>操作</span>
</div>
<div className="admin-intercept-rule-list">
{rules.map((rule, index) => (
<div key={index} className={cn("admin-intercept-rule-row", duplicateRuleIndexes.has(index) && "has-duplicate")}>
<div className="admin-intercept-rule-index">
<span>{index + 1}</span>
<small>{index === 0 ? "最先" : `第 ${index + 1}`}</small>
</div>
<div className="admin-intercept-rule-field">
<Input
value={rule.contains}
maxLength={MAX_MATCH_RUNES}
showCount
disabled={loading || refreshing || saving}
aria-label={`第 ${index + 1} 条匹配文案`}
placeholder="例如:余额不足、HTTP 429"
onChange={(event) => updateRule(index, "contains", event.target.value)}
/>
<small>忽略英文大小写,使用包含判断。</small>
</div>
<div className="admin-intercept-rule-field">
<Input.TextArea
value={rule.replace}
maxLength={MAX_REPLACE_RUNES}
showCount
autoSize={{ minRows: 1, maxRows: 4 }}
disabled={loading || refreshing || saving}
aria-label={`第 ${index + 1} 条替换文案`}
placeholder="例如:服务暂时繁忙,请稍后重试"
onChange={(event) => updateRule(index, "replace", event.target.value)}
/>
<small>命中后整条替换,不是局部替换。</small>
</div>
<div className="admin-intercept-rule-actions">
<Button type="text" icon={<ArrowUp className="size-4" />} aria-label={`上移第 ${index + 1} 条规则`} disabled={loading || refreshing || saving || index === 0} onClick={() => moveRule(index, -1)} />
<Button
type="text"
icon={<ArrowDown className="size-4" />}
aria-label={`下移第 ${index + 1} 条规则`}
disabled={loading || refreshing || saving || index === rules.length - 1}
onClick={() => moveRule(index, 1)}
/>
<Button type="text" danger icon={<Trash2 className="size-4" />} aria-label={`删除第 ${index + 1} 条规则`} disabled={loading || refreshing || saving} onClick={() => removeRule(index)} />
</div>
{rules.length ? (
<div className="admin-intercept-rule-table" aria-label="模型响应拦截规则">
<div className="admin-intercept-rule-header" aria-hidden="true">
<span>优先级</span>
<span>上游文案包含</span>
<span>整条替换为</span>
<span>操作</span>
</div>
))}
</div>
</div>
) : (
<div className="admin-intercept-empty">
<span>
<Search className="size-5" aria-hidden="true" />
</span>
<div>
<strong>还没有拦截规则</strong>
<p>用户可见错误会保持原样。添加规则后可先在本地预览,再决定是否保存和启用。</p>
</div>
<Button icon={<Plus className="size-4" />} disabled={loading || refreshing || saving} onClick={addRule}>
添加第一条规则
</Button>
</div>
)}
</SettingsSectionCard>
</div>
<div id="admin-intercept-preview" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-intercept-section admin-intercept-preview-section"
icon={<Eye className="size-4" aria-hidden="true" />}
title="本地命中预览"
description="使用当前草稿模拟服务端的裁剪、忽略大小写包含和首条命中逻辑;内容不会发送或保存。"
status={<AdminStatusBadge label="仅当前浏览器" tone="neutral" />}
>
<div className="admin-intercept-preview-grid">
<div className="admin-intercept-preview-input">
<label htmlFor="admin-intercept-preview-input">模拟上游错误文案</label>
<Input.TextArea id="admin-intercept-preview-input" value={previewInput} autoSize={{ minRows: 4, maxRows: 8 }} placeholder="粘贴一段用于本地预览的错误文案" onChange={(event) => setPreviewInput(event.target.value)} />
<p>只在内存中计算,不进入保存请求、审计或请求明细。</p>
</div>
<div className="admin-intercept-preview-result" aria-live="polite">
<div className="admin-intercept-preview-heading">
<strong>用户最终可见文案</strong>
<AdminStatusBadge
label={!preview.raw ? "等待输入" : preview.matchIndex < 0 ? "未命中" : enabled ? `命中第 ${preview.matchIndex + 1} 条` : `可命中第 ${preview.matchIndex + 1} 条但全局停用`}
tone={!preview.raw || preview.matchIndex < 0 || !enabled ? "neutral" : "success"}
/>
</div>
<p className={cn(!preview.output && "is-placeholder")}>{preview.output || "输入模拟错误后,这里会显示按当前开关和规则计算的结果。"}</p>
<small>
{preview.matchIndex >= 0
? enabled
? "已按首条命中规则整条替换。"
: "规则可以命中,但全局开关停用,所以实际仍原样显示。"
: preview.raw
? "没有规则包含该文案,因此保持原样。"
: "预览不验证真实上游响应,也不会触发模型请求。"}
</small>
</div>
<div className="admin-intercept-rule-list">
{rules.map((rule, index) => (
<div key={index} className={cn("admin-intercept-rule-row", duplicateRuleIndexes.has(index) && "has-duplicate")}>
<div className="admin-intercept-rule-index">
<span>{index + 1}</span>
<small>{index === 0 ? "最先" : `第 ${index + 1}`}</small>
</div>
<div className="admin-intercept-rule-field">
<Input
value={rule.contains}
maxLength={MAX_MATCH_RUNES}
showCount
disabled={loading || refreshing || saving}
aria-label={`第 ${index + 1} 条匹配文案`}
placeholder="例如:余额不足、HTTP 429"
onChange={(event) => updateRule(index, "contains", event.target.value)}
/>
<small>忽略英文大小写,使用包含判断。</small>
</div>
<div className="admin-intercept-rule-field">
<Input.TextArea
value={rule.replace}
maxLength={MAX_REPLACE_RUNES}
showCount
autoSize={{ minRows: 1, maxRows: 4 }}
disabled={loading || refreshing || saving}
aria-label={`第 ${index + 1} 条替换文案`}
placeholder="例如:服务暂时繁忙,请稍后重试"
onChange={(event) => updateRule(index, "replace", event.target.value)}
/>
<small>命中后整条替换,不是局部替换。</small>
</div>
<div className="admin-intercept-rule-actions">
<Button type="text" icon={<ArrowUp className="size-4" />} aria-label={`上移第 ${index + 1} 条规则`} disabled={loading || refreshing || saving || index === 0} onClick={() => moveRule(index, -1)} />
<Button
type="text"
icon={<ArrowDown className="size-4" />}
aria-label={`下移第 ${index + 1} 条规则`}
disabled={loading || refreshing || saving || index === rules.length - 1}
onClick={() => moveRule(index, 1)}
/>
<Button type="text" danger icon={<Trash2 className="size-4" />} aria-label={`删除第 ${index + 1} 条规则`} disabled={loading || refreshing || saving} onClick={() => removeRule(index)} />
</div>
</div>
))}
</div>
</div>
) : (
<div className="admin-intercept-empty">
<span>
<Search className="size-5" aria-hidden="true" />
</span>
<div>
<strong>还没有拦截规则</strong>
<p>用户可见错误会保持原样。添加规则后可先在本地预览,再决定是否保存和启用。</p>
</div>
<Button icon={<Plus className="size-4" />} disabled={loading || refreshing || saving} onClick={addRule}>
添加第一条规则
</Button>
</div>
)}
</SettingsSectionCard>
</div>
</SettingsSectionCard>
</div>
<div id="admin-intercept-preview" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-intercept-section admin-intercept-preview-section"
icon={<Eye className="size-4" aria-hidden="true" />}
title="3. 本地预览用户最终文案"
description="使用当前草稿模拟服务端的裁剪、忽略大小写包含和首条命中逻辑;内容不会发送或保存。"
status={<AdminStatusBadge label="仅当前浏览器" tone="neutral" />}
>
<div className="admin-intercept-preview-grid">
<div className="admin-intercept-preview-input">
<label htmlFor="admin-intercept-preview-input">模拟上游错误文案</label>
<Input.TextArea id="admin-intercept-preview-input" value={previewInput} autoSize={{ minRows: 4, maxRows: 8 }} placeholder="粘贴一段用于本地预览的错误文案" onChange={(event) => setPreviewInput(event.target.value)} />
<p>只在内存中计算,不进入保存请求、审计或请求明细。</p>
</div>
<div className="admin-intercept-preview-result" aria-live="polite">
<div className="admin-intercept-preview-heading">
<strong>用户最终可见文案</strong>
<AdminStatusBadge
label={!preview.raw ? "等待输入" : preview.matchIndex < 0 ? "未命中" : enabled ? `命中第 ${preview.matchIndex + 1} 条` : `可命中第 ${preview.matchIndex + 1} 条但全局停用`}
tone={!preview.raw || preview.matchIndex < 0 || !enabled ? "neutral" : "success"}
/>
</div>
<p className={cn(!preview.output && "is-placeholder")}>{preview.output || "输入模拟错误后,这里会显示按当前开关和规则计算的结果。"}</p>
<small>
{preview.matchIndex >= 0
? enabled
? "已按首条命中规则整条替换。"
: "规则可以命中,但全局开关停用,所以实际仍原样显示。"
: preview.raw
? "没有规则包含该文案,因此保持原样。"
: "预览不验证真实上游响应,也不会触发模型请求。"}
</small>
</div>
</div>
</SettingsSectionCard>
</div>
</>
) : null}
</div>
</AdminPageFrame>
);

View File

@@ -1,13 +1,13 @@
import { App, Button, Form, Input, Skeleton } from "antd";
import { AlertTriangle, BadgeCheck, Cloud, Database, Globe2, HardDrive, KeyRound, LocateFixed, RefreshCw, RotateCcw, Save, Server, ShieldCheck } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { App, Button, Form, Input, Select, Skeleton, Switch } from "antd";
import { AlertTriangle, BadgeCheck, Check, Cloud, Database, Globe2, HardDrive, KeyRound, LocateFixed, RefreshCw, RotateCcw, Save, Server, ShieldCheck, Wifi } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useBlocker } from "react-router";
import { changesRequireOSSRetest, DEFAULT_OSS_PATH_PREFIX, getS3PresetHints, S3_PRESET_OPTIONS, type OSSConnectionTestResult, type S3Preset } from "@/lib/oss-settings";
import { cn } from "@/lib/utils";
import { getAdminOSSSetting, updateAdminOSSSetting, type AdminOSSSetting } from "@/services/api/auth";
import { useAdminContext } from "../admin-context";
import { getAdminOSSSetting, testAdminOSSConnection, updateAdminOSSSetting, type AdminOSSSetting } from "@/services/api/auth";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminStatTile, AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "../components/admin-ui";
import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "../components/admin-ui";
type StorageMode = "local" | AdminOSSSetting["provider"];
type OSSFormValues = {
@@ -19,25 +19,32 @@ type OSSFormValues = {
bucket: string;
accessKeyId: string;
accessKeySecret: string;
sessionToken: string;
pathPrefix: string;
s3Preset: S3Preset;
pathStyle: boolean;
allowUserS3: boolean;
};
type StoragePayload = Pick<AdminOSSSetting, "enabled" | "provider" | "region" | "endpoint" | "cdnBaseUrl" | "bucket" | "accessKeyId" | "accessKeySecret" | "publicBaseUrl" | "pathPrefix">;
type StoragePayload = Pick<AdminOSSSetting, "enabled" | "provider" | "region" | "endpoint" | "cdnBaseUrl" | "bucket" | "accessKeyId" | "accessKeySecret" | "sessionToken" | "publicBaseUrl" | "pathPrefix" | "s3Preset" | "pathStyle" | "allowUserS3">;
const STORAGE_MODES: Array<{ mode: StorageMode; label: string; short: string; description: string }> = [
{ mode: "local", label: "服务器本地", short: "本地磁盘", description: "新增资源写入当前部署的数据目录,通过后端签名链接访问。" },
{ mode: "aliyun", label: "阿里云 OSS", short: "对象存储", description: "新增资源写入阿里云 Bucket,可选 CDN 域名读取。" },
{ mode: "tencent", label: "腾讯云 COS", short: "对象存储", description: "新增资源写入腾讯云 Bucket,可由 Region 生成 Endpoint。" },
{ mode: "qiniu", label: "七牛云 Kodo", short: "对象存储", description: "新增资源上传到 Kodo;无绑定域名时由后端代理读取。" },
{ mode: "s3", label: "S3 兼容存储", short: "对象存储", description: "支持 AWS S3、Cloudflare R2、Backblaze B2、RustFS 与自定义 S3 Endpoint。" },
];
export default function StorageSettingsPage() {
const { message, modal } = App.useApp();
const { references } = useAdminContext();
const [setting, setSetting] = useState<AdminOSSSetting | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<OSSConnectionTestResult | null>(null);
const [testStale, setTestStale] = useState(false);
const [dirty, setDirty] = useState(false);
const [draftMode, setDraftMode] = useState<StorageMode>("local");
const [loadError, setLoadError] = useState("");
@@ -46,7 +53,6 @@ export default function StorageSettingsPage() {
const requestVersionRef = useRef(0);
const navigationConfirmOpenRef = useRef(false);
const navigationTriggerRef = useRef<HTMLElement | null>(null);
const userNameById = useMemo(() => new Map(references.users.map((user) => [user.id, user.displayName || user.username])), [references.users]);
const load = useCallback(
async (initial = false, announce = false) => {
@@ -59,6 +65,8 @@ export default function StorageSettingsPage() {
if (requestVersion !== requestVersionRef.current) return;
if (!isAdminOSSSetting(result.setting)) throw new Error("服务端返回的存储配置格式无效");
setSetting(result.setting);
setTestResult(result.setting.testedAt ? { ok: true, testedAt: result.setting.testedAt, testedDigest: result.setting.testedDigest } : null);
setTestStale(false);
setDirty(false);
setSaveError("");
if (announce) message.success("已重新读取当前平台存储配置");
@@ -138,6 +146,8 @@ export default function StorageSettingsPage() {
setDraftMode(values.mode);
setDirty(false);
setSaveError("");
setTestResult(setting.testedAt ? { ok: true, testedAt: setting.testedAt, testedDigest: setting.testedDigest } : null);
setTestStale(false);
message.info("已撤销存储服务的未保存调整");
};
@@ -167,24 +177,14 @@ export default function StorageSettingsPage() {
form.setFields([]);
setDraftMode(nextMode);
setDirty(hasStorageChanges({ ...current, ...nextValues }, setting));
setTestStale(true);
setSaveError("");
return { ...current, ...nextValues } as OSSFormValues;
};
const requestModeChange = async (nextMode: StorageMode) => {
const requestModeChange = (nextMode: StorageMode) => {
if (!setting || nextMode === draftMode || saving || refreshing) return;
const values = applyMode(nextMode);
if (!values) return;
const validationError = validateStorageDraft(values, setting);
if (validationError) {
message.info(`${validationError};补全后点击保存修改即可生效`);
return;
}
try {
await save(values);
} catch {
// 保存错误已在 save 中就地提示。
}
applyMode(nextMode);
};
const save = async (values: OSSFormValues) => {
@@ -200,6 +200,8 @@ export default function StorageSettingsPage() {
form.setFieldsValue(nextValues);
setDraftMode(nextValues.mode);
setDirty(false);
setTestResult(result.setting.testedAt ? { ok: true, testedAt: result.setting.testedAt, testedDigest: result.setting.testedDigest } : null);
setTestStale(false);
message.success("平台存储配置已保存");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "保存存储配置失败";
@@ -231,20 +233,40 @@ export default function StorageSettingsPage() {
}
};
const testConnection = async () => {
if (!setting) return;
let values: OSSFormValues;
try {
values = await form.validateFields();
} catch {
return;
}
const validationError = validateStorageDraft(values, setting);
if (validationError) {
message.error(validationError);
return;
}
if (values.mode === "local") return;
setTesting(true);
try {
const result = await testAdminOSSConnection(connectionInput(values));
setTestResult(result);
setTestStale(false);
result.ok ? message.success(result.message || "连接测试通过") : message.error(result.message || "连接测试失败");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "连接测试失败";
setTestResult({ ok: false, message: errorMessage });
setTestStale(false);
message.error(errorMessage);
} finally {
setTesting(false);
}
};
if (loading && !setting) {
return (
<AdminPageFrame title="存储服务" description="配置新增资源的默认存储位置" scroll>
<div className="admin-settings-stack admin-storage-settings" aria-label="正在读取平台存储配置" role="status">
<div className="admin-storage-command-bar">
<Skeleton active title={{ width: 180 }} paragraph={false} />
</div>
<div className="admin-storage-overview">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="admin-stat-tile">
<Skeleton active title={{ width: 96 }} paragraph={{ rows: 1 }} />
</div>
))}
</div>
<div className="admin-storage-loading-card">
<Skeleton active paragraph={{ rows: 7 }} />
</div>
@@ -277,8 +299,6 @@ export default function StorageSettingsPage() {
const currentValues = form.getFieldsValue(true);
const normalizedDraft = normalizeStoragePayload(currentValues, setting);
const hasCurrentProviderSecret = draftMode !== "local" && setting.provider === draftMode && setting.hasAccessKeySecret;
const secretConfigured = draftMode === "local" || Boolean(normalizedDraft.accessKeySecret || hasCurrentProviderSecret);
const configOperator = setting.updatedBy ? userNameById.get(setting.updatedBy) || setting.updatedBy : "系统默认";
return (
<AdminPageFrame title="存储服务" description="配置新增资源的默认存储位置" scroll>
@@ -289,11 +309,8 @@ export default function StorageSettingsPage() {
<Database className="size-4" aria-hidden="true" />
</span>
<div>
<div className="flex flex-wrap items-center gap-2">
<strong>{dirty ? "平台存储有调整待保存" : "平台存储配置已同步"}</strong>
<AdminStatusBadge label={dirty ? "尚未生效" : "服务端当前值"} tone={dirty ? "warning" : "neutral"} />
</div>
<p>{dirty ? "当前选择和接入信息只在本页暂存;保存后仅影响新增资源。" : "页面显示新增资源的默认去向;已有资源保持原存储位置。"}</p>
<strong>{dirty ? "有未保存的存储调整" : `当前使用:${storageProviderLabel(draftMode)}`}</strong>
<p>{dirty ? "保存后仅影响新增资源。" : formatSettingTime(setting.updatedAt, "使用系统默认值")}</p>
</div>
</div>
<div className="admin-storage-command-actions">
@@ -315,28 +332,13 @@ export default function StorageSettingsPage() {
</div>
) : null}
<div className="admin-storage-overview" aria-label="平台存储配置概览">
<AdminStatTile label="新增资源存储" value={storageProviderLabel(draftMode)} detail={dirty ? "暂存状态预览" : formatSettingTime(setting.updatedAt, "使用系统默认值")} />
<AdminStatTile
label="读取链路"
value={storageDeliveryLabel(draftMode, normalizedDraft.cdnBaseUrl)}
detail={draftMode === "local" ? "经当前后端签名访问" : normalizedDraft.cdnBaseUrl ? "浏览器使用绑定或 CDN 域名" : "使用签名地址或后端代理"}
/>
<AdminStatTile
label="访问凭据"
value={secretConfigured ? (draftMode === "local" ? "无需云密钥" : "已配置") : "未配置"}
detail={draftMode === "local" ? "由当前部署管理资源" : hasCurrentProviderSecret && !normalizedDraft.accessKeySecret ? "保留服务端已有密钥" : "密钥仅保存在服务端"}
/>
<AdminStatTile label="配置来源" value={hasValidSettingTime(setting.updatedAt) ? "管理员配置" : "系统默认"} detail={hasValidSettingTime(setting.updatedAt) ? configOperator : "尚未保存平台存储配置"} />
</div>
<div id="admin-storage-mode" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-storage-section admin-storage-mode-section"
icon={<HardDrive className="size-4" aria-hidden="true" />}
title="新资源存储位置"
description="选择之后新增资源的默认写入位置,不迁移、覆盖或删除已有资源。"
status={<AdminStatusBadge label={dirty ? `待切换为${storageProviderLabel(draftMode)}` : storageProviderLabel(draftMode)} tone={dirty ? "warning" : "neutral"} />}
title="1. 选择新资源存储位置"
description="先选择一种存储方式。选择结果会决定下一步需要填写的接入信息,不会迁移已有资源。"
status={<AdminStatusBadge label={dirty ? "待保存" : "当前设置"} tone={dirty ? "warning" : "info"} />}
>
<div className="admin-storage-mode-content">
<div className="admin-storage-mode-grid" role="radiogroup" aria-label="新增资源存储位置">
@@ -348,20 +350,26 @@ export default function StorageSettingsPage() {
aria-checked={draftMode === item.mode}
className={cn("admin-storage-mode-choice", draftMode === item.mode && "is-selected")}
disabled={loading || refreshing || saving}
onClick={() => void requestModeChange(item.mode)}
onClick={() => requestModeChange(item.mode)}
>
<span className="admin-storage-mode-icon">{item.mode === "local" ? <HardDrive className="size-4" aria-hidden="true" /> : <Cloud className="size-4" aria-hidden="true" />}</span>
<span className="admin-storage-mode-copy">
<strong>{item.label}</strong>
<small>{item.description}</small>
</span>
<AdminStatusBadge label={draftMode === item.mode ? "当前选择" : item.short} tone={draftMode === item.mode ? "info" : "neutral"} />
<span className="admin-storage-mode-meta">{item.short}</span>
{draftMode === item.mode ? (
<span className="admin-storage-mode-selected">
<Check className="size-3.5" aria-hidden="true" />
已选择
</span>
) : null}
</button>
))}
</div>
<div className="admin-storage-history-note">
<ShieldCheck className="size-4" aria-hidden="true" />
<span>切换后只改变新增资源;历史资源仍按自身记录的 provider、Endpoint 和 Bucket 读取,云厂商切换时服务端会归档上一厂商凭据。</span>
<span>选择后继续完成第 2 步并保存。保存只改变新增资源;历史资源仍按自身记录的 provider、Endpoint 和 Bucket 读取。</span>
</div>
</div>
</SettingsSectionCard>
@@ -371,8 +379,8 @@ export default function StorageSettingsPage() {
<SettingsSectionCard
className="admin-storage-section admin-storage-configuration-section"
icon={draftMode === "local" ? <Server className="size-4" aria-hidden="true" /> : <Cloud className="size-4" aria-hidden="true" />}
title={draftMode === "local" ? "服务器本地访问" : `${storageProviderLabel(draftMode)} 接入配置`}
description={draftMode === "local" ? "设置浏览器访问本地资源时使用的服务器根地址。" : "配置新资源写入位置、读取出口和服务端访问密钥。"}
title={draftMode === "local" ? "2. 配置服务器本地访问" : `2. 配置 ${storageProviderLabel(draftMode)} 接入`}
description={draftMode === "local" ? "填写浏览器访问本地资源时使用的服务器根地址,然后保存。" : "按顺序填写存储位置、读取出口和服务端访问密钥,然后测试并保存。"}
status={
<AdminStatusBadge
label={dirty ? "有调整" : storageConfigurationReady(draftMode, normalizedDraft, setting) ? "已配置" : "待配置"}
@@ -383,7 +391,7 @@ export default function StorageSettingsPage() {
<>
<div className="admin-storage-footer-note">
<BadgeCheck className="size-4" aria-hidden="true" />
<span>{formatSettingTime(setting.updatedAt, "尚未保存平台存储配置")} · 保存不会连接存储服务或上传测试文件</span>
<span>{formatSettingTime(setting.updatedAt, "尚未保存平台存储配置")} · 保存不会自动连接存储服务,建议先执行连接测试</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{dirty ? (
@@ -403,10 +411,11 @@ export default function StorageSettingsPage() {
layout="vertical"
requiredMark={false}
disabled={loading || refreshing || saving}
onValuesChange={() => {
onValuesChange={(changedValues) => {
const values = form.getFieldsValue(true);
setDraftMode(values.mode || "local");
setDirty(hasStorageChanges(values, setting));
if (changesRequireOSSRetest(changedValues)) setTestStale(true);
setSaveError("");
}}
>
@@ -449,9 +458,27 @@ export default function StorageSettingsPage() {
<div className="admin-storage-form-section">
<FormSectionTitle icon={<Database className="size-4" />} title="存储位置" description="Bucket 决定容器,路径前缀用于隔离当前应用写入的对象目录。" />
{draftMode === "s3" ? (
<Form.Item name="s3Preset" label="S3 预设" extra={getS3PresetHints(form.getFieldValue("s3Preset") || "custom").help}>
<Select
options={S3_PRESET_OPTIONS}
onChange={(preset: S3Preset) => {
const hints = getS3PresetHints(preset);
form.setFieldsValue({ region: hints.region, endpoint: hints.endpoint });
setDirty(hasStorageChanges({ ...form.getFieldsValue(true), region: hints.region, endpoint: hints.endpoint, s3Preset: preset }, setting));
setTestStale(true);
}}
/>
</Form.Item>
) : null}
<div className="admin-storage-form-grid is-location">
<Form.Item name="region" label="Region" extra={draftMode === "tencent" ? "Endpoint 留空时由 Region 自动生成。" : draftMode === "qiniu" ? "无绑定域名时用于兼容 S3 的私有读取。" : "按云厂商控制台显示值填写。"}>
<Input autoComplete="off" placeholder={draftMode === "tencent" ? "ap-guangzhou" : draftMode === "qiniu" ? "z0 / cn-east-1" : "oss-cn-hangzhou"} />
<Input
autoComplete="off"
placeholder={
draftMode === "s3" ? getS3PresetHints(form.getFieldValue("s3Preset") || "custom").region : draftMode === "tencent" ? "ap-guangzhou" : draftMode === "qiniu" ? "z0 / cn-east-1" : "oss-cn-hangzhou"
}
/>
</Form.Item>
<Form.Item name="bucket" label="Bucket">
<Input autoComplete="off" placeholder={draftMode === "qiniu" ? "七牛云存储空间名称" : "对象存储 Bucket"} />
@@ -468,12 +495,26 @@ export default function StorageSettingsPage() {
<Form.Item
name="endpoint"
label={draftMode === "qiniu" ? "上传 Endpoint" : "Endpoint"}
extra={draftMode === "tencent" ? "可留空并由 Region 生成,也可填写完整 COS Endpoint。" : "必须填写完整的 http/https 地址,服务端会继续执行出站安全校验。"}
extra={
draftMode === "s3"
? getS3PresetHints(form.getFieldValue("s3Preset") || "custom").help
: draftMode === "tencent"
? "可留空并由 Region 生成,也可填写完整 COS Endpoint。"
: "必须填写完整的 http/https 地址,服务端会继续执行出站安全校验。"
}
>
<Input
autoComplete="off"
inputMode="url"
placeholder={draftMode === "tencent" ? "https://cos.ap-guangzhou.myqcloud.com" : draftMode === "qiniu" ? "https://up-z0.qiniup.com" : "https://oss-cn-hangzhou.aliyuncs.com"}
placeholder={
draftMode === "s3"
? getS3PresetHints(form.getFieldValue("s3Preset") || "custom").endpoint
: draftMode === "tencent"
? "https://cos.ap-guangzhou.myqcloud.com"
: draftMode === "qiniu"
? "https://up-z0.qiniup.com"
: "https://oss-cn-hangzhou.aliyuncs.com"
}
/>
</Form.Item>
<Form.Item
@@ -495,10 +536,39 @@ export default function StorageSettingsPage() {
<Form.Item name="accessKeySecret" label={hasCurrentProviderSecret ? `${accessKeySecretLabel(draftMode)}(${configuredSecretText})` : accessKeySecretLabel(draftMode)} extra="只在新增或替换当前厂商密钥时填写。">
<Input.Password autoComplete="new-password" placeholder={hasCurrentProviderSecret ? "留空保留原密钥" : accessKeySecretLabel(draftMode)} />
</Form.Item>
{draftMode === "s3" ? (
<Form.Item
name="sessionToken"
label={setting.provider === "s3" && setting.hasSessionToken ? `Session Token(${configuredSecretText})` : "Session Token(可选)"}
extra="使用临时凭证时填写;留空会保留当前 S3 提供方已有 Token。"
>
<Input.Password autoComplete="new-password" placeholder={setting.provider === "s3" && setting.hasSessionToken ? "留空保留原 Token" : "临时凭证 Session Token"} />
</Form.Item>
) : null}
</div>
{draftMode === "s3" ? (
<Form.Item name="pathStyle" label="Path Style" valuePropName="checked" extra="开启后强制使用 path-style;关闭时由后端按 Endpoint 自动选择。">
<Switch checkedChildren="强制" unCheckedChildren="自动" />
</Form.Item>
) : null}
</div>
<div className="admin-storage-form-section">
<FormSectionTitle icon={<Wifi className="size-4" />} title="连接验证" description="使用当前草稿执行最小读写测试;测试不会保存配置,也不会迁移已有资源。" />
<div className="flex flex-wrap items-center gap-3">
<Button icon={<Wifi className="size-4" />} loading={testing} disabled={saving || refreshing} onClick={() => void testConnection()}>
测试连接
</Button>
<ConnectionTestStatus result={testResult} stale={testStale} />
</div>
</div>
</>
)}
<div className="admin-storage-form-section">
<FormSectionTitle icon={<ShieldCheck className="size-4" />} title="用户自有存储" description="允许用户配置个人 S3 兼容存储;个人配置停用时仍回退到平台存储。" />
<Form.Item name="allowUserS3" label="允许个人 S3 兼容存储" valuePropName="checked">
<Switch checkedChildren="允许" unCheckedChildren="不允许" />
</Form.Item>
</div>
</Form>
</SettingsSectionCard>
</div>
@@ -529,7 +599,11 @@ function formValues(setting: AdminOSSSetting): OSSFormValues {
bucket: setting.bucket || "",
accessKeyId: setting.accessKeyId || "",
accessKeySecret: "",
pathPrefix: setting.pathPrefix || "",
sessionToken: "",
pathPrefix: setting.pathPrefix || DEFAULT_OSS_PATH_PREFIX,
s3Preset: setting.s3Preset || "custom",
pathStyle: setting.pathStyle === true,
allowUserS3: setting.allowUserS3 === true,
};
}
@@ -542,10 +616,24 @@ function providerDraftValues(mode: Exclude<StorageMode, "local">, setting: Admin
bucket: setting.bucket || "",
accessKeyId: setting.accessKeyId || "",
accessKeySecret: "",
sessionToken: "",
pathPrefix: setting.pathPrefix || pathPrefix || "",
s3Preset: setting.s3Preset || "custom",
pathStyle: setting.pathStyle === true,
};
}
return { region: "", endpoint: "", cdnBaseUrl: "", bucket: "", accessKeyId: "", accessKeySecret: "", pathPrefix: pathPrefix || "" };
return {
region: "",
endpoint: "",
cdnBaseUrl: "",
bucket: "",
accessKeyId: "",
accessKeySecret: "",
sessionToken: "",
pathPrefix: pathPrefix || DEFAULT_OSS_PATH_PREFIX,
s3Preset: "custom",
pathStyle: false,
};
}
function normalizeStoragePayload(values: Partial<OSSFormValues>, setting: AdminOSSSetting): StoragePayload {
@@ -563,8 +651,12 @@ function normalizeStoragePayload(values: Partial<OSSFormValues>, setting: AdminO
bucket: values.bucket?.trim() || "",
accessKeyId: values.accessKeyId?.trim() || "",
accessKeySecret: values.accessKeySecret?.trim() || "",
sessionToken: values.sessionToken?.trim() || "",
publicBaseUrl: trimTrailingSlash(values.publicBaseUrl || ""),
pathPrefix: (values.pathPrefix?.trim() || "").replace(/^\/+|\/+$/g, ""),
pathPrefix: (values.pathPrefix?.trim() || DEFAULT_OSS_PATH_PREFIX).replace(/^\/+|\/+$/g, ""),
s3Preset: values.s3Preset || "custom",
pathStyle: values.pathStyle === true,
allowUserS3: values.allowUserS3 === true,
};
}
@@ -572,15 +664,17 @@ function hasStorageChanges(values: Partial<OSSFormValues>, setting: AdminOSSSett
if (!setting) return false;
const draft = normalizeStoragePayload(values, setting);
const saved = normalizeStoragePayload(formValues(setting), setting);
if (draft.accessKeySecret) return true;
return (Object.keys(saved) as Array<keyof StoragePayload>).some((key) => key !== "accessKeySecret" && draft[key] !== saved[key]);
if (draft.accessKeySecret || draft.sessionToken) return true;
return (Object.keys(saved) as Array<keyof StoragePayload>).some((key) => key !== "accessKeySecret" && key !== "sessionToken" && draft[key] !== saved[key]);
}
function validateStorageDraft(values: OSSFormValues, setting: AdminOSSSetting) {
const draft = normalizeStoragePayload(values, setting);
if (!draft.enabled) return validatePublicBaseURL(draft.publicBaseUrl);
if (!draft.bucket) return "请填写对象存储 Bucket";
if (!draft.endpoint) return draft.provider === "tencent" ? "请填写腾讯云 COS Region 或 Endpoint" : draft.provider === "qiniu" ? "请填写七牛云 Kodo 上传 Endpoint" : "请填写阿里云 OSS Endpoint";
if (!draft.endpoint)
return draft.provider === "tencent" ? "请填写腾讯云 COS Region 或 Endpoint" : draft.provider === "qiniu" ? "请填写七牛云 Kodo 上传 Endpoint" : draft.provider === "s3" ? "请填写 S3 Endpoint 服务根 URL" : "请填写阿里云 OSS Endpoint";
if (draft.provider === "s3" && !draft.region) return "请填写 S3 Region";
if (!isHTTPURL(draft.endpoint)) return "Endpoint 必须是完整的 http/https 地址";
if (draft.cdnBaseUrl && !isValidCDNBaseURL(draft.cdnBaseUrl)) return "CDN 或绑定域名只能填写 http/https 根地址,不能包含认证、路径、查询参数或片段";
if (!draft.accessKeyId) return `请填写 ${accessKeyIdLabel(draft.provider)}`;
@@ -603,8 +697,9 @@ function validatePublicBaseURL(value: string) {
function storageResponseMatches(setting: AdminOSSSetting, expected: StoragePayload) {
const actual = normalizeStoragePayload(formValues(setting), setting);
const fields: Array<keyof StoragePayload> = ["enabled", "provider", "region", "endpoint", "cdnBaseUrl", "bucket", "accessKeyId", "publicBaseUrl", "pathPrefix"];
const fields: Array<keyof StoragePayload> = ["enabled", "provider", "region", "endpoint", "cdnBaseUrl", "bucket", "accessKeyId", "publicBaseUrl", "pathPrefix", "s3Preset", "pathStyle", "allowUserS3"];
if (expected.accessKeySecret && !setting.hasAccessKeySecret) return false;
if (expected.sessionToken && !setting.hasSessionToken) return false;
return fields.every((key) => actual[key] === expected[key]);
}
@@ -613,13 +708,17 @@ function isAdminOSSSetting(value: unknown): value is AdminOSSSetting {
const setting = value as Partial<AdminOSSSetting>;
return (
typeof setting.enabled === "boolean" &&
["aliyun", "tencent", "qiniu"].includes(setting.provider || "") &&
["aliyun", "tencent", "qiniu", "s3"].includes(setting.provider || "") &&
["aws", "r2", "b2", "rustfs", "custom"].includes(setting.s3Preset || "") &&
typeof setting.region === "string" &&
typeof setting.endpoint === "string" &&
typeof setting.cdnBaseUrl === "string" &&
typeof setting.bucket === "string" &&
typeof setting.accessKeyId === "string" &&
typeof setting.hasAccessKeySecret === "boolean" &&
typeof setting.hasSessionToken === "boolean" &&
typeof setting.pathStyle === "boolean" &&
typeof setting.allowUserS3 === "boolean" &&
typeof setting.publicBaseUrl === "string" &&
typeof setting.pathPrefix === "string"
);
@@ -631,16 +730,11 @@ function storageConfigurationReady(mode: StorageMode, values: StoragePayload, se
}
function storageProviderLabel(provider?: StorageMode) {
return provider === "tencent" ? "腾讯云 COS" : provider === "qiniu" ? "七牛云 Kodo" : provider === "aliyun" ? "阿里云 OSS" : "服务器本地";
}
function storageDeliveryLabel(mode: StorageMode, cdnBaseUrl: string) {
if (mode === "local") return "后端签名链接";
if (cdnBaseUrl) return mode === "qiniu" ? "绑定域名" : "CDN 域名";
return mode === "qiniu" ? "后端代理" : "对象存储签名";
return provider === "s3" ? "S3 兼容存储" : provider === "tencent" ? "腾讯云 COS" : provider === "qiniu" ? "七牛云 Kodo" : provider === "aliyun" ? "阿里云 OSS" : "服务器本地";
}
function providerGuidance(mode: Exclude<StorageMode, "local">) {
if (mode === "s3") return "使用预设快速填写 Region 与 Endpoint,也可以选择自定义;自托管 S3 仍受服务端私网主机白名单约束。";
if (mode === "tencent") return "腾讯云可只填写 Region,由服务端生成标准 COS Endpoint;也可填写完整 Endpoint 覆盖。";
if (mode === "qiniu") return "七牛上传必须配置上传 Endpoint;绑定域名可选,留空时资源由当前后端使用 AK/SK 代理读取。";
return "阿里云需要完整 OSS Endpoint、Bucket 和访问密钥;CDN 域名可选。";
@@ -676,6 +770,29 @@ function trimTrailingSlash(value: string) {
return value.trim().replace(/\/+$/, "");
}
function connectionInput(values: OSSFormValues) {
return {
provider: values.mode === "local" ? ("aliyun" as const) : values.mode,
s3Preset: values.s3Preset,
region: values.region.trim(),
endpoint: trimTrailingSlash(values.endpoint),
cdnBaseUrl: trimTrailingSlash(values.cdnBaseUrl),
bucket: values.bucket.trim(),
accessKeyId: values.accessKeyId.trim(),
accessKeySecret: values.accessKeySecret.trim(),
sessionToken: values.sessionToken.trim(),
pathPrefix: (values.pathPrefix.trim() || DEFAULT_OSS_PATH_PREFIX).replace(/^\/+|\/+$/g, ""),
pathStyle: values.pathStyle === true,
};
}
function ConnectionTestStatus({ result, stale }: { result: OSSConnectionTestResult | null; stale: boolean }) {
if (stale) return <AdminStatusBadge label="配置已变化,请重新测试" tone="warning" />;
if (!result) return <AdminStatusBadge label="尚未测试" tone="neutral" />;
if (result.ok) return <AdminStatusBadge label={result.testedAt ? `测试通过 · ${formatSettingTime(result.testedAt, "刚刚")}` : "测试通过"} tone="success" />;
return <AdminStatusBadge label={result.message || "测试失败"} tone="error" />;
}
function hasValidSettingTime(value?: string) {
if (!value) return false;
const date = new Date(value);