Files
ttstd-infinite-canvas/web/src/pages/admin/settings/components/skin-theme-editor.tsx
ddCat 9ce158aaa0 refactor(admin): 管理后台 UI - 独立设计系统并与用户端隔离
把后台 token、Ant 主题、壳层控件和抽屉弹窗从用户工作台拆开,去掉树状侧栏装饰线,色板仍用原来的中性黑白台阶。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 21:29:35 +08:00

291 lines
16 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 { Button, Form, Input, InputNumber } from "antd";
import { IconButton, SegmentedControl, Select, Tooltip } from "@/pages/admin/ui/controls";
import { Check, Copy, LockKeyhole, Moon, Plus, Sparkles, Sun, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { SKIN_COLOR_GROUPS, SKIN_COMPONENT_NUMBER_FIELDS, skinSwatches, type SkinComponentTokens, type SkinDefinition, type SkinModeTokens, type SkinThemeMode } from "@/lib/skin-themes";
import { cn } from "@/lib/utils";
import { AdminStatusBadge } from "@/pages/admin/components/admin-ui";
export function SkinThemeEditor({
themes,
selectedID,
disabled,
onSelect,
onCreate,
onDuplicate,
onDelete,
onChange,
}: {
themes: SkinDefinition[];
selectedID: string;
disabled: boolean;
onSelect: (id: string) => void;
onCreate: () => void;
onDuplicate: (id: string) => void;
onDelete: (id: string) => void;
onChange: (theme: SkinDefinition) => void;
}) {
const [mode, setMode] = useState<SkinThemeMode>("light");
const selected = useMemo(() => themes.find((theme) => theme.id === selectedID) || themes[0], [selectedID, themes]);
if (!selected) return null;
const editorDisabled = disabled || selected.locked;
const updateIdentity = (patch: Partial<Pick<SkinDefinition, "name" | "description">>) => onChange({ ...selected, ...patch });
const updateColor = (key: keyof SkinModeTokens, value: string) =>
onChange({
...selected,
tokens: { ...selected.tokens, [mode]: { ...selected.tokens[mode], [key]: value.trim().toLowerCase() } },
});
const updateComponent = <Key extends keyof SkinComponentTokens>(key: Key, value: SkinComponentTokens[Key]) => onChange({ ...selected, tokens: { ...selected.tokens, components: { ...selected.tokens.components, [key]: value } } });
return (
<div className="admin-skin-workspace">
<div className="admin-skin-library-toolbar">
<div>
<strong>主题库</strong>
<span>共 {themes.length} 套,最多 16 套</span>
</div>
<div>
<Button icon={<Plus className="size-3.5" />} disabled={disabled || themes.length >= 16} onClick={onCreate}>
从默认新建
</Button>
<Button icon={<Copy className="size-3.5" />} disabled={disabled || themes.length >= 16} onClick={() => onDuplicate(selected.id)}>
复制当前
</Button>
</div>
</div>
<div className="admin-skin-library" role="radiogroup" aria-label="选择全站皮肤主题">
{themes.map((theme) => {
const active = theme.id === selected.id;
const swatches = skinSwatches(theme);
return (
<article key={theme.id} className={cn("admin-skin-card", active && "is-selected")}>
<button type="button" className="admin-skin-card-select" role="radio" aria-checked={active} disabled={disabled} onClick={() => onSelect(theme.id)}>
<span className="admin-skin-card-preview" aria-hidden="true" style={{ background: theme.tokens.dark.canvas, borderRadius: theme.tokens.components.cardRadius }}>
<span style={{ background: theme.tokens.dark.surface, borderRadius: theme.tokens.components.inputRadius }}>
{swatches.map((color, index) => (
<i key={`${color}-${index}`} style={{ background: color }} />
))}
</span>
</span>
<span className="admin-skin-card-copy">
<strong>{theme.name}</strong>
<small>{theme.description || "暂无说明"}</small>
</span>
{theme.locked ? <LockKeyhole className="admin-skin-card-lock" aria-label="系统默认主题不可修改" /> : null}
{active ? (
<span className="admin-skin-card-check" aria-hidden="true">
<Check />
</span>
) : null}
</button>
<div className="admin-skin-card-actions">
<Tooltip title="复制这套主题">
<IconButton size="sm" variant="ghost" icon={Copy} aria-label={`复制 ${theme.name}`} disabled={disabled || themes.length >= 16} onClick={() => onDuplicate(theme.id)} />
</Tooltip>
<Tooltip title={theme.locked ? "系统默认主题不可删除" : "删除这套主题"}>
<Button
className="admin-skin-delete-button"
type="primary"
danger
size="small"
aria-label={`删除 ${theme.name}`}
icon={<Trash2 className="size-3.5" />}
disabled={disabled || theme.locked}
onClick={() => onDelete(theme.id)}
/>
</Tooltip>
</div>
</article>
);
})}
</div>
<div className="admin-skin-editor">
<div className="admin-skin-editor-heading">
<div>
<span>当前编辑</span>
<strong>{selected.name}</strong>
</div>
<AdminStatusBadge label={selected.locked ? "系统默认 · 只读" : "自定义主题"} tone={selected.locked ? "neutral" : "info"} />
</div>
{selected.locked ? (
<div className="admin-skin-locked-note">
<LockKeyhole className="size-4" aria-hidden="true" />
<span>经典黑白始终保留项目原始视觉,不能改名、修改或删除。点击“从默认新建”即可复制全部参数后自由调整。</span>
</div>
) : null}
<Form className="admin-appearance-form admin-skin-identity-form" layout="vertical" requiredMark={false} disabled={editorDisabled}>
<div className="admin-appearance-form-grid">
<Form.Item label="主题名称" extra="1–40 个字符,可随时更改。">
<Input value={selected.name} maxLength={40} showCount onChange={(event) => updateIdentity({ name: event.target.value })} />
</Form.Item>
<Form.Item label="主题说明" extra="用于主题库辨识,不影响前台文案。">
<Input value={selected.description} maxLength={100} showCount onChange={(event) => updateIdentity({ description: event.target.value })} />
</Form.Item>
</div>
</Form>
<div className="admin-skin-mode-bar">
<div>
<strong>颜色体系</strong>
<span>同一用途的控件统一消费下列语义颜色</span>
</div>
<SegmentedControl<SkinThemeMode>
ariaLabel="皮肤预览模式"
value={mode}
options={[
{
label: (
<span className="admin-skin-mode-label">
<Sun />
浅色
</span>
),
value: "light",
},
{
label: (
<span className="admin-skin-mode-label">
<Moon />
深色
</span>
),
value: "dark",
},
]}
onChange={setMode}
/>
</div>
<SkinThemePreview theme={selected} mode={mode} />
<div className="admin-skin-color-groups">
{SKIN_COLOR_GROUPS.map((group, index) => (
<details key={group.key} className="admin-skin-token-group" open={index < 2}>
<summary>
<span>{group.label}</span>
<small>{group.fields.length} 项</small>
</summary>
<div className="admin-skin-color-grid">
{group.fields.map((field) => (
<ColorTokenField key={field.key} label={field.label} help={field.help} value={selected.tokens[mode][field.key]} disabled={editorDisabled} onChange={(value) => updateColor(field.key, value)} />
))}
</div>
</details>
))}
</div>
<details className="admin-skin-token-group admin-skin-component-group" open>
<summary>
<span>控件尺寸、圆角与反馈</span>
<small>17 项</small>
</summary>
<div className="admin-skin-component-grid">
{SKIN_COMPONENT_NUMBER_FIELDS.map((field) => (
<label key={field.key} className="admin-skin-number-field">
<span>{field.label}</span>
<InputNumber
value={selected.tokens.components[field.key]}
min={field.min}
max={field.max}
step={field.step || 1}
addonAfter={field.suffix || undefined}
disabled={editorDisabled}
onChange={(value) => {
if (typeof value === "number") updateComponent(field.key, value);
}}
/>
</label>
))}
<label className="admin-skin-number-field">
<span>阴影风格</span>
<Select
value={selected.tokens.components.shadowStyle}
disabled={editorDisabled}
options={[
{ label: "无阴影", value: "none" },
{ label: "柔和阴影", value: "soft" },
{ label: "强层次阴影", value: "strong" },
]}
onChange={(value) => updateComponent("shadowStyle", value)}
/>
</label>
</div>
</details>
</div>
</div>
);
}
function ColorTokenField({ label, help, value, disabled, onChange }: { label: string; help: string; value: string; disabled: boolean; onChange: (value: string) => void }) {
const pickerValue = /^#[0-9a-f]{6}$/i.test(value) ? value : /^#[0-9a-f]{8}$/i.test(value) ? value.slice(0, 7) : "#000000";
return (
<div className="admin-skin-color-field">
<span>
<strong>{label}</strong>
<small>{help}</small>
</span>
<span className="admin-skin-color-control">
<input type="color" value={pickerValue} disabled={disabled} aria-label={`${label}取色器`} title={`选择${label}`} onChange={(event) => onChange(event.target.value)} />
<Input value={value} maxLength={9} disabled={disabled} spellCheck={false} status={/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(value) ? undefined : "error"} onChange={(event) => onChange(event.target.value)} />
</span>
</div>
);
}
function SkinThemePreview({ theme, mode }: { theme: SkinDefinition; mode: SkinThemeMode }) {
const color = theme.tokens[mode];
const component = theme.tokens.components;
const shadow = component.shadowStyle === "none" ? "none" : component.shadowStyle === "strong" ? "0 18px 44px #0000003d" : "0 10px 28px #00000024";
return (
<div className="admin-skin-live-preview" style={{ background: color.canvas, color: color.text, borderColor: color.border, borderRadius: component.cardRadius }}>
<div className="admin-skin-live-preview-card" style={{ background: color.surface, borderColor: color.border, borderRadius: component.cardRadius, boxShadow: shadow }}>
<span className="admin-skin-live-preview-icon" style={{ background: color.surfaceSubtle, color: color.iconActive, borderRadius: component.inputRadius }}>
<Sparkles />
</span>
<div>
<strong>控件组合预览</strong>
<small style={{ color: color.textMuted }}>按钮、输入、选中与状态均来自同一套主题参数</small>
</div>
<span className="admin-skin-live-preview-status" style={{ background: color.selected, color: color.selectedForeground, borderRadius: component.menuRadius }}>
已选中
</span>
<span className="admin-skin-live-preview-menu" style={{ background: color.selected, color: color.selectedForeground, borderRadius: component.menuRadius }}>
后台菜单
</span>
<span
className="admin-skin-live-preview-input"
style={{ height: component.controlHeight, background: color.control, borderColor: color.controlBorder, borderWidth: component.borderWidth, borderRadius: component.inputRadius, color: color.textMuted }}
>
输入框状态
</span>
<span className="admin-skin-live-preview-button" style={{ height: component.controlHeight, background: color.primary, color: color.primaryForeground, borderRadius: component.buttonRadius, fontWeight: component.buttonFontWeight }}>
主要操作
</span>
<span className="admin-skin-live-preview-switches" aria-label="开关开启与关闭状态预览">
<i className="is-on" style={{ background: color.switchChecked }}>
<b style={{ background: color.switchCheckedHandle }} />
</i>
<i style={{ background: color.switchUnchecked }}>
<b style={{ background: color.switchUncheckedHandle }} />
</i>
</span>
<span className="admin-skin-live-preview-danger" style={{ background: color.danger, color: color.dangerForeground, borderRadius: component.buttonRadius }}>
删除
</span>
<span className="admin-skin-live-preview-signals">
<i style={{ background: color.success }} />
<i style={{ background: color.warning }} />
<i style={{ background: color.danger }} />
<i style={{ background: color.info }} />
</span>
</div>
</div>
);
}