修复 MySQL 读写分离下的持久化与 SSE 重连问题,报告完成前先落库;8BF 嵌入页支持 taskId 任务卡与独立意图 metadata;前端统一 createClientId 并更新 runtime 配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
770 lines
28 KiB
TypeScript
770 lines
28 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
ChevronDownIcon,
|
|
ChevronUpIcon,
|
|
CheckIcon,
|
|
MoonIcon,
|
|
PaletteIcon,
|
|
PlusIcon,
|
|
RotateCcwIcon,
|
|
SaveIcon,
|
|
SlidersHorizontalIcon,
|
|
SunIcon,
|
|
Trash2Icon,
|
|
} from "lucide-react";
|
|
import { useEffect, useId, useMemo, useState, type CSSProperties } from "react";
|
|
import { ColorPickerPanel } from "tdesign-react";
|
|
import { toast } from "sonner";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import type {
|
|
CustomThemeColorOverrides,
|
|
CustomThemePreset,
|
|
} from "@/core/system-settings/api";
|
|
import {
|
|
useAppearanceSettings,
|
|
useUpdateAppearanceSettings,
|
|
} from "@/core/system-settings/hooks";
|
|
import {
|
|
CUSTOM_THEME_ADVANCED_KEYS,
|
|
getCustomThemeAutomaticTokens,
|
|
getCustomThemeTokens,
|
|
normalizeHexColor,
|
|
customThemeTextContrast,
|
|
type CustomThemeAdvancedKey,
|
|
} from "@/core/theme/custom-theme";
|
|
import { createClientId } from "@/lib/client-id";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const MAX_CUSTOM_THEMES = 12;
|
|
const SUGGESTED_COLORS = [
|
|
"#9BCEF1",
|
|
"#F3FBD9",
|
|
"#7AB8F5",
|
|
"#8ED1B2",
|
|
"#C6B4F6",
|
|
"#F3C5A6",
|
|
"#F3B1B7",
|
|
"#63C4C8",
|
|
];
|
|
const TEXT_COLORS = ["#202A3A", "#172033", "#2D587A", "#4C6330", "#F4F7FB", "#FFFFFF"];
|
|
|
|
const ADVANCED_COLOR_FIELDS: Array<{
|
|
key: CustomThemeAdvancedKey;
|
|
label: string;
|
|
description: string;
|
|
token: string;
|
|
}> = [
|
|
{
|
|
key: "button_hover",
|
|
label: "按钮悬停色",
|
|
description: "主按钮悬停时的背景色",
|
|
token: "--theme-button-hover",
|
|
},
|
|
{
|
|
key: "navigation_gradient_start",
|
|
label: "导航渐变起点",
|
|
description: "全量模式顶部导航栏左侧的渐变色",
|
|
token: "--navigation-gradient-start",
|
|
},
|
|
{
|
|
key: "navigation_gradient_end",
|
|
label: "导航渐变终点",
|
|
description: "全量模式顶部导航栏右侧的渐变色",
|
|
token: "--navigation-gradient-end",
|
|
},
|
|
{
|
|
key: "selected_background",
|
|
label: "选中背景",
|
|
description: "左侧菜单、卡片选中时的浅背景",
|
|
token: "--theme-selected-background",
|
|
},
|
|
{
|
|
key: "border",
|
|
label: "边框色",
|
|
description: "卡片、输入框和分隔线",
|
|
token: "--border",
|
|
},
|
|
{
|
|
key: "muted",
|
|
label: "弱化背景",
|
|
description: "次级区域与弱化容器",
|
|
token: "--muted",
|
|
},
|
|
{
|
|
key: "muted_text",
|
|
label: "辅助文字",
|
|
description: "说明、占位和次要信息文字",
|
|
token: "--muted-foreground",
|
|
},
|
|
{
|
|
key: "destructive",
|
|
label: "删除/危险色",
|
|
description: "删除按钮、错误与危险操作的颜色",
|
|
token: "--destructive",
|
|
},
|
|
{
|
|
key: "brand_title",
|
|
label: "栏目标题文字",
|
|
description: "业务链、分析记录等带图标栏目标题",
|
|
token: "--brand-title-foreground",
|
|
},
|
|
{
|
|
key: "primary_foreground",
|
|
label: "主色块文字(Logo/状态标签)",
|
|
description: "Logo 的 C、启用状态和默认标签;不影响主按钮文字",
|
|
token: "--primary-foreground",
|
|
},
|
|
];
|
|
|
|
function makeThemeId(): `custom-theme-${string}` {
|
|
const suffix = createClientId().replace(/[^a-z0-9-]/gi, "").toLowerCase();
|
|
return `custom-theme-${suffix}`;
|
|
}
|
|
|
|
function newCustomTheme(order: number): CustomThemePreset {
|
|
return {
|
|
id: makeThemeId(),
|
|
name: `自定义主题 ${order + 1}`,
|
|
primary: "#9BCEF1",
|
|
text_color: null,
|
|
mode: "light",
|
|
overrides: {},
|
|
enabled: true,
|
|
sort_order: order,
|
|
};
|
|
}
|
|
|
|
function normalizeColorOverrides(
|
|
overrides: CustomThemeColorOverrides | undefined,
|
|
): CustomThemeColorOverrides {
|
|
const normalized: CustomThemeColorOverrides = {};
|
|
for (const key of CUSTOM_THEME_ADVANCED_KEYS) {
|
|
const value = overrides?.[key];
|
|
if (typeof value === "string" && value.trim()) {
|
|
normalized[key] = normalizeHexColor(value);
|
|
}
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeThemeDrafts(
|
|
themes: CustomThemePreset[] | undefined,
|
|
): CustomThemePreset[] {
|
|
return [...(themes ?? [])]
|
|
.sort((a, b) => a.sort_order - b.sort_order)
|
|
.map((theme, index) => ({
|
|
...theme,
|
|
id: theme.id || makeThemeId(),
|
|
name: theme.name || `自定义主题 ${index + 1}`,
|
|
primary: normalizeHexColor(theme.primary),
|
|
text_color:
|
|
typeof theme.text_color === "string" && theme.text_color.trim()
|
|
? normalizeHexColor(theme.text_color)
|
|
: null,
|
|
mode: theme.mode === "dark" ? "dark" : "light",
|
|
overrides: normalizeColorOverrides(theme.overrides),
|
|
enabled: theme.enabled !== false,
|
|
sort_order: index,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Render the TDesign panel inside the settings dialog instead of its default
|
|
* body-level popup. The dialog otherwise treats clicks inside that portal as
|
|
* outside interactions and closes the picker while a colour is being chosen.
|
|
*/
|
|
function InlineThemeColorPicker({
|
|
value,
|
|
fallback,
|
|
swatchColors,
|
|
onCommit,
|
|
ariaLabel = "选择颜色",
|
|
}: {
|
|
value: string;
|
|
fallback?: string;
|
|
swatchColors: string[];
|
|
onCommit: (value: string) => void;
|
|
ariaLabel?: string;
|
|
}) {
|
|
const normalizedValue = normalizeHexColor(value, fallback);
|
|
const [pendingValue, setPendingValue] = useState(normalizedValue);
|
|
const [open, setOpen] = useState(false);
|
|
const panelId = useId();
|
|
|
|
useEffect(() => {
|
|
if (!open) setPendingValue(normalizedValue);
|
|
}, [normalizedValue, open]);
|
|
|
|
return (
|
|
<div className="w-fit">
|
|
<button
|
|
type="button"
|
|
className="flex h-10 min-w-32 items-center gap-2 rounded-md border bg-background px-2 font-mono text-sm shadow-xs transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
aria-label={ariaLabel}
|
|
aria-expanded={open}
|
|
aria-controls={panelId}
|
|
onClick={() => setOpen((visible) => !visible)}
|
|
>
|
|
<span
|
|
className="size-7 rounded border"
|
|
style={{ backgroundColor: pendingValue }}
|
|
aria-hidden
|
|
/>
|
|
{pendingValue}
|
|
</button>
|
|
{open && (
|
|
<div
|
|
id={panelId}
|
|
className="mt-2 w-fit rounded-lg border bg-background p-2 shadow-lg"
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<ColorPickerPanel
|
|
value={pendingValue}
|
|
format="HEX"
|
|
colorModes={["monochrome"]}
|
|
enableAlpha={false}
|
|
swatchColors={swatchColors}
|
|
onChange={(color) => setPendingValue(normalizeHexColor(String(color), fallback))}
|
|
/>
|
|
<div className="mt-2 flex justify-end gap-2 border-t pt-2">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setPendingValue(normalizedValue);
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
取消
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={() => {
|
|
setOpen(false);
|
|
onCommit(pendingValue);
|
|
}}
|
|
>
|
|
应用颜色
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CustomThemePreview({ theme }: { theme: CustomThemePreset }) {
|
|
const tokens = getCustomThemeTokens(theme) as CSSProperties;
|
|
const buttonContrast = customThemeTextContrast(theme);
|
|
const isButtonContrastLow = buttonContrast < 4.5;
|
|
|
|
return (
|
|
<div
|
|
className="overflow-hidden rounded-lg border"
|
|
style={tokens}
|
|
aria-label={`${theme.name} 的颜色预览`}
|
|
>
|
|
<div
|
|
className="flex items-center gap-2 border-b border-[var(--border)] px-3 py-2"
|
|
style={{
|
|
backgroundImage:
|
|
"linear-gradient(105deg, var(--navigation-gradient-start) 0%, var(--primary) 52%, var(--navigation-gradient-end) 100%)",
|
|
}}
|
|
>
|
|
<span
|
|
className="flex size-5 items-center justify-center rounded-md bg-white/25 text-[10px] font-bold text-[var(--navigation-foreground)] ring-2 ring-white/35"
|
|
aria-hidden
|
|
>
|
|
C
|
|
</span>
|
|
<span className="text-xs font-semibold text-[var(--navigation-foreground)]">
|
|
实时预览
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
"ml-auto text-[11px]",
|
|
isButtonContrastLow
|
|
? "text-amber-700 dark:text-amber-300"
|
|
: "text-[var(--muted-foreground)]",
|
|
)}
|
|
>
|
|
主按钮对比度 {buttonContrast.toFixed(1)}:1
|
|
</span>
|
|
</div>
|
|
<div className="grid gap-2 bg-[var(--background)] p-3 sm:grid-cols-[1fr_auto]">
|
|
<div className="rounded-md border border-[var(--border)] bg-[var(--card)] p-2.5">
|
|
<div className="text-xs font-semibold text-[var(--brand-title-foreground)]">
|
|
{theme.name || "未命名主题"}
|
|
</div>
|
|
<div className="mt-1 text-[11px] text-[var(--muted-foreground)]">
|
|
页面正文自动生成;主按钮文字可单独指定。
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="rounded-md bg-[var(--primary)] px-3 py-2 text-xs font-semibold text-[var(--button-foreground,var(--primary-foreground))] shadow-sm"
|
|
tabIndex={-1}
|
|
>
|
|
主按钮
|
|
</button>
|
|
{isButtonContrastLow && (
|
|
<p className="sm:col-span-2 text-[11px] text-amber-700 dark:text-amber-300">
|
|
当前按钮文字会按您的颜色设置展示,但对比度低于 4.5:1,建议改用更深的主色或恢复自动。
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AdvancedThemeColorControls({
|
|
theme,
|
|
onPatch,
|
|
}: {
|
|
theme: CustomThemePreset;
|
|
onPatch: (patch: Partial<CustomThemePreset>) => void;
|
|
}) {
|
|
const automaticTokens = getCustomThemeAutomaticTokens({
|
|
primary: theme.primary,
|
|
mode: theme.mode,
|
|
});
|
|
|
|
const setOverride = (key: CustomThemeAdvancedKey, value: string) => {
|
|
const overrides = { ...(theme.overrides ?? {}) };
|
|
if (!value.trim()) {
|
|
delete overrides[key];
|
|
} else {
|
|
overrides[key] = value;
|
|
}
|
|
onPatch({ overrides });
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3 rounded-lg border border-dashed bg-muted/15 p-3">
|
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
<div>
|
|
<div className="text-sm font-medium">更多配色</div>
|
|
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
|
|
默认由主色自动推导。仅在品牌规范有明确要求时,选择单项覆盖;“恢复自动”会重新跟随主色。
|
|
</p>
|
|
</div>
|
|
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
|
已覆盖 {Object.keys(theme.overrides ?? {}).length} 项
|
|
</span>
|
|
</div>
|
|
<div className="grid gap-3 lg:grid-cols-2">
|
|
{ADVANCED_COLOR_FIELDS.map((field) => {
|
|
const explicit = theme.overrides?.[field.key];
|
|
const automatic = automaticTokens[field.token] ?? "#0062D9";
|
|
const value = explicit ?? automatic;
|
|
return (
|
|
<div key={field.key} className="rounded-md border bg-background p-3">
|
|
<div className="mb-2 flex items-start justify-between gap-2">
|
|
<div>
|
|
<div className="text-xs font-semibold">{field.label}</div>
|
|
<div className="mt-0.5 text-[11px] text-muted-foreground">
|
|
{field.description}
|
|
</div>
|
|
</div>
|
|
{explicit ? (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 px-2 text-xs"
|
|
onClick={() => setOverride(field.key, "")}
|
|
>
|
|
<RotateCcwIcon className="mr-1 size-3.5" />
|
|
恢复自动
|
|
</Button>
|
|
) : (
|
|
<span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
|
自动
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-wrap items-end gap-2">
|
|
<InlineThemeColorPicker
|
|
value={value}
|
|
swatchColors={SUGGESTED_COLORS}
|
|
onCommit={(color) => setOverride(field.key, color)}
|
|
ariaLabel={`${theme.name || "自定义主题"}${field.label}选色器`}
|
|
/>
|
|
<Input
|
|
value={value}
|
|
maxLength={7}
|
|
className="h-8 w-28 font-mono text-xs uppercase"
|
|
onChange={(event) => setOverride(field.key, event.target.value)}
|
|
onBlur={(event) =>
|
|
setOverride(
|
|
field.key,
|
|
event.target.value.trim()
|
|
? normalizeHexColor(event.target.value)
|
|
: "",
|
|
)
|
|
}
|
|
aria-label={`${theme.name || "自定义主题"}${field.label}十六进制值`}
|
|
/>
|
|
</div>
|
|
{!explicit && (
|
|
<p className="mt-2 text-[11px] text-muted-foreground">
|
|
当前自动值:{automatic}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function CustomThemeLibrarySettings() {
|
|
const { data: appearance } = useAppearanceSettings();
|
|
const updateAppearance = useUpdateAppearanceSettings();
|
|
const [drafts, setDrafts] = useState<CustomThemePreset[]>([]);
|
|
const [advancedThemeIds, setAdvancedThemeIds] = useState<Set<string>>(
|
|
() => new Set(),
|
|
);
|
|
|
|
useEffect(() => {
|
|
setDrafts(normalizeThemeDrafts(appearance?.custom_themes));
|
|
}, [appearance?.custom_themes]);
|
|
|
|
const hasChanges = useMemo(
|
|
() =>
|
|
JSON.stringify(normalizeThemeDrafts(appearance?.custom_themes)) !==
|
|
JSON.stringify(normalizeThemeDrafts(drafts)),
|
|
[appearance?.custom_themes, drafts],
|
|
);
|
|
|
|
const patchTheme = (id: string, patch: Partial<CustomThemePreset>) => {
|
|
setDrafts((items) =>
|
|
items.map((item) => (item.id === id ? { ...item, ...patch } : item)),
|
|
);
|
|
};
|
|
|
|
const toggleAdvancedTheme = (id: string) => {
|
|
setAdvancedThemeIds((ids) => {
|
|
const next = new Set(ids);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
const cleaned = normalizeThemeDrafts(drafts).map((theme) => ({
|
|
...theme,
|
|
name: theme.name.trim(),
|
|
primary: normalizeHexColor(theme.primary),
|
|
}));
|
|
if (cleaned.some((theme) => !theme.name)) {
|
|
toast.error("请为每个主题填写名称");
|
|
return;
|
|
}
|
|
const names = cleaned.map((theme) => theme.name.toLocaleLowerCase());
|
|
if (new Set(names).size !== names.length) {
|
|
toast.error("主题名称不能重复");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await updateAppearance.mutateAsync({ custom_themes: cleaned });
|
|
toast.success("自定义主题库已保存,所有用户可在主题切换器中选择已启用主题");
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "保存主题库失败");
|
|
}
|
|
};
|
|
|
|
const handleSetDefault = async (theme: CustomThemePreset) => {
|
|
if (!theme.enabled || appearance?.default_theme === theme.id) return;
|
|
if (!(appearance?.custom_themes ?? []).some((preset) => preset.id === theme.id)) {
|
|
toast.info("请先保存主题库,再将新主题设为系统默认");
|
|
return;
|
|
}
|
|
try {
|
|
await updateAppearance.mutateAsync({ default_theme: theme.id });
|
|
toast.success(`已将“${theme.name || "自定义主题"}”设为系统默认主题`);
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "设置系统默认主题失败");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<p className="max-w-2xl text-xs leading-relaxed text-muted-foreground">
|
|
默认只需设置主色、主按钮文字与明暗模式;系统会自动生成页面正文、背景、边框、弱化、选中和悬停色。点击“更多”后才按需覆盖个别交互色,停用后不再向用户展示,最多可保存 {MAX_CUSTOM_THEMES} 个。
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() =>
|
|
setDrafts((items) =>
|
|
items.length >= MAX_CUSTOM_THEMES
|
|
? items
|
|
: [...items, newCustomTheme(items.length)],
|
|
)
|
|
}
|
|
disabled={drafts.length >= MAX_CUSTOM_THEMES || updateAppearance.isPending}
|
|
>
|
|
<PlusIcon className="mr-1.5 size-4" />
|
|
添加主题
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={() => void handleSave()}
|
|
disabled={!hasChanges || updateAppearance.isPending}
|
|
>
|
|
<SaveIcon className="mr-1.5 size-4" />
|
|
保存主题库
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{drafts.length === 0 ? (
|
|
<div className="rounded-lg border border-dashed bg-muted/20 px-5 py-8 text-center">
|
|
<PaletteIcon className="mx-auto mb-2 size-5 text-muted-foreground" />
|
|
<div className="text-sm font-medium">还没有自定义主题</div>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
添加后,用户可以从右上角主题切换器中自行选择。
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{drafts.map((theme, index) => {
|
|
const isDefault = appearance?.default_theme === theme.id;
|
|
const advancedOpen = advancedThemeIds.has(theme.id);
|
|
const automaticTokens = getCustomThemeAutomaticTokens({
|
|
primary: theme.primary,
|
|
mode: theme.mode,
|
|
});
|
|
const textColor = theme.text_color ?? automaticTokens["--foreground"] ?? "#202A3A";
|
|
return (
|
|
<div key={theme.id} className="space-y-4 rounded-xl border bg-card p-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<PaletteIcon className="size-4 text-primary" />
|
|
<span className="text-sm font-semibold">主题 {index + 1}</span>
|
|
{isDefault && (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
|
<CheckIcon className="size-3" />
|
|
系统默认
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Switch
|
|
checked={theme.enabled}
|
|
onCheckedChange={(enabled) => patchTheme(theme.id, { enabled })}
|
|
aria-label={`启用${theme.name || "自定义主题"}`}
|
|
/>
|
|
{theme.enabled ? "启用" : "停用"}
|
|
</label>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={isDefault ? "default" : "outline"}
|
|
onClick={() => void handleSetDefault(theme)}
|
|
disabled={!theme.enabled || isDefault || hasChanges || updateAppearance.isPending}
|
|
>
|
|
设为默认
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => toggleAdvancedTheme(theme.id)}
|
|
aria-expanded={advancedOpen}
|
|
aria-controls={`${theme.id}-advanced-colors`}
|
|
>
|
|
<SlidersHorizontalIcon className="mr-1.5 size-4" />
|
|
更多
|
|
{advancedOpen ? (
|
|
<ChevronUpIcon className="ml-1 size-3.5" />
|
|
) : (
|
|
<ChevronDownIcon className="ml-1 size-3.5" />
|
|
)}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
className="text-destructive hover:text-destructive"
|
|
onClick={() =>
|
|
setDrafts((items) =>
|
|
items
|
|
.filter((item) => item.id !== theme.id)
|
|
.map((item, order) => ({ ...item, sort_order: order })),
|
|
)
|
|
}
|
|
title="删除主题"
|
|
aria-label={`删除${theme.name || "自定义主题"}`}
|
|
>
|
|
<Trash2Icon className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(180px,0.55fr)_150px_150px]">
|
|
<label className="space-y-1.5">
|
|
<span className="text-xs font-medium">主题名称</span>
|
|
<Input
|
|
value={theme.name}
|
|
maxLength={32}
|
|
onChange={(event) => patchTheme(theme.id, { name: event.target.value })}
|
|
placeholder="例如:品牌浅蓝"
|
|
/>
|
|
</label>
|
|
<label className="space-y-1.5">
|
|
<span className="text-xs font-medium">基础模式</span>
|
|
<Select
|
|
value={theme.mode}
|
|
onValueChange={(value) =>
|
|
patchTheme(theme.id, {
|
|
mode: value === "dark" ? "dark" : "light",
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="light">
|
|
<span className="inline-flex items-center gap-1.5"><SunIcon className="size-3.5" />浅色</span>
|
|
</SelectItem>
|
|
<SelectItem value="dark">
|
|
<span className="inline-flex items-center gap-1.5"><MoonIcon className="size-3.5" />深色</span>
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</label>
|
|
<label className="space-y-1.5">
|
|
<span className="text-xs font-medium">主色值</span>
|
|
<Input
|
|
value={theme.primary}
|
|
maxLength={7}
|
|
className="font-mono uppercase"
|
|
onChange={(event) => patchTheme(theme.id, { primary: event.target.value })}
|
|
onBlur={() =>
|
|
patchTheme(theme.id, {
|
|
primary: normalizeHexColor(theme.primary),
|
|
})
|
|
}
|
|
aria-label={`${theme.name || "自定义主题"}主色十六进制值`}
|
|
/>
|
|
</label>
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-xs font-medium">主按钮文字</span>
|
|
{theme.text_color ? (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 px-1.5 text-[11px]"
|
|
onClick={() => patchTheme(theme.id, { text_color: null })}
|
|
>
|
|
自动
|
|
</Button>
|
|
) : (
|
|
<span className="text-[11px] text-muted-foreground">自动</span>
|
|
)}
|
|
</div>
|
|
<Input
|
|
value={textColor}
|
|
maxLength={7}
|
|
className="font-mono uppercase"
|
|
onChange={(event) =>
|
|
patchTheme(theme.id, { text_color: event.target.value })
|
|
}
|
|
onBlur={(event) =>
|
|
patchTheme(theme.id, {
|
|
text_color: event.target.value.trim()
|
|
? normalizeHexColor(event.target.value)
|
|
: null,
|
|
})
|
|
}
|
|
aria-label={`${theme.name || "自定义主题"}主按钮文字十六进制值`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-end gap-3 rounded-lg border bg-muted/15 p-3">
|
|
<div className="space-y-1.5">
|
|
<span className="text-xs font-medium">选择主色</span>
|
|
<InlineThemeColorPicker
|
|
value={theme.primary}
|
|
swatchColors={SUGGESTED_COLORS}
|
|
onCommit={(primary) => patchTheme(theme.id, { primary })}
|
|
ariaLabel={`${theme.name || "自定义主题"}主色选色器`}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<span className="text-xs font-medium">选择主按钮文字</span>
|
|
<InlineThemeColorPicker
|
|
value={textColor}
|
|
fallback="#202A3A"
|
|
swatchColors={TEXT_COLORS}
|
|
onCommit={(text_color) => patchTheme(theme.id, { text_color })}
|
|
ariaLabel={`${theme.name || "自定义主题"}主按钮文字选色器`}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5 pb-0.5">
|
|
{SUGGESTED_COLORS.map((color) => (
|
|
<button
|
|
key={color}
|
|
type="button"
|
|
className={cn(
|
|
"size-7 rounded-md border shadow-sm transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
normalizeHexColor(theme.primary) === color
|
|
? "ring-2 ring-primary ring-offset-2"
|
|
: "border-border",
|
|
)}
|
|
style={{ backgroundColor: color }}
|
|
onClick={() => patchTheme(theme.id, { primary: color })}
|
|
title={`使用 ${color}`}
|
|
aria-label={`使用主色 ${color}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{advancedOpen && (
|
|
<div id={`${theme.id}-advanced-colors`}>
|
|
<AdvancedThemeColorControls
|
|
theme={theme}
|
|
onPatch={(patch) => patchTheme(theme.id, patch)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<CustomThemePreview theme={{ ...theme, primary: normalizeHexColor(theme.primary) }} />
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|