import { App, Button, Form, Input, Skeleton } from "antd"; import { Select } from "@/pages/admin/ui/controls"; import { Switch } from "@/pages/admin/ui/controls"; 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, normalizeOSSConnectionTestInput, S3_PRESET_OPTIONS, type OSSConnectionTestResult, type S3Preset } from "@/lib/oss-settings"; import { cn } from "@/lib/utils"; import { getAdminOSSSetting, testAdminOSSConnection, updateAdminOSSSetting, type AdminOSSSetting } from "@/services/api/auth"; import { useAppearanceStore } from "@/stores/use-appearance-store"; import { AdminPageFrame } from "../components/admin-shell"; import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "../components/admin-ui"; type StorageMode = "local" | AdminOSSSetting["provider"]; type OSSFormValues = { mode: StorageMode; publicBaseUrl: string; region: string; endpoint: string; cdnBaseUrl: string; cdnAuthMode: "" | "public" | "qiniu" | string; requireCDN: boolean; allowPrivateProxy: boolean; 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" | "cdnAuthMode" | "requireCDN" | "allowPrivateProxy" | "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 brandSlug = useAppearanceStore((state) => state.appearance.brandSlug); const [setting, setSetting] = useState(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(null); const [testStale, setTestStale] = useState(false); const [dirty, setDirty] = useState(false); const [draftMode, setDraftMode] = useState("local"); const [loadError, setLoadError] = useState(""); const [saveError, setSaveError] = useState(""); const [form] = Form.useForm(); const requestVersionRef = useRef(0); const navigationConfirmOpenRef = useRef(false); const navigationTriggerRef = useRef(null); const load = useCallback( async (initial = false, announce = false) => { const requestVersion = ++requestVersionRef.current; if (initial) setLoading(true); else setRefreshing(true); setLoadError(""); try { const result = await getAdminOSSSetting(); 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("已重新读取当前平台存储配置"); } 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; const values = formValues(setting); form.setFieldsValue(values); setDraftMode(values.mode); }, [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: "当前页面有尚未保存的存储位置或接入配置,离开后这些草稿会丢失。服务端正在使用的存储配置不会改变。", 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(".admin-storage-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 = formValues(setting); form.setFieldsValue(values); form.setFields([]); setDraftMode(values.mode); setDirty(false); setSaveError(""); setTestResult(setting.testedAt ? { ok: true, testedAt: setting.testedAt, testedDigest: setting.testedDigest } : null); setTestStale(false); message.info("已撤销存储服务的未保存调整"); }; const requestRefresh = () => { if (!dirty) { void load(false, true); return; } modal.confirm({ title: "放弃调整并重新读取?", content: "重新读取会丢弃当前存储表单中的未保存内容,并以服务端配置为准。", okText: "放弃并刷新", cancelText: "继续编辑", okButtonProps: { danger: true }, onOk: () => load(false, true), }); }; const applyMode = (nextMode: StorageMode): OSSFormValues | null => { if (!setting) return null; const current = form.getFieldsValue(true); const nextValues: Partial = { mode: nextMode }; if (nextMode !== "local") { Object.assign(nextValues, providerDraftValues(nextMode, setting, current.pathPrefix)); } form.setFieldsValue(nextValues); form.setFields([]); setDraftMode(nextMode); setDirty(hasStorageChanges({ ...current, ...nextValues }, setting)); setTestStale(true); setSaveError(""); return { ...current, ...nextValues } as OSSFormValues; }; const requestModeChange = (nextMode: StorageMode) => { if (!setting || nextMode === draftMode || saving || refreshing) return; applyMode(nextMode); }; const useBrandPathPrefix = () => { if (!setting || saving || refreshing) return; const current = form.getFieldsValue(true); form.setFieldValue("pathPrefix", brandSlug); setDirty(hasStorageChanges({ ...current, pathPrefix: brandSlug }, setting)); setTestStale(true); setSaveError(""); }; const save = async (values: OSSFormValues) => { if (!setting) return; const expected = normalizeStoragePayload(values, setting); setSaving(true); setSaveError(""); try { const result = await updateAdminOSSSetting(expected); if (!isAdminOSSSetting(result.setting) || !storageResponseMatches(result.setting, expected)) throw new Error("服务端返回的存储配置与本次保存内容不一致,请重新读取后核对"); setSetting(result.setting); const nextValues = formValues(result.setting); 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 : "保存存储配置失败"; setSaveError(`${errorMessage}。未自动重试,请重新读取当前配置后再决定是否保存。`); message.error(errorMessage); throw error; } finally { setSaving(false); } }; const submitSave = 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; } try { await save(values); } catch { // 保存错误已在 save 中就地提示。 } }; 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 (
); } if (!setting) { return (

无法读取平台存储配置

{loadError || "当前没有可显示的配置,请稍后重试。"}

); } const currentValues = form.getFieldsValue(true); const normalizedDraft = normalizeStoragePayload(currentValues, setting); const hasCurrentProviderSecret = draftMode !== "local" && setting.provider === draftMode && setting.hasAccessKeySecret; return (
{dirty ? "有未保存的存储调整" : `当前使用:${storageProviderLabel(draftMode)}`}

{dirty ? "保存后仅影响新增资源。" : formatSettingTime(setting.updatedAt, "使用系统默认值")}

{dirty ? ( ) : null}
{loadError || saveError ? (
) : null}
} title="连接与读取出口" description="Endpoint 用于服务端写入;CDN 或绑定域名只决定浏览器读取出口。" />
{draftMode === "s3" ? ( ) : null}
{draftMode === "s3" ? ( ) : null}
} title="连接验证" description="使用当前草稿执行最小读写测试;测试不会保存配置,也不会迁移已有资源。" />
)}
} title="用户自有存储" description="允许用户配置个人 S3 兼容存储;个人配置停用时仍回退到平台存储。" />
); } function FormSectionTitle({ icon, title, description }: { icon: ReactNode; title: string; description: string }) { return (
{icon}

{title}

{description}

); } function formValues(setting: AdminOSSSetting): OSSFormValues { return { mode: setting.enabled ? setting.provider : "local", publicBaseUrl: setting.publicBaseUrl || "", region: setting.region || "", endpoint: setting.endpoint || "", cdnBaseUrl: setting.cdnBaseUrl || "", cdnAuthMode: setting.cdnAuthMode || "", requireCDN: setting.requireCDN === true, allowPrivateProxy: setting.allowPrivateProxy === true, bucket: setting.bucket || "", accessKeyId: setting.accessKeyId || "", accessKeySecret: "", sessionToken: "", pathPrefix: setting.pathPrefix || DEFAULT_OSS_PATH_PREFIX, s3Preset: setting.s3Preset || "custom", pathStyle: setting.pathStyle === true, allowUserS3: setting.allowUserS3 === true, }; } function providerDraftValues(mode: Exclude, setting: AdminOSSSetting, pathPrefix: string): Partial { if (mode === setting.provider) { return { region: setting.region || "", endpoint: setting.endpoint || "", cdnBaseUrl: setting.cdnBaseUrl || "", cdnAuthMode: setting.cdnAuthMode || "", requireCDN: setting.requireCDN === true, allowPrivateProxy: setting.allowPrivateProxy === true, bucket: setting.bucket || "", accessKeyId: setting.accessKeyId || "", accessKeySecret: "", sessionToken: "", pathPrefix: setting.pathPrefix || pathPrefix || "", s3Preset: setting.s3Preset || "custom", pathStyle: setting.pathStyle === true, }; } return { region: "", endpoint: "", cdnBaseUrl: "", cdnAuthMode: "", requireCDN: false, allowPrivateProxy: false, bucket: "", accessKeyId: "", accessKeySecret: "", sessionToken: "", pathPrefix: pathPrefix || DEFAULT_OSS_PATH_PREFIX, s3Preset: "custom", pathStyle: false, }; } function normalizeStoragePayload(values: Partial, setting: AdminOSSSetting): StoragePayload { const mode = values.mode || "local"; const provider = mode === "local" ? setting.provider || "aliyun" : mode; const region = values.region?.trim() || ""; let endpoint = trimTrailingSlash(values.endpoint || ""); if (provider === "tencent" && !endpoint && region) endpoint = `https://cos.${region}.myqcloud.com`; return { enabled: mode !== "local", provider, region, endpoint, cdnBaseUrl: trimTrailingSlash(values.cdnBaseUrl || ""), cdnAuthMode: values.cdnAuthMode || "", requireCDN: values.requireCDN === true, allowPrivateProxy: values.allowPrivateProxy === true, bucket: values.bucket?.trim() || "", accessKeyId: values.accessKeyId?.trim() || "", accessKeySecret: values.accessKeySecret?.trim() || "", sessionToken: values.sessionToken?.trim() || "", publicBaseUrl: trimTrailingSlash(values.publicBaseUrl || ""), pathPrefix: (values.pathPrefix?.trim() || DEFAULT_OSS_PATH_PREFIX).replace(/^\/+|\/+$/g, ""), s3Preset: values.s3Preset || "custom", pathStyle: values.pathStyle === true, allowUserS3: values.allowUserS3 === true, }; } function hasStorageChanges(values: Partial, setting: AdminOSSSetting | null) { if (!setting) return false; const draft = normalizeStoragePayload(values, setting); const saved = normalizeStoragePayload(formValues(setting), setting); if (draft.accessKeySecret || draft.sessionToken) return true; return (Object.keys(saved) as Array).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" : 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)}`; if (!draft.accessKeySecret && !(setting.provider === draft.provider && setting.hasAccessKeySecret)) return `请填写 ${accessKeySecretLabel(draft.provider)}`; return ""; } function validatePublicBaseURL(value: string) { if (!value) return "服务器本地存储需要填写服务器访问地址"; try { const parsed = new URL(value); if (!parsed.hostname || !["http:", "https:"].includes(parsed.protocol)) return "服务器访问地址必须是完整的 http/https 地址"; if (parsed.search || parsed.hash) return "服务器访问地址不能包含查询参数或片段"; if (parsed.pathname.replace(/\/+$/, "").endsWith("/api")) return "服务器访问地址请填写站点根地址,不要包含 /api"; return ""; } catch { return "服务器访问地址必须是完整的 http/https 地址"; } } function storageResponseMatches(setting: AdminOSSSetting, expected: StoragePayload) { const actual = normalizeStoragePayload(formValues(setting), setting); const fields: Array = ["enabled", "provider", "region", "endpoint", "cdnBaseUrl", "cdnAuthMode", "requireCDN", "allowPrivateProxy", "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]); } function isAdminOSSSetting(value: unknown): value is AdminOSSSetting { if (!value || typeof value !== "object") return false; const setting = value as Partial; return ( typeof setting.enabled === "boolean" && ["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.cdnAuthMode === "string" && typeof setting.requireCDN === "boolean" && typeof setting.allowPrivateProxy === "boolean" && 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" ); } function storageConfigurationReady(mode: StorageMode, values: StoragePayload, setting: AdminOSSSetting) { if (mode === "local") return !validatePublicBaseURL(values.publicBaseUrl); return !validateStorageDraft({ ...formValues(setting), ...values, mode }, setting); } function storageProviderLabel(provider?: StorageMode) { return provider === "s3" ? "S3 兼容存储" : provider === "tencent" ? "腾讯云 COS" : provider === "qiniu" ? "七牛云 Kodo" : provider === "aliyun" ? "阿里云 OSS" : "服务器本地"; } function providerGuidance(mode: Exclude) { 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 域名可选。"; } function accessKeyIdLabel(mode: Exclude | AdminOSSSetting["provider"]) { return mode === "tencent" ? "SecretId" : mode === "qiniu" ? "AccessKey" : "AccessKey ID"; } function accessKeySecretLabel(mode: Exclude | AdminOSSSetting["provider"]) { return mode === "tencent" || mode === "qiniu" ? "SecretKey" : "AccessKey Secret"; } function isHTTPURL(value: string) { try { const parsed = new URL(value); return Boolean(parsed.hostname) && ["http:", "https:"].includes(parsed.protocol); } catch { return false; } } function isValidCDNBaseURL(value: string) { try { const parsed = new URL(value); return Boolean(parsed.hostname) && ["http:", "https:"].includes(parsed.protocol) && !parsed.username && !parsed.password && !parsed.search && !parsed.hash && !parsed.pathname.replace(/\/+$/, ""); } catch { return false; } } function trimTrailingSlash(value: string) { return value.trim().replace(/\/+$/, ""); } function connectionInput(values: Partial) { return normalizeOSSConnectionTestInput({ provider: !values.mode || values.mode === "local" ? ("aliyun" as const) : values.mode, s3Preset: values.s3Preset, region: values.region, endpoint: values.endpoint, cdnBaseUrl: values.cdnBaseUrl, bucket: values.bucket, accessKeyId: values.accessKeyId, accessKeySecret: values.accessKeySecret, sessionToken: values.sessionToken, pathPrefix: values.pathPrefix, pathStyle: values.pathStyle === true, }); } function ConnectionTestStatus({ result, stale }: { result: OSSConnectionTestResult | null; stale: boolean }) { if (stale) return ; if (!result) return ; if (result.ok) return ; return ; } 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 })}`; }