refactor(*): 平台架构 - 拆分后端领域模块与画布前端组件并补充测试

This commit is contained in:
ddcat666
2026-09-30 10:01:39 +08:00
parent e0a2697c60
commit 7f5d87ef1a
186 changed files with 33727 additions and 29905 deletions

View File

@@ -1,32 +1,26 @@
import { App, Button, Form, Input, Skeleton, Tabs } from "antd";
import { Switch } from "@/pages/admin/ui/controls";
import { Copyright, Globe2, Image as ImageIcon, MonitorPlay, Moon, Palette, RefreshCw, RotateCcw, Save, Search, Sun, Type, Undo2, Upload } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import { useBlocker } from "react-router";
import { AdminPageFrame } from "@/pages/admin/components/admin-shell";
import { AdminStatusBadge, SettingsSectionCard } from "@/pages/admin/components/admin-ui";
import { cn } from "@/lib/utils";
import { cloneSkinDefinition, DEFAULT_CLASSIC_SKIN, duplicateSkinDefinition, isSkinButtonFill, normalizeSkinDefinition, type SkinDefinition } from "@/lib/skin-themes";
import { SkinThemeEditor } from "@/pages/admin/settings/components/skin-theme-editor";
import { WelcomeSetting } from "@/pages/admin/settings/components/welcome-setting";
import { CanvasAppearanceEditor } from "./components/canvas-appearance-editor";
import { DEFAULT_CANVAS_APPEARANCE, type CanvasAppearance } from "@/lib/canvas/agent-appearance";
import { deleteAdminResources } from "@/services/api/admin-storage";
import { getAdminAppearance, resetAdminAppearance, updateAdminAppearance, uploadAppearanceAsset, type AdminAppearance, type AppearanceAssetSlot } from "@/services/api/appearance";
import { commitPublicAppearance, DEFAULT_PUBLIC_APPEARANCE } from "@/stores/use-appearance-store";
import { deleteAdminResources } from "@/services/api/admin-storage";
import { AdminStatusBadge, SettingsSectionCard } from "@/pages/admin/components/admin-ui";
import { AdminPageFrame } from "@/pages/admin/components/admin-shell";
import { Palette, RefreshCw, Undo2, RotateCcw, Save, Sun, Moon, MonitorPlay, Globe2, Search, Copyright, Type } from "lucide-react";
import { cn } from "@/lib/utils";
import { Switch } from "@/pages/admin/ui/controls";
import { WelcomeSetting } from "@/pages/admin/settings/components/welcome-setting";
import { SkinThemeEditor } from "@/pages/admin/settings/components/skin-theme-editor";
import { CanvasAppearanceEditor } from "./components/canvas-appearance-editor";
import { App, Button, Tabs, Form, Input } from "antd";
import { useCallback, useEffect, useRef, useState } from "react";
import { useBlocker } from "react-router";
import { cloneSkinDefinition, DEFAULT_CLASSIC_SKIN, normalizeSkinDefinition, type SkinDefinition, duplicateSkinDefinition } from "@/lib/skin-themes";
import { DEFAULT_CANVAS_APPEARANCE, type CanvasAppearance } from "@/lib/canvas/agent-appearance";
import { getAdminAppearance, type AdminAppearance, type AppearanceAssetSlot, resetAdminAppearance, uploadAppearanceAsset, updateAdminAppearance } from "@/services/api/appearance";
import { normalizeDraftCopy, normalizeSingleLine, hasUnsupportedControlCharacter, validateSkinDrafts, useAppearancePreviews, AppearanceSkeleton, AssetPicker, LogoThemePreview } from "./appearance-settings-parts";
import { type DraftFiles, FILE_RULES, type ResetState } from "./appearance-settings-parts";
type DraftFiles = Record<AppearanceAssetSlot, File | null>;
type ResetState = Record<AppearanceAssetSlot, boolean>;
export { type DraftFiles, FILE_RULES, type ResetState } from "./appearance-settings-parts";
const EMPTY_FILES: DraftFiles = { logo: null, "logo-dark": null, video: null, poster: null };
const EMPTY_RESETS: ResetState = { logo: false, "logo-dark": false, video: false, poster: false };
const FILE_RULES: Record<AppearanceAssetSlot, { accept: string; maxBytes: number; label: string }> = {
logo: { accept: "image/png,image/jpeg,image/webp", maxBytes: 5 << 20, label: "浅色模式 Logo" },
"logo-dark": { accept: "image/png,image/jpeg,image/webp", maxBytes: 5 << 20, label: "深色模式 Logo" },
poster: { accept: "image/png,image/jpeg,image/webp", maxBytes: 10 << 20, label: "视频封面" },
video: { accept: "video/mp4,video/webm", maxBytes: 256 << 20, label: "品牌视频" },
};
export default function AppearanceSettingsPage() {
const { message, modal } = App.useApp();
@@ -775,143 +769,3 @@ export default function AppearanceSettingsPage() {
</AdminPageFrame>
);
}
function AssetPicker({
slot,
title,
description,
configured,
file,
inputRef,
onSelect,
onReset,
disabled,
emptyLabel,
}: {
slot: AppearanceAssetSlot;
title: string;
description: string;
configured: boolean;
file: File | null;
inputRef: RefObject<HTMLInputElement | null>;
onSelect: (slot: AppearanceAssetSlot, file?: File) => void;
onReset: (slot: AppearanceAssetSlot) => void;
disabled: boolean;
emptyLabel?: string;
}) {
const rule = FILE_RULES[slot];
return (
<div className="admin-appearance-asset-row">
<span className="admin-appearance-asset-icon">{slot === "video" ? <MonitorPlay /> : <ImageIcon />}</span>
<span className="admin-appearance-asset-copy">
<strong>{title}</strong>
<small>{description}</small>
<em>{file ? `${file.name} · ${formatBytes(file.size)}` : configured ? "已配置自定义文件" : emptyLabel || "使用项目原始文件"}</em>
</span>
<span className="admin-appearance-asset-actions">
<input ref={inputRef} type="file" accept={rule.accept} onChange={(event) => onSelect(slot, event.target.files?.[0])} />
<Button icon={<Upload className="size-3.5" />} disabled={disabled} onClick={() => inputRef.current?.click()}>
选择文件
</Button>
<Button type="text" danger={configured || Boolean(file)} disabled={disabled || (!configured && !file)} onClick={() => onReset(slot)}>
恢复原始
</Button>
</span>
</div>
);
}
function useAppearancePreviews(setting: AdminAppearance | null, files: DraftFiles, resets: ResetState) {
const logoObjectURL = useObjectURL(files.logo);
const darkLogoObjectURL = useObjectURL(files["logo-dark"]);
const videoObjectURL = useObjectURL(files.video);
const posterObjectURL = useObjectURL(files.poster);
return useMemo(() => {
if (!setting) return { logoLight: DEFAULT_PUBLIC_APPEARANCE.logoUrl, logoDark: DEFAULT_PUBLIC_APPEARANCE.darkLogoUrl, video: DEFAULT_PUBLIC_APPEARANCE.authVideoUrl, poster: DEFAULT_PUBLIC_APPEARANCE.authVideoPosterUrl };
const customVideo = Boolean(files.video || (!resets.video && setting.authVideoResourceId));
const lightLogo = logoObjectURL || (!resets.logo && setting.logoResourceId ? setting.public.logoUrl : "");
const darkLogo = darkLogoObjectURL || (!resets["logo-dark"] && setting.darkLogoResourceId ? setting.public.darkLogoUrl : "");
return {
logoLight: lightLogo || darkLogo || DEFAULT_PUBLIC_APPEARANCE.logoUrl,
logoDark: darkLogo || lightLogo || DEFAULT_PUBLIC_APPEARANCE.darkLogoUrl,
video: videoObjectURL || (resets.video ? DEFAULT_PUBLIC_APPEARANCE.authVideoUrl : setting.public.authVideoUrl),
poster: posterObjectURL || (resets.poster ? (customVideo ? "" : DEFAULT_PUBLIC_APPEARANCE.authVideoPosterUrl) : setting.public.authVideoPosterUrl),
};
}, [darkLogoObjectURL, files.video, logoObjectURL, posterObjectURL, resets, setting, videoObjectURL]);
}
function LogoThemePreview({ label, icon, src, dark, frameEnabled }: { label: string; icon: ReactNode; src: string; dark: boolean; frameEnabled: boolean }) {
return (
<div className={cn("admin-appearance-logo-preview", dark ? "is-dark" : "is-light")}>
<span className={cn("admin-appearance-logo-preview-mark", !frameEnabled && "is-unframed")}>
<img src={src} alt="" />
</span>
<span className="admin-appearance-logo-preview-label">
{icon}
{label}
</span>
</div>
);
}
function useObjectURL(file: File | null) {
const url = useMemo(() => (file ? URL.createObjectURL(file) : ""), [file]);
useEffect(
() => () => {
if (url) URL.revokeObjectURL(url);
},
[url],
);
return url;
}
function AppearanceSkeleton() {
return (
<div className="admin-settings-stack admin-appearance-settings" aria-label="正在读取外观配置" role="status">
<div className="admin-appearance-command-bar">
<Skeleton active title={{ width: 190 }} paragraph={false} />
</div>
<div className="admin-appearance-loading-card">
<Skeleton active paragraph={{ rows: 8 }} />
</div>
<div className="admin-appearance-loading-card">
<Skeleton active paragraph={{ rows: 10 }} />
</div>
</div>
);
}
function normalizeDraftCopy(value: string) {
return value.replace(/\r\n?/g, "\n").trim();
}
function normalizeSingleLine(value: string) {
return value.replace(/\r\n?/g, " ").trim();
}
function hasUnsupportedControlCharacter(value: string) {
return Array.from(value).some((character) => character !== "\n" && /[\u0000-\u001f\u007f]/.test(character));
}
function validateSkinDrafts(themes: SkinDefinition[], selectedID: string) {
if (!themes.length || themes.length > 16) return "皮肤主题数量必须为 1 到 16 套";
const ids = new Set<string>();
for (const theme of themes) {
if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(theme.id) || ids.has(theme.id)) return "皮肤主题 ID 无效或重复";
ids.add(theme.id);
if (!theme.name.trim() || Array.from(theme.name.trim()).length > 40) return "皮肤主题名称必须为 1 到 40 个字符";
if (Array.from(theme.description.trim()).length > 100) return "皮肤主题说明不能超过 100 个字符";
const invalidColor = [...Object.values(theme.tokens.light), ...Object.values(theme.tokens.dark)].some((color) => !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(color));
if (invalidColor) return `主题“${theme.name}”存在无效颜色,请使用 6 或 8 位十六进制色值`;
if (!isSkinButtonFill(theme.tokens.buttons.light) || !isSkinButtonFill(theme.tokens.buttons.dark)) return `主题“${theme.name}”的主按钮参数无效,请检查颜色和渐变角度(0–360°)`;
if (theme.tokens.components.controlHeightSmall > theme.tokens.components.controlHeight || theme.tokens.components.controlHeight > theme.tokens.components.controlHeightLarge) return `主题“${theme.name}”的控件高度顺序无效`;
if (theme.tokens.components.motionFast > theme.tokens.components.motionNormal) return `主题“${theme.name}”的快速动效不能慢于常规动效`;
}
if (!ids.has("classic") || !ids.has(selectedID)) return "默认主题或当前启用主题不存在";
return "";
}
function formatBytes(bytes: number) {
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}

