refactor(*): 平台架构 - 拆分后端领域模块与画布前端组件并补充测试
This commit is contained in:
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`;
|
||||
}
|
||||
Reference in new issue
Block a user