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

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

No files matched your search

@@ -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`;
}