View File

@@ -0,0 +1,161 @@
// 站点外观设置的素材选择、Logo 主题预览、骨架屏与草稿校验。
import { type AdminAppearance, type AppearanceAssetSlot } from "@/services/api/appearance";
import { type ReactNode, type RefObject, useEffect, useMemo } from "react";
import { Image as ImageIcon, MonitorPlay, Upload } from "lucide-react";
import { Button, Skeleton } from "antd";
import { DEFAULT_PUBLIC_APPEARANCE } from "@/stores/use-appearance-store";
import { cn } from "@/lib/utils";
import { isSkinButtonFill, type SkinDefinition } from "@/lib/skin-themes";
export type DraftFiles = Record<AppearanceAssetSlot, File | null>;
export type ResetState = Record<AppearanceAssetSlot, boolean>;
export const FILE_RULES: Record<AppearanceAssetSlot, { accept: string; maxBytes: number; label: string }> = {
logo: { accept: "image/png,image/jpeg,image/webp", maxBytes: 5 << 20, label: "浅色模式 Logo" },
"logo-dark": { accept: "image/png,image/jpeg,image/webp", maxBytes: 5 << 20, label: "深色模式 Logo" },
poster: { accept: "image/png,image/jpeg,image/webp", maxBytes: 10 << 20, label: "视频封面" },
video: { accept: "video/mp4,video/webm", maxBytes: 256 << 20, label: "品牌视频" },
};
export function AssetPicker({
slot,
title,
description,
configured,
file,
inputRef,
onSelect,
onReset,
disabled,
emptyLabel,
}: {
slot: AppearanceAssetSlot;
title: string;
description: string;
configured: boolean;
file: File | null;
inputRef: RefObject<HTMLInputElement | null>;
onSelect: (slot: AppearanceAssetSlot, file?: File) => void;
onReset: (slot: AppearanceAssetSlot) => void;
disabled: boolean;
emptyLabel?: string;
}) {
const rule = FILE_RULES[slot];
return (
<div className="admin-appearance-asset-row">
<span className="admin-appearance-asset-icon">{slot === "video" ? <MonitorPlay /> : <ImageIcon />}</span>
<span className="admin-appearance-asset-copy">
<strong>{title}</strong>
<small>{description}</small>
<em>{file ? `${file.name} · ${formatBytes(file.size)}` : configured ? "已配置自定义文件" : emptyLabel || "使用项目原始文件"}</em>
</span>
<span className="admin-appearance-asset-actions">
<input ref={inputRef} type="file" accept={rule.accept} onChange={(event) => onSelect(slot, event.target.files?.[0])} />
<Button icon={<Upload className="size-3.5" />} disabled={disabled} onClick={() => inputRef.current?.click()}>
选择文件
</Button>
<Button type="text" danger={configured || Boolean(file)} disabled={disabled || (!configured && !file)} onClick={() => onReset(slot)}>
恢复原始
</Button>
</span>
</div>
);
}
export function useAppearancePreviews(setting: AdminAppearance | null, files: DraftFiles, resets: ResetState) {
const logoObjectURL = useObjectURL(files.logo);
const darkLogoObjectURL = useObjectURL(files["logo-dark"]);
const videoObjectURL = useObjectURL(files.video);
const posterObjectURL = useObjectURL(files.poster);
return useMemo(() => {
if (!setting) return { logoLight: DEFAULT_PUBLIC_APPEARANCE.logoUrl, logoDark: DEFAULT_PUBLIC_APPEARANCE.darkLogoUrl, video: DEFAULT_PUBLIC_APPEARANCE.authVideoUrl, poster: DEFAULT_PUBLIC_APPEARANCE.authVideoPosterUrl };
const customVideo = Boolean(files.video || (!resets.video && setting.authVideoResourceId));
const lightLogo = logoObjectURL || (!resets.logo && setting.logoResourceId ? setting.public.logoUrl : "");
const darkLogo = darkLogoObjectURL || (!resets["logo-dark"] && setting.darkLogoResourceId ? setting.public.darkLogoUrl : "");
return {
logoLight: lightLogo || darkLogo || DEFAULT_PUBLIC_APPEARANCE.logoUrl,
logoDark: darkLogo || lightLogo || DEFAULT_PUBLIC_APPEARANCE.darkLogoUrl,
video: videoObjectURL || (resets.video ? DEFAULT_PUBLIC_APPEARANCE.authVideoUrl : setting.public.authVideoUrl),
poster: posterObjectURL || (resets.poster ? (customVideo ? "" : DEFAULT_PUBLIC_APPEARANCE.authVideoPosterUrl) : setting.public.authVideoPosterUrl),
};
}, [darkLogoObjectURL, files.video, logoObjectURL, posterObjectURL, resets, setting, videoObjectURL]);
}
export function LogoThemePreview({ label, icon, src, dark, frameEnabled }: { label: string; icon: ReactNode; src: string; dark: boolean; frameEnabled: boolean }) {
return (
<div className={cn("admin-appearance-logo-preview", dark ? "is-dark" : "is-light")}>
<span className={cn("admin-appearance-logo-preview-mark", !frameEnabled && "is-unframed")}>
<img src={src} alt="" />
</span>
<span className="admin-appearance-logo-preview-label">
{icon}
{label}
</span>
</div>
);
}
export function useObjectURL(file: File | null) {
const url = useMemo(() => (file ? URL.createObjectURL(file) : ""), [file]);
useEffect(
() => () => {
if (url) URL.revokeObjectURL(url);
},
[url],
);
return url;
}
export function AppearanceSkeleton() {
return (
<div className="admin-settings-stack admin-appearance-settings" aria-label="正在读取外观配置" role="status">
<div className="admin-appearance-command-bar">
<Skeleton active title={{ width: 190 }} paragraph={false} />
</div>
<div className="admin-appearance-loading-card">
<Skeleton active paragraph={{ rows: 8 }} />
</div>
<div className="admin-appearance-loading-card">
<Skeleton active paragraph={{ rows: 10 }} />
</div>
</div>
);
}
export function normalizeDraftCopy(value: string) {
return value.replace(/\r\n?/g, "\n").trim();
}
export function normalizeSingleLine(value: string) {
return value.replace(/\r\n?/g, " ").trim();
}
export function hasUnsupportedControlCharacter(value: string) {
return Array.from(value).some((character) => character !== "\n" && /[\u0000-\u001f\u007f]/.test(character));
}
export function validateSkinDrafts(themes: SkinDefinition[], selectedID: string) {
if (!themes.length || themes.length > 16) return "皮肤主题数量必须为 1 到 16 套";
const ids = new Set<string>();
for (const theme of themes) {
if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(theme.id) || ids.has(theme.id)) return "皮肤主题 ID 无效或重复";
ids.add(theme.id);
if (!theme.name.trim() || Array.from(theme.name.trim()).length > 40) return "皮肤主题名称必须为 1 到 40 个字符";
if (Array.from(theme.description.trim()).length > 100) return "皮肤主题说明不能超过 100 个字符";
const invalidColor = [...Object.values(theme.tokens.light), ...Object.values(theme.tokens.dark)].some((color) => !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(color));
if (invalidColor) return `主题“${theme.name}”存在无效颜色,请使用 6 或 8 位十六进制色值`;
if (!isSkinButtonFill(theme.tokens.buttons.light) || !isSkinButtonFill(theme.tokens.buttons.dark)) return `主题“${theme.name}”的主按钮参数无效,请检查颜色和渐变角度(0–360°)`;
if (theme.tokens.components.controlHeightSmall > theme.tokens.components.controlHeight || theme.tokens.components.controlHeight > theme.tokens.components.controlHeightLarge) return `主题“${theme.name}”的控件高度顺序无效`;
if (theme.tokens.components.motionFast > theme.tokens.components.motionNormal) return `主题“${theme.name}”的快速动效不能慢于常规动效`;
}
if (!ids.has("classic") || !ids.has(selectedID)) return "默认主题或当前启用主题不存在";
return "";
}
export function formatBytes(bytes: number) {
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}

View File

@@ -0,0 +1,273 @@
// 对象存储设置的表单模型:表单值与接口载荷互转、变更检测与草稿校验。
//
// 服务器访问地址只校验 http/https 格式,允许本机、局域网与 HTTP 地址(产品约定,勿收紧)。
import { type AdminOSSSetting } from "@/services/api/auth";
import { DEFAULT_OSS_PATH_PREFIX, normalizeOSSConnectionTestInput } from "@/lib/oss-settings";
import { type S3Preset } from "@/lib/oss-settings";
export type StorageMode = "local" | AdminOSSSetting["provider"];
export 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;
};
export type StoragePayload = Pick<
AdminOSSSetting,
| "enabled"
| "provider"
| "region"
| "endpoint"
| "cdnBaseUrl"
| "cdnAuthMode"
| "requireCDN"
| "allowPrivateProxy"
| "bucket"
| "accessKeyId"
| "accessKeySecret"
| "sessionToken"
| "publicBaseUrl"
| "pathPrefix"
| "s3Preset"
| "pathStyle"
| "allowUserS3"
>;
export 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,
};
}
export function providerDraftValues(mode: Exclude<StorageMode, "local">, setting: AdminOSSSetting, pathPrefix: string): Partial<OSSFormValues> {
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,
};
}
export function normalizeStoragePayload(values: Partial<OSSFormValues>, 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,
};
}
export function hasStorageChanges(values: Partial<OSSFormValues>, 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<keyof StoragePayload>).some((key) => key !== "accessKeySecret" && key !== "sessionToken" && draft[key] !== saved[key]);
}
export 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 "";
}
export 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 地址";
}
}
export function storageResponseMatches(setting: AdminOSSSetting, expected: StoragePayload) {
const actual = normalizeStoragePayload(formValues(setting), setting);
const fields: Array<keyof StoragePayload> = ["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]);
}
export function isAdminOSSSetting(value: unknown): value is AdminOSSSetting {
if (!value || typeof value !== "object") return false;
const setting = value as Partial<AdminOSSSetting>;
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"
);
}
export function storageConfigurationReady(mode: StorageMode, values: StoragePayload, setting: AdminOSSSetting) {
if (mode === "local") return !validatePublicBaseURL(values.publicBaseUrl);
return !validateStorageDraft({ ...formValues(setting), ...values, mode }, setting);
}
export function storageProviderLabel(provider?: StorageMode) {
return provider === "s3" ? "S3 兼容存储" : provider === "tencent" ? "腾讯云 COS" : provider === "qiniu" ? "七牛云 Kodo" : provider === "aliyun" ? "阿里云 OSS" : "服务器本地";
}
export 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 域名可选。";
}
export function accessKeyIdLabel(mode: Exclude<StorageMode, "local"> | AdminOSSSetting["provider"]) {
return mode === "tencent" ? "SecretId" : mode === "qiniu" ? "AccessKey" : "AccessKey ID";
}
export function accessKeySecretLabel(mode: Exclude<StorageMode, "local"> | AdminOSSSetting["provider"]) {
return mode === "tencent" || mode === "qiniu" ? "SecretKey" : "AccessKey Secret";
}
export function isHTTPURL(value: string) {
try {
const parsed = new URL(value);
return Boolean(parsed.hostname) && ["http:", "https:"].includes(parsed.protocol);
} catch {
return false;
}
}
export 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;
}
}
export function trimTrailingSlash(value: string) {
return value.trim().replace(/\/+$/, "");
}
export function connectionInput(values: Partial<OSSFormValues>) {
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,
});
}
export function hasValidSettingTime(value?: string) {
if (!value) return false;
const date = new Date(value);
return !Number.isNaN(date.getTime()) && date.getFullYear() >= 2000;
}
export function formatSettingTime(value: string | undefined, fallback: string) {
if (!hasValidSettingTime(value)) return fallback;
return `更新于 ${new Date(value as string).toLocaleString("zh-CN", { hour12: false })}`;
}

View File

@@ -1,57 +1,19 @@
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 { AdminPageFrame } from "../components/admin-shell";
import { AlertTriangle, RefreshCw, Database, RotateCcw, HardDrive, Cloud, Check, ShieldCheck, Server, BadgeCheck, Save, Globe2, LocateFixed, KeyRound, Wifi } from "lucide-react";
import { cn } from "@/lib/utils";
import { Select, Switch } from "@/pages/admin/ui/controls";
import { App, Form, Skeleton, Button, Input } from "antd";
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 { type OSSConnectionTestResult, changesRequireOSSRetest, getS3PresetHints, S3_PRESET_OPTIONS, type S3Preset } from "@/lib/oss-settings";
import { getAdminOSSSetting, updateAdminOSSSetting, type AdminOSSSetting, testAdminOSSConnection } 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";
import { accessKeySecretLabel, formValues, formatSettingTime, hasStorageChanges, isAdminOSSSetting, normalizeStoragePayload, providerDraftValues, storageProviderLabel, storageResponseMatches, type StoragePayload, validateStorageDraft, connectionInput, storageConfigurationReady, providerGuidance, accessKeyIdLabel } from "./storage-settings-model";
import { type OSSFormValues, type StorageMode } from "./storage-settings-model";
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"
>;
export { type OSSFormValues, type StorageMode, type StoragePayload } from "./storage-settings-model";
const STORAGE_MODES: Array<{ mode: StorageMode; label: string; short: string; description: string }> = [
{ mode: "local", label: "服务器本地", short: "本地磁盘", description: "新增资源写入当前部署的数据目录,通过后端签名链接访问。" },
@@ -646,232 +608,9 @@ function FormSectionTitle({ icon, title, description }: { icon: ReactNode; title
);
}
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<StorageMode, "local">, setting: AdminOSSSetting, pathPrefix: string): Partial<OSSFormValues> {
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<OSSFormValues>, 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<OSSFormValues>, 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<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" : 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<keyof StoragePayload> = ["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<AdminOSSSetting>;
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<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 域名可选。";
}
function accessKeyIdLabel(mode: Exclude<StorageMode, "local"> | AdminOSSSetting["provider"]) {
return mode === "tencent" ? "SecretId" : mode === "qiniu" ? "AccessKey" : "AccessKey ID";
}
function accessKeySecretLabel(mode: Exclude<StorageMode, "local"> | 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<OSSFormValues>) {
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 <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);
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 })}`;
}