Files
ttstd-infinite-canvas/web/src/pages/admin/settings/ark-private-assets-settings-page.tsx

393 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { App, Button, Form, Input, Skeleton, Switch } from "antd";
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 { AdminPageFrame } from "../components/admin-shell";
import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "../components/admin-ui";
type ArkPrivateAssetForm = {
enabled: boolean;
region: string;
projectName: string;
accessKeyId: string;
accessKeySecret: string;
};
type ArkPrivateAssetPayload = Pick<AdminArkPrivateAssetSetting, "enabled" | "region" | "projectName" | "accessKeyId" | "accessKeySecret">;
export default function ArkPrivateAssetsSettingsPage() {
const { message, modal } = App.useApp();
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 [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 watchedValues = Form.useWatch([], form) as Partial<ArkPrivateAssetForm> | undefined;
const draftEnabled = watchedValues?.enabled === true;
const load = useCallback(
async (initial = false, announce = false) => {
const requestVersion = ++requestVersionRef.current;
if (initial) setLoading(true);
else setRefreshing(true);
setLoadError("");
try {
const result = await getAdminArkPrivateAssetSetting();
if (requestVersion !== requestVersionRef.current) return;
if (!isAdminArkPrivateAssetSetting(result.setting)) throw new Error("服务端返回的方舟素材库配置格式无效");
setSetting(result.setting);
setDirty(false);
setSaveError("");
if (announce) message.success("已重新读取方舟素材库配置");
} catch (error) {
if (requestVersion !== requestVersionRef.current) return;
const errorMessage = error instanceof Error ? error.message : "读取方舟素材库配置失败";
setLoadError(errorMessage);
if (!initial) message.error(errorMessage);
} finally {
if (requestVersion === requestVersionRef.current) {
setLoading(false);
setRefreshing(false);
}
}
},
[message],
);
useEffect(() => {
void load(true);
return () => {
requestVersionRef.current += 1;
};
}, [load]);
useEffect(() => {
if (loading || !setting) return;
form.setFieldsValue(toFormValues(setting));
}, [form, loading, setting]);
const blocker = useBlocker(dirty && !saving);
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
if (!dirty || saving) return;
event.preventDefault();
};
window.addEventListener("beforeunload", beforeUnload);
return () => window.removeEventListener("beforeunload", beforeUnload);
}, [dirty, saving]);
useEffect(() => {
if (blocker.state !== "blocked" || navigationConfirmOpenRef.current) return;
navigationConfirmOpenRef.current = true;
navigationTriggerRef.current = document.activeElement instanceof HTMLElement && document.activeElement !== document.body ? document.activeElement : null;
modal.confirm({
title: "放弃方舟素材库调整?",
content: "当前页面有尚未保存的同步策略、项目或 IAM 凭据草稿,离开后这些内容会丢失。服务端正在使用的配置不会改变。",
okText: "放弃并离开",
cancelText: "继续编辑",
okButtonProps: { danger: true },
onOk: () => {
navigationConfirmOpenRef.current = false;
navigationTriggerRef.current = null;
blocker.proceed();
},
onCancel: () => {
navigationConfirmOpenRef.current = false;
blocker.reset();
window.requestAnimationFrame(() => {
const fallback = document.querySelector<HTMLButtonElement>(".admin-ark-command-actions button");
const target = navigationTriggerRef.current?.isConnected ? navigationTriggerRef.current : fallback;
target?.focus();
navigationTriggerRef.current = null;
});
},
});
}, [blocker, modal]);
const resetDraft = () => {
if (!setting || saving) return;
const values = toFormValues(setting);
form.setFieldsValue(values);
form.setFields([]);
setDirty(false);
setSaveError("");
message.info("已撤销方舟素材库的未保存调整");
};
const requestRefresh = () => {
if (!dirty) {
void load(false, true);
return;
}
modal.confirm({
title: "放弃调整并重新读取?",
content: "重新读取会丢弃当前同步策略、项目和 IAM 凭据草稿,并以服务端配置为准。",
okText: "放弃并刷新",
cancelText: "继续编辑",
okButtonProps: { danger: true },
onOk: () => load(false, true),
});
};
const save = async (values: ArkPrivateAssetForm) => {
if (!setting) return;
const expected = normalizeArkPrivateAssetPayload(values);
const expectedHasSecret = nextSecretPresence(expected, setting);
setSaving(true);
setSaveError("");
try {
const result = await updateAdminArkPrivateAssetSetting(expected);
if (!isAdminArkPrivateAssetSetting(result.setting) || !arkPrivateAssetResponseMatches(result.setting, expected, expectedHasSecret)) {
throw new Error("服务端返回的方舟素材库配置与本次保存内容不一致,请重新读取后核对");
}
setSetting(result.setting);
const nextValues = toFormValues(result.setting);
form.setFieldsValue(nextValues);
setDirty(false);
message.success("方舟素材库配置已保存");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "保存方舟素材库配置失败";
setSaveError(`${errorMessage}。未自动重试,请重新读取当前配置后再决定是否保存。`);
message.error(errorMessage);
throw error;
} finally {
setSaving(false);
}
};
const submitSave = async () => {
if (!setting) return;
let values: ArkPrivateAssetForm;
try {
values = await form.validateFields();
} catch {
return;
}
const validationError = validateArkPrivateAssetDraft(values, setting);
if (validationError) {
message.error(validationError);
return;
}
try {
await save(values);
} catch {
// 保存错误已在 save 中就地提示。
}
};
if (loading && !setting) {
return (
<AdminPageFrame title="方舟素材库" description="为 Seedance 参考图配置后端可信素材导入" scroll>
<div className="admin-settings-stack admin-ark-settings" aria-label="正在读取方舟素材库配置" role="status">
<div className="admin-ark-command-bar">
<Skeleton active title={{ width: 190 }} paragraph={false} />
</div>
<div className="admin-ark-loading-card">
<Skeleton active paragraph={{ rows: 7 }} />
</div>
</div>
</AdminPageFrame>
);
}
if (!setting) {
return (
<AdminPageFrame title="方舟素材库" description="为 Seedance 参考图配置后端可信素材导入" scroll>
<div className="admin-settings-stack admin-ark-settings">
<div className="admin-ark-load-error" role="alert">
<span className="admin-ark-load-error-icon">
<AlertTriangle className="size-5" aria-hidden="true" />
</span>
<div>
<h2>无法读取方舟素材库配置</h2>
<p>{loadError || "当前没有可显示的配置,请稍后重试。"}</p>
</div>
<Button icon={<RefreshCw className="size-4" />} loading={refreshing} onClick={() => void load(false, true)}>
重新读取
</Button>
</div>
</div>
</AdminPageFrame>
);
}
const currentValues = watchedValues || form.getFieldsValue(true);
const normalizedDraft = normalizeArkPrivateAssetPayload(currentValues);
const usableSecret = hasUsableSecret(normalizedDraft, setting);
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">
<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">
{dirty ? (
<Button icon={<RotateCcw className="size-4" />} disabled={saving} onClick={resetDraft}>
撤销调整
</Button>
) : null}
<Button icon={<RefreshCw className="size-4" />} loading={refreshing} disabled={saving} onClick={requestRefresh}>
刷新状态
</Button>
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || loading || refreshing} onClick={() => void submitSave()}>
保存修改
</Button>
</div>
</div>
{loadError || saveError ? (
<div className="admin-ark-inline-alert" role="alert">
<AlertTriangle className="size-4 shrink-0" aria-hidden="true" />
<span>{saveError || `${loadError}。页面仍显示上一次成功读取的配置。`}</span>
</div>
) : null}
<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 };
}
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"} />}
>
<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>
{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>
</SettingsSectionCard>
</div>
) : null}
</Form>
</div>
</AdminPageFrame>
);
}
function toFormValues(setting: AdminArkPrivateAssetSetting): ArkPrivateAssetForm {
return {
enabled: setting.enabled,
region: setting.region || "",
projectName: setting.projectName || "",
accessKeyId: setting.accessKeyId || "",
accessKeySecret: "",
};
}
function normalizeArkPrivateAssetPayload(values: Partial<ArkPrivateAssetForm>): ArkPrivateAssetPayload {
return {
enabled: Boolean(values.enabled),
region: values.region?.trim() || "",
projectName: values.projectName?.trim() || "",
accessKeyId: values.accessKeyId?.trim() || "",
accessKeySecret: values.accessKeySecret?.trim() || "",
};
}
function hasArkPrivateAssetChanges(values: Partial<ArkPrivateAssetForm>, setting: AdminArkPrivateAssetSetting | null) {
if (!setting) return false;
const draft = normalizeArkPrivateAssetPayload(values);
const saved = normalizeArkPrivateAssetPayload(toFormValues(setting));
return draft.accessKeySecret !== "" || draft.enabled !== saved.enabled || draft.region !== saved.region || draft.projectName !== saved.projectName || draft.accessKeyId !== saved.accessKeyId;
}
function validateArkPrivateAssetDraft(values: ArkPrivateAssetForm, setting: AdminArkPrivateAssetSetting) {
const draft = normalizeArkPrivateAssetPayload(values);
if (!draft.enabled) return "";
if (!draft.region) return "请填写方舟 Region";
if (!draft.projectName) return "请填写方舟 ProjectName";
if (!draft.accessKeyId) return "请填写方舟素材库 IAM AccessKey";
if (!hasUsableSecret(draft, setting)) return draft.accessKeyId !== setting.accessKeyId ? "更换 IAM AccessKey 时请同时填写匹配的 SecretKey" : "请填写方舟素材库 IAM SecretKey";
return "";
}
function hasUsableSecret(values: ArkPrivateAssetPayload, setting: AdminArkPrivateAssetSetting) {
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;
}
function arkPrivateAssetResponseMatches(setting: AdminArkPrivateAssetSetting, expected: ArkPrivateAssetPayload, expectedHasSecret: boolean) {
return setting.enabled === expected.enabled && setting.region === expected.region && setting.projectName === expected.projectName && setting.accessKeyId === expected.accessKeyId && setting.hasAccessKeySecret === expectedHasSecret;
}
function isAdminArkPrivateAssetSetting(value: unknown): value is AdminArkPrivateAssetSetting {
if (!value || typeof value !== "object") return false;
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";
}