feat(admin): 管理后台 - 完善配置运营页面并修复导航、筛选与积分计费
This commit is contained in:
@@ -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 })}`;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user