功能:完善自定义主题与独立登录会话

This commit is contained in:
董宏凯 committed 2026-07-26 18:19:50 +08:00
1 parent 3779bb69ef
commit f1e53ef5c0
23 files changed
+1742 -196

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@ const buttonVariants = cva(
variants: {
variant: {
default:
"cursor-pointer bg-primary text-primary-foreground hover:bg-primary/90",
"cursor-pointer bg-primary text-[var(--button-foreground,var(--primary-foreground))] hover:bg-primary/90",
destructive:
"cursor-pointer bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
@@ -47,10 +47,18 @@ type AgentAvatarTheme =
| "lightyellow"
| "light"
| "lightblue"
| "lightgreen";
| "lightgreen"
| `custom-theme-${string}`;
/** Returns a stable avatar gradient and readable icon color for each theme. */
export function agentColor(id: string, theme: AgentAvatarTheme) {
if (theme.startsWith("custom-theme-")) {
return {
from: "var(--accent)",
to: "var(--brand-200)",
foreground: "var(--accent-foreground)",
};
}
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
@@ -1,7 +1,7 @@
"use client";
import { Check, ExternalLink, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
@@ -84,8 +84,23 @@ export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }
export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
const { theme, setTheme } = useTheme();
const { data: appearance } = useAppearanceSettings();
const [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const swatches = useMemo(
() => [
...THEME_SWATCHES,
...(appearance?.custom_themes ?? [])
.filter((preset) => preset.enabled)
.sort((a, b) => a.sort_order - b.sort_order)
.map((preset) => ({
id: preset.id as ThemeType,
label: preset.name,
color: preset.primary,
})),
],
[appearance?.custom_themes],
);
const cancelClose = () => {
if (closeTimer.current) {
@@ -124,9 +139,9 @@ export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
</Button>
{open ? (
<div className="absolute top-full left-1/2 z-50 -translate-x-1/2 pt-2">
<div className="relative flex items-center gap-2 rounded-lg border border-black/5 bg-white px-2.5 py-1.5 shadow-lg">
<div className="absolute -top-1 left-1/2 size-2 -translate-x-1/2 rotate-45 rounded-[1px] border-t border-l border-black/5 bg-white" />
{THEME_SWATCHES.map((swatch) => {
<div className="relative flex max-w-64 flex-wrap items-center justify-center gap-2 rounded-lg border border-border bg-popover px-2.5 py-1.5 shadow-lg">
<div className="absolute -top-1 left-1/2 size-2 -translate-x-1/2 rotate-45 rounded-[1px] border-t border-l border-border bg-popover" />
{swatches.map((swatch) => {
const active = theme === swatch.id;
return (
<button
@@ -1172,7 +1172,7 @@ export function InputBox({
className={cn(
"rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none",
compact &&
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--primary-foreground)] dark:hover:opacity-90 dark:disabled:bg-muted",
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--button-foreground,var(--primary-foreground))] dark:hover:opacity-90 dark:disabled:bg-muted",
)}
disabled={disabled}
variant="outline"
@@ -11,6 +11,7 @@ import {
ListIcon,
MoonIcon,
PanelLeftIcon,
PaletteIcon,
Plus,
SnowflakeIcon,
SunIcon,
@@ -20,7 +21,7 @@ import { useEffect, useMemo, useState, type ComponentType, type SVGProps } from
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import { FONT_SCALE_OPTIONS } from "@/components/font-scale";
import { Button } from "@/components/ui/button";
@@ -63,9 +64,10 @@ import {
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage";
import { SettingsSection } from "./settings-section";
import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage";
import { CustomThemeLibrarySettings } from "./custom-theme-library-settings";
import { SettingsSection } from "./settings-section";
const languageOptions: { value: Locale; label: string }[] = [
{ value: "en-US", label: enUS.locale.localName },
@@ -176,9 +178,21 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const { data: citationDisplay } = useCitationDisplaySettings();
const updateCitationDisplay = useUpdateCitationDisplaySettings();
const globalDefault = appearance?.default_theme ?? "light";
const globalSkillView = appearance?.default_skill_view ?? "list";
const globalAgentView = appearance?.default_agent_view ?? "card";
const globalLayoutMode = appearance?.default_layout_mode ?? "full";
const globalSkillView = appearance?.default_skill_view ?? "list";
const globalAgentView = appearance?.default_agent_view ?? "card";
const globalLayoutMode = appearance?.default_layout_mode ?? "full";
const globalDefaultLabel = useMemo(() => {
const customTheme = (appearance?.custom_themes ?? []).find(
(preset) => preset.id === globalDefault,
);
if (customTheme) return customTheme.name;
if (globalDefault === "light") return "浅色";
if (globalDefault === "dark") return "深色";
if (globalDefault === "lightgreen") return "绿色";
if (globalDefault === "iceblue") return "冰川蓝";
if (globalDefault === "lightyellow") return "浅黄色";
return "浅蓝";
}, [appearance?.custom_themes, globalDefault]);
// 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则会话级回退)。
const [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
const cleanupWords =
@@ -428,18 +442,34 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
description={t.settings.appearance.themeDescription}
>
<div className="grid gap-3 lg:grid-cols-2">
{themeOptions.map((option) => (
<ThemePreviewCard
{themeOptions.map((option) => (
<ThemePreviewCard
key={option.id}
icon={option.icon}
label={option.label}
description={option.description}
active={currentTheme === option.id}
mode={option.id as "light" | "dark" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow"}
onSelect={(value) => setTheme(value)}
/>
))}
</div>
onSelect={(value) => setTheme(value)}
/>
))}
{(appearance?.custom_themes ?? [])
.filter((preset) => preset.enabled)
.sort((a, b) => a.sort_order - b.sort_order)
.map((preset) => (
<ThemePreviewCard
key={preset.id}
icon={PaletteIcon}
label={preset.name}
description={`自定义主题 · 主色 ${preset.primary}`}
active={currentTheme === preset.id}
mode={preset.id}
previewDark={preset.mode === "dark"}
accentColor={preset.primary}
onSelect={(value) => setTheme(value)}
/>
))}
</div>
</SettingsSection>
<Separator />
@@ -617,26 +647,40 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
<SunIcon className="mr-1.5 size-4" />
浅黄色
</Button>
<p className="text-muted-foreground ml-2 text-xs">
当前系统默认:
{globalDefault === "light"
? "浅色"
: globalDefault === "dark"
? "深色"
: globalDefault === "lightgreen"
? "绿色"
: globalDefault === "iceblue"
? "冰川蓝"
: globalDefault === "lightyellow"
? "浅黄色"
: "浅蓝"}
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
document
.getElementById("custom-theme-library")
?.scrollIntoView({ behavior: "smooth", block: "start" })
}
>
<PaletteIcon className="mr-1.5 size-4" />
管理自定义主题
</Button>
<p className="text-muted-foreground ml-2 text-xs">
当前系统默认:{globalDefaultLabel}
</p>
</div>
</SettingsSection>
</>
)}
{isAdmin && (
</>
)}
{isAdmin && (
<div id="custom-theme-library" className="scroll-mt-4">
<Separator />
<SettingsSection
title="自定义主题库(管理员)"
description="管理员可按品牌主色创建主题,并设置为系统默认;用户只会看到已启用的主题并可自行切换。"
>
<CustomThemeLibrarySettings />
</SettingsSection>
</div>
)}
{isAdmin && (
<>
<Separator />
<SettingsSection
@@ -1137,19 +1181,24 @@ function CompactShortcutSettings({
function ThemePreviewCard({
icon: Icon,
label,
description,
active,
mode,
onSelect,
}: {
icon: ComponentType<SVGProps<SVGSVGElement>>;
label: string;
description: string;
active: boolean;
mode: "light" | "dark" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow";
onSelect: (mode: "light" | "dark" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow") => void;
}) {
const previewMode = mode;
description,
active,
mode,
previewDark,
accentColor,
onSelect,
}: {
icon: ComponentType<SVGProps<SVGSVGElement>>;
label: string;
description: string;
active: boolean;
mode: ThemeType;
previewDark?: boolean;
accentColor?: string;
onSelect: (mode: ThemeType) => void;
}) {
const previewMode = mode;
const isDarkPreview = previewDark ?? previewMode === "dark";
return (
<button
type="button"
@@ -1175,7 +1224,7 @@ function ThemePreviewCard({
<div
className={cn(
"relative overflow-hidden rounded-md border text-xs transition-colors",
previewMode === "dark"
isDarkPreview
? "border-neutral-800 bg-neutral-900 text-neutral-200"
: previewMode === "lightblue"
? "border-blue-200 bg-white text-slate-900"
@@ -1192,18 +1241,21 @@ function ThemePreviewCard({
<div
className={cn(
"h-2 w-2 rounded-full",
previewMode === "dark"
? "bg-emerald-400"
: previewMode === "lightblue"
? "bg-blue-500"
: previewMode === "lightgreen"
? "bg-[#07c05f]"
: previewMode === "iceblue"
? "bg-[#9BCEF1]"
: previewMode === "lightyellow"
? "bg-[#F3FBD9]"
: "bg-[#cf1322]",
)}
accentColor
? ""
: isDarkPreview
? "bg-emerald-400"
: previewMode === "lightblue"
? "bg-blue-500"
: previewMode === "lightgreen"
? "bg-[#07c05f]"
: previewMode === "iceblue"
? "bg-[#9BCEF1]"
: previewMode === "lightyellow"
? "bg-[#F3FBD9]"
: "bg-[#cf1322]",
)}
style={accentColor ? { backgroundColor: accentColor } : undefined}
/>
<div className="h-2 w-10 rounded-full bg-current/20" />
<div className="h-2 w-6 rounded-full bg-current/15" />
@@ -0,0 +1,753 @@
"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 { 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: "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 =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID().replace(/[^a-z0-9-]/gi, "").toLowerCase()
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
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)] bg-[var(--background)] px-3 py-2">
<span
className="flex size-5 items-center justify-center rounded-md bg-[var(--primary)] text-[10px] font-bold text-[var(--primary-foreground)] ring-2 ring-[var(--ring)]"
aria-hidden
>
C
</span>
<span className="text-xs font-semibold text-[var(--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>
);
}
@@ -184,7 +184,7 @@ export function WorkspaceNavChatList({
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
{/* <SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/roundtable/position")}
asChild
@@ -192,10 +192,10 @@ export function WorkspaceNavChatList({
>
<Link href="/page/workspace/roundtable/position">
<BriefcaseBusiness />
<span>岗位会商</span>
<span>岗位会商1</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenuItem> */}
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/scheduled/tasks"}
+80 -32
View File
@@ -8,14 +8,20 @@ import React, {
import { useTheme as useNextTheme } from 'next-themes';
import { useAppearanceSettings } from '@/core/system-settings/hooks';
import {
BUILTIN_THEME_IDS,
type BuiltinThemeId,
type CustomThemePreset,
} from '@/core/system-settings/api';
import { getCustomThemeTokens, isCustomThemeId } from '@/core/theme/custom-theme';
import { isPositionRoundtableStandaloneMode } from '@/position-roundtable/demo/position-roundtable-demo-config';
// 主题类型
export type ThemeType = 'dark' | 'light' | 'lightblue' | 'lightgreen' | 'iceblue' | 'lightyellow';
export type ThemeType = BuiltinThemeId | `custom-theme-${string}`;
// 主题配置
export interface ThemeConfig {
type: ThemeType;
type: BuiltinThemeId;
name: string;
colors: {
background: string;
@@ -38,7 +44,7 @@ export interface ThemeConfig {
}
// 主题配置
const themes: Record<ThemeType, ThemeConfig> = {
const themes: Record<BuiltinThemeId, ThemeConfig> = {
dark: {
type: 'dark',
name: '深色主题',
@@ -183,16 +189,30 @@ const STORAGE_KEY = 'strategy-theme';
// admin changes. When the flag is present we never override the user.
const EXPLICIT_FLAG_KEY = 'strategy-theme-user-set';
const CUSTOM_THEME_TOKEN_NAMES = Object.keys(
getCustomThemeTokens({ primary: '#0062D9', mode: 'light' }),
);
function isBuiltinTheme(value: string | null | undefined): value is BuiltinThemeId {
return typeof value === 'string' && (BUILTIN_THEME_IDS as readonly string[]).includes(value);
}
function getEnabledCustomTheme(
id: string | null | undefined,
presets: CustomThemePreset[] | undefined,
): CustomThemePreset | undefined {
return isCustomThemeId(id)
? presets?.find((preset) => preset.id === id && preset.enabled)
: undefined;
}
function readPersistedTheme(): ThemeType {
if (typeof window === 'undefined') {
return 'light';
}
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === 'dark') return 'dark';
if (saved === 'lightblue') return 'lightblue';
if (saved === 'lightgreen') return 'lightgreen';
if (saved === 'iceblue') return 'iceblue';
if (saved === 'lightyellow') return 'lightyellow';
if (isBuiltinTheme(saved)) return saved;
if (isCustomThemeId(saved)) return saved;
return 'light';
}
@@ -249,15 +269,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const current = localStorage.getItem(STORAGE_KEY);
const legacy = localStorage.getItem('theme');
if (
current &&
current !== 'light' &&
current !== 'dark' &&
current !== 'lightblue' &&
current !== 'lightgreen' &&
current !== 'iceblue' &&
current !== 'lightyellow'
) {
if (current && !isBuiltinTheme(current) && !isCustomThemeId(current)) {
// Garbage value — wipe it so the boot-sync below can take over.
localStorage.removeItem(STORAGE_KEY);
}
@@ -288,25 +300,47 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
}, [mounted, forcedTheme, appearance?.default_theme, resolvedTheme, setNextTheme]);
const effectiveTheme = forcedTheme ?? resolvedTheme;
const theme: ThemeType = mounted
? effectiveTheme === 'dark'
? 'dark'
: effectiveTheme === 'lightblue'
? 'lightblue'
: effectiveTheme === 'lightgreen'
? 'lightgreen'
: effectiveTheme === 'iceblue'
? 'iceblue'
: effectiveTheme === 'lightyellow'
? 'lightyellow'
: 'light'
: readPersistedTheme();
const persistedTheme = readPersistedTheme();
const requestedTheme = (mounted ? effectiveTheme : persistedTheme) ?? 'light';
const customTheme = getEnabledCustomTheme(
requestedTheme,
appearance?.custom_themes,
);
const theme: ThemeType = isBuiltinTheme(requestedTheme)
? requestedTheme
: customTheme?.id ?? 'light';
useEffect(() => {
if (typeof document === 'undefined') return;
const root = document.documentElement;
// next-themes adds a class for every value passed to setTheme(). Custom
// themes use data attributes and CSS variables instead, so remove its
// transient class before applying the generated token set.
for (const className of [...root.classList]) {
if (isCustomThemeId(className)) {
root.classList.remove(className);
}
}
for (const tokenName of CUSTOM_THEME_TOKEN_NAMES) {
root.style.removeProperty(tokenName);
}
root.removeAttribute('data-custom-theme');
if (customTheme) {
for (const [tokenName, tokenValue] of Object.entries(
getCustomThemeTokens(customTheme),
)) {
root.style.setProperty(tokenName, tokenValue);
}
root.setAttribute('data-theme', customTheme.id);
root.setAttribute('data-custom-theme', 'true');
root.setAttribute('theme-mode', customTheme.mode);
root.classList.toggle('dark', customTheme.mode === 'dark');
return;
}
if (theme === 'dark') {
root.setAttribute('theme-mode', 'dark');
root.classList.add('dark');
@@ -327,7 +361,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
} else {
root.removeAttribute('data-theme');
}
}, [theme]);
}, [customTheme, theme]);
const toggleTheme = () => {
setNextTheme(theme === 'dark' ? 'light' : 'dark');
@@ -339,7 +373,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
markExplicitChoice();
};
const themeConfig = themes[theme];
const themeConfig = themes[isBuiltinTheme(theme) ? theme : 'light'];
return (
<ThemeContext.Provider
@@ -365,6 +399,20 @@ export const getThemeClasses = (colorKey: keyof ThemeConfig['colors']): string =
return themeConfig.colors[colorKey];
};
/** Resolve custom runtime themes to the two visual modes expected by older
* integrations (reports, canvases and exported artifacts). */
export function themeToBaseMode(theme: ThemeType): 'light' | 'dark' {
if (theme === 'dark') return 'dark';
if (
isCustomThemeId(theme) &&
typeof document !== 'undefined' &&
document.documentElement.getAttribute('theme-mode') === 'dark'
) {
return 'dark';
}
return 'light';
}
// 组合类名工具函数
export const cn = (...classes: (string | undefined | null | false)[]): string => {
return classes.filter(Boolean).join(' ');
+15 -6
View File
@@ -4,6 +4,7 @@ import fallbackLogoLightblue from "@/assets/img/brand-logo-lightblue.png";
import { readRuntimeEnv } from "@/env";
export type BrandTheme = "light" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow" | "dark";
type BrandAssetTheme = BrandTheme | `custom-theme-${string}`;
const headerFallbackAssets: Record<BrandTheme, string> = {
light: fallbackLogoLight,
@@ -61,18 +62,26 @@ function readRuntimeBrandAsset(key: string): string | undefined {
}
}
export function getHeaderBrandAsset(theme: BrandTheme): string {
return readRuntimeBrandAsset(headerRuntimeKeys[theme]) ?? headerFallbackAssets[theme];
function resolveBrandTheme(theme: BrandAssetTheme): BrandTheme {
if (!theme.startsWith("custom-theme-")) return theme as BrandTheme;
return typeof document !== "undefined" && document.documentElement.getAttribute("theme-mode") === "dark"
? "dark"
: "light";
}
export function getHeaderBrandFallback(theme: BrandTheme): string {
return headerFallbackAssets[theme];
export function getHeaderBrandAsset(theme: BrandAssetTheme): string {
const resolved = resolveBrandTheme(theme);
return readRuntimeBrandAsset(headerRuntimeKeys[resolved]) ?? headerFallbackAssets[resolved];
}
export function getHeaderBrandFallback(theme: BrandAssetTheme): string {
return headerFallbackAssets[resolveBrandTheme(theme)];
}
/**
* Welcome content deliberately has no bundled image fallback. An empty
* runtime URL keeps the original CM text + gradient treatment.
*/
export function getWelcomeBrandAsset(theme: BrandTheme): string | undefined {
return readRuntimeBrandAsset(welcomeRuntimeKeys[theme]);
export function getWelcomeBrandAsset(theme: BrandAssetTheme): string | undefined {
return readRuntimeBrandAsset(welcomeRuntimeKeys[resolveBrandTheme(theme)]);
}
@@ -65,12 +65,12 @@ export const sidebarMenuItems: MenuItem[] = [
icon: Users,
path: "/page/strategy/qa/multi-agent-roundtable",
},
{
id: "position-roundtable",
label: "岗位会商",
icon: Briefcase,
path: "/page/strategy/qa/position-roundtable",
},
// {
// id: "position-roundtable",
// label: "岗位会商",
// icon: Briefcase,
// path: "/page/strategy/qa/position-roundtable",
// },
{
id: "dashboard-agent",
label: "大屏绘制",
+36 -1
View File
@@ -276,7 +276,40 @@ export async function updatePublishSquares(
return data;
}
export type ThemeMode = "light" | "dark" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow";
export const BUILTIN_THEME_IDS = [
"light",
"dark",
"lightblue",
"lightgreen",
"iceblue",
"lightyellow",
] as const;
export type BuiltinThemeId = (typeof BUILTIN_THEME_IDS)[number];
export type ThemeMode = BuiltinThemeId | `custom-theme-${string}`;
export type CustomThemeBaseMode = "light" | "dark";
export interface CustomThemeColorOverrides {
button_hover?: string | null;
selected_background?: string | null;
border?: string | null;
muted?: string | null;
muted_text?: string | null;
destructive?: string | null;
brand_title?: string | null;
primary_foreground?: string | null;
}
export interface CustomThemePreset {
id: `custom-theme-${string}`;
name: string;
primary: string;
text_color?: string | null;
mode: CustomThemeBaseMode;
overrides?: CustomThemeColorOverrides;
enabled: boolean;
sort_order: number;
}
export type ViewMode = "card" | "list";
export type LayoutMode = "full" | "compact";
export type CompactShortcutParamType = "login" | "theme";
@@ -304,6 +337,7 @@ export interface AppearanceSettings {
default_agent_view: ViewMode;
default_layout_mode: LayoutMode;
compact_shortcut_buttons: CompactShortcutButton[];
custom_themes: CustomThemePreset[];
}
export interface AppearanceUpdate {
@@ -312,6 +346,7 @@ export interface AppearanceUpdate {
default_agent_view?: ViewMode;
default_layout_mode?: LayoutMode;
compact_shortcut_buttons?: CompactShortcutButton[];
custom_themes?: CustomThemePreset[];
}
export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
+284
View File
@@ -0,0 +1,284 @@
import type {
CustomThemeColorOverrides,
CustomThemePreset,
} from "@/core/system-settings/api";
export const CUSTOM_THEME_PREFIX = "custom-theme-";
type Rgb = { r: number; g: number; b: number };
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
const WHITE: Rgb = { r: 255, g: 255, b: 255 };
const BLACK: Rgb = { r: 0, g: 0, b: 0 };
export const CUSTOM_THEME_ADVANCED_KEYS = [
"button_hover",
"selected_background",
"border",
"muted",
"muted_text",
"destructive",
"brand_title",
"primary_foreground",
] as const;
export type CustomThemeAdvancedKey = (typeof CUSTOM_THEME_ADVANCED_KEYS)[number];
export type CustomThemeTokenInput = Pick<
CustomThemePreset,
"primary" | "mode"
> &
Partial<Pick<CustomThemePreset, "text_color" | "overrides">>;
export function isCustomThemeId(value: string | null | undefined): value is `custom-theme-${string}` {
return typeof value === "string" && value.startsWith(CUSTOM_THEME_PREFIX);
}
export function normalizeHexColor(value: string, fallback = "#0062D9"): string {
const normalized = value.trim().toUpperCase();
return HEX_COLOR.test(normalized) ? normalized : fallback;
}
function optionalHexColor(value: string | null | undefined): string | undefined {
if (!value?.trim() || !HEX_COLOR.test(value.trim())) return undefined;
return value.trim().toUpperCase();
}
function toRgb(value: string): Rgb {
const hex = normalizeHexColor(value).slice(1);
return {
r: Number.parseInt(hex.slice(0, 2), 16),
g: Number.parseInt(hex.slice(2, 4), 16),
b: Number.parseInt(hex.slice(4, 6), 16),
};
}
function toHex({ r, g, b }: Rgb): string {
const channel = (value: number) => Math.round(Math.min(255, Math.max(0, value))).toString(16).padStart(2, "0");
return `#${channel(r)}${channel(g)}${channel(b)}`.toUpperCase();
}
function mix(from: Rgb, to: Rgb, amount: number): Rgb {
const ratio = Math.min(1, Math.max(0, amount));
return {
r: from.r + (to.r - from.r) * ratio,
g: from.g + (to.g - from.g) * ratio,
b: from.b + (to.b - from.b) * ratio,
};
}
function relativeLuminance({ r, g, b }: Rgb): number {
const linear = [r, g, b].map((channel) => {
const value = channel / 255;
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * linear[0]! + 0.7152 * linear[1]! + 0.0722 * linear[2]!;
}
function contrastRatio(a: Rgb, b: Rgb): number {
const [lighter, darker] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x);
return (lighter! + 0.05) / (darker! + 0.05);
}
function readableForeground(background: Rgb): string {
return contrastRatio(background, BLACK) >= contrastRatio(background, WHITE) ? "#172033" : "#FFFFFF";
}
function ensureContrast(color: Rgb, background: Rgb, toward: Rgb, minimum = 4.5): Rgb {
let candidate = color;
for (let step = 1; step <= 10 && contrastRatio(candidate, background) < minimum; step += 1) {
candidate = mix(candidate, toward, 0.18);
}
return candidate;
}
function rgbChannels(color: Rgb): string {
return `${Math.round(color.r)} ${Math.round(color.g)} ${Math.round(color.b)}`;
}
/**
* Produce the semantic CSS tokens consumed by Tailwind/shadcn and TDesign.
* Inputs are restricted to a safe six-digit primary colour, optional primary
* button text colour, a small set of interaction overrides and a light/dark surface
* family, so callers never interpolate arbitrary CSS values.
*/
export function getCustomThemeTokens(theme: CustomThemeTokenInput): Record<string, string> {
const primary = toRgb(theme.primary);
const isDark = theme.mode === "dark";
const overrides: CustomThemeColorOverrides = theme.overrides ?? {};
const background = isDark ? mix({ r: 13, g: 19, b: 32 }, primary, 0.12) : mix(WHITE, primary, 0.035);
const card = isDark ? mix({ r: 18, g: 27, b: 44 }, primary, 0.12) : WHITE;
// Normal page copy stays automatic so selecting white for a teal/blue
// action does not accidentally make a light-page's body text white.
const preferredForeground = toRgb(isDark ? "#F4F7FB" : "#202A3A");
const foregroundRgb = ensureContrast(
preferredForeground,
background,
toRgb(readableForeground(background)),
);
const foreground = toHex(foregroundRgb);
const defaultMutedForeground = toRgb(isDark ? "#AAB4C2" : "#667085");
const mutedForegroundRgb = optionalHexColor(overrides.muted_text)
? ensureContrast(
toRgb(overrides.muted_text!),
background,
toRgb(readableForeground(background)),
3,
)
: defaultMutedForeground;
const mutedForeground = toHex(mutedForegroundRgb);
const defaultAccent = isDark ? mix(background, primary, 0.3) : mix(WHITE, primary, 0.14);
const accent = optionalHexColor(overrides.selected_background)
? toRgb(overrides.selected_background!)
: defaultAccent;
const accentHover = isDark ? mix(accent, primary, 0.2) : mix(accent, primary, 0.14);
const defaultBorder = isDark ? mix(background, WHITE, 0.16) : mix(WHITE, primary, 0.28);
const border = optionalHexColor(overrides.border) ? toRgb(overrides.border!) : defaultBorder;
const input = isDark ? mix(background, WHITE, 0.22) : mix(WHITE, primary, 0.38);
const action = ensureContrast(
mix(primary, isDark ? WHITE : BLACK, isDark ? 0.2 : 0.3),
isDark ? background : WHITE,
isDark ? WHITE : BLACK,
);
const actionHover = isDark ? mix(action, WHITE, 0.12) : mix(action, BLACK, 0.12);
const actionActive = isDark ? mix(action, WHITE, 0.22) : mix(action, BLACK, 0.22);
const defaultPrimaryHover = isDark ? mix(primary, WHITE, 0.1) : mix(primary, BLACK, 0.1);
const primaryHover = optionalHexColor(overrides.button_hover)
? toRgb(overrides.button_hover!)
: defaultPrimaryHover;
const primaryActive = isDark ? mix(primary, WHITE, 0.2) : mix(primary, BLACK, 0.2);
const primaryForeground = optionalHexColor(overrides.primary_foreground) ?? readableForeground(primary);
const buttonForeground = optionalHexColor(theme.text_color) ?? primaryForeground;
const brandTitle = optionalHexColor(overrides.brand_title) ?? foreground;
const defaultSecondary = isDark ? mix(background, WHITE, 0.08) : mix(WHITE, primary, 0.075);
const secondary = optionalHexColor(overrides.muted) ? toRgb(overrides.muted!) : defaultSecondary;
const sidebar = isDark ? mix(background, BLACK, 0.08) : mix(WHITE, primary, 0.018);
const ring = rgbChannels(primary);
const destructive = optionalHexColor(overrides.destructive) ?? (isDark ? "#FF777B" : "#CF4A4A");
const primaryHex = toHex(primary);
const actionHex = toHex(action);
const actionHoverHex = toHex(actionHover);
const actionActiveHex = toHex(actionActive);
const accentHex = toHex(accent);
const accentHoverHex = toHex(accentHover);
const backgroundHex = toHex(background);
const cardHex = toHex(card);
const borderHex = toHex(border);
const inputHex = toHex(input);
const secondaryHex = toHex(secondary);
const primaryHoverHex = toHex(primaryHover);
const primaryActiveHex = toHex(primaryActive);
const sidebarHex = toHex(sidebar);
return {
"--workspace-brand": primaryHex,
"--workspace-brand-foreground": primaryForeground,
"--brand-200": accentHex,
"--brand-900": actionHex,
"--brand-950": actionActiveHex,
"--theme-button-hover": primaryHoverHex,
"--theme-selected-background": accentHex,
"--background": backgroundHex,
"--foreground": foreground,
"--card": cardHex,
"--card-foreground": foreground,
"--popover": cardHex,
"--popover-foreground": foreground,
"--primary": primaryHex,
"--primary-foreground": primaryForeground,
"--button-foreground": buttonForeground,
"--brand-title-foreground": brandTitle,
"--secondary": secondaryHex,
"--secondary-foreground": actionHex,
"--muted": secondaryHex,
"--muted-foreground": mutedForeground,
"--accent": accentHex,
"--accent-foreground": actionHex,
"--destructive": destructive,
"--border": borderHex,
"--input": inputHex,
"--ring": `rgb(${ring} / 0.48)`,
"--chart-1": actionHex,
"--chart-2": toHex(mix(primary, { r: 20, g: 145, b: 150 }, 0.45)),
"--chart-3": toHex(mix(primary, { r: 108, g: 96, b: 175 }, 0.45)),
"--chart-4": toHex(mix(primary, { r: 220, g: 137, b: 54 }, 0.45)),
"--chart-5": toHex(mix(primary, { r: 57, g: 139, b: 110 }, 0.45)),
"--sidebar": sidebarHex,
"--sidebar-foreground": foreground,
"--sidebar-primary": primaryHex,
"--sidebar-primary-foreground": primaryForeground,
"--sidebar-accent": accentHex,
"--sidebar-accent-foreground": actionHex,
"--sidebar-border": borderHex,
"--sidebar-ring": `rgb(${ring} / 0.48)`,
"--legacy-accent-rgb": rgbChannels(accent),
"--legacy-background-rgb": rgbChannels(background),
"--legacy-border-rgb": rgbChannels(border),
"--legacy-brand-200-rgb": rgbChannels(accent),
"--legacy-card-rgb": rgbChannels(card),
"--legacy-destructive-rgb": rgbChannels(toRgb(destructive)),
"--legacy-foreground-rgb": rgbChannels(foregroundRgb),
"--legacy-input-rgb": rgbChannels(input),
"--legacy-muted-rgb": rgbChannels(secondary),
"--legacy-muted-foreground-rgb": rgbChannels(mutedForegroundRgb),
"--legacy-primary-rgb": rgbChannels(primary),
"--legacy-primary-foreground-rgb": rgbChannels(toRgb(primaryForeground)),
"--legacy-ring-rgb": ring,
"--legacy-secondary-rgb": rgbChannels(secondary),
"--legacy-sidebar-accent-rgb": rgbChannels(accent),
"--legacy-sidebar-border-rgb": rgbChannels(border),
"--legacy-sidebar-foreground-rgb": rgbChannels(foregroundRgb),
"--legacy-workspace-brand-rgb": rgbChannels(primary),
"--td-brand-color-1": backgroundHex,
"--td-brand-color-2": secondaryHex,
"--td-brand-color-3": accentHex,
"--td-brand-color-4": accentHoverHex,
"--td-brand-color-5": primaryHex,
"--td-brand-color-6": primaryHex,
"--td-brand-color-7": actionHex,
"--td-brand-color-8": actionHoverHex,
"--td-brand-color-9": actionActiveHex,
"--td-brand-color-10": actionActiveHex,
"--td-brand-color": primaryHex,
"--td-brand-color-hover": primaryHoverHex,
"--td-brand-color-focus": accentHex,
"--td-brand-color-active": primaryActiveHex,
"--td-brand-color-disabled": accentHoverHex,
"--td-brand-color-light": accentHex,
"--td-brand-color-light-hover": accentHoverHex,
"--td-text-color-primary": foreground,
"--td-text-color-secondary": mutedForeground,
"--td-text-color-brand": actionHex,
"--td-text-color-link": actionHex,
"--td-text-color-anti": buttonForeground,
"--td-bg-color-page": backgroundHex,
"--td-bg-color-container": cardHex,
"--td-bg-color-container-hover": secondaryHex,
"--td-bg-color-container-active": accentHex,
"--td-bg-color-secondarycontainer": secondaryHex,
"--td-bg-color-secondarycontainer-hover": accentHex,
"--td-bg-color-secondarycontainer-active": accentHoverHex,
"--td-border-level-1-color": borderHex,
"--td-component-stroke": borderHex,
"--td-border-level-2-color": inputHex,
"--td-component-border": inputHex,
};
}
/** Tokens produced from the automatic palette only, used by the editor to
* show which advanced values are currently inheriting from the system. */
export function getCustomThemeAutomaticTokens(
theme: Pick<CustomThemePreset, "primary" | "mode">,
): Record<string, string> {
return getCustomThemeTokens(theme);
}
export function customThemeContrast(primary: string): number {
const color = toRgb(primary);
return Math.max(contrastRatio(color, BLACK), contrastRatio(color, WHITE));
}
export function customThemeTextContrast(theme: CustomThemeTokenInput): number {
const tokens = getCustomThemeTokens(theme);
return contrastRatio(toRgb(tokens["--button-foreground"]!), toRgb(tokens["--primary"]!));
}
@@ -28,7 +28,7 @@ import {
getNodeTitle,
} from "@/components/knowledge-graph/graphUtils";
import "@/components/knowledge-graph/graphStyles.css";
import { useTheme } from "@/contexts/ThemeContext";
import { themeToBaseMode, useTheme } from "@/contexts/ThemeContext";
import {
useGraphBlacklist,
useGraphBlacklistActions,
@@ -799,7 +799,7 @@ export default function KnowledgeGraphPage() {
searchMatchIds={searchMatchIds}
layoutType={layoutType}
showLabels={showLabels}
theme={theme}
theme={themeToBaseMode(theme)}
onSelectNode={handleSelectNode}
onTooltip={setTooltip}
onZoomChange={setZoomLevel}
@@ -35,6 +35,7 @@ import { toast } from "sonner";
import { Pagination } from "@/components/ui/pagination";
import { cn } from "@/lib/utils";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import { isCustomThemeId } from "@/core/theme/custom-theme";
import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
@@ -65,6 +66,17 @@ function hashIndex(str: string, mod: number): number {
}
function getLightAccent(theme: ThemeType) {
if (isCustomThemeId(theme)) {
return {
dot: "bg-[var(--primary)]",
badge: "border-[var(--border)] bg-[var(--accent)] text-[var(--accent-foreground)]",
focus: "focus:border-[var(--brand-900)] focus:ring-[var(--ring)]",
text: "text-[var(--accent-foreground)]",
hoverText: "group-hover:text-[var(--accent-foreground)]",
link: "text-[var(--accent-foreground)] hover:text-[var(--brand-950)]",
button: "bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] shadow-sm hover:bg-[var(--theme-button-hover)]",
};
}
if (theme === "lightgreen") {
return {
dot: "bg-[#07C05F]",
+17 -6
View File
@@ -90,12 +90,14 @@ export default function LoginPage() {
() => readEmbedParam(searchParams.toString()) === 'on',
[searchParams],
);
// External consumers may still send `from=iframe` for the legacy protocol
// chat. `chatMode=full` is an explicit escape hatch: always use the regular
// full ChatPage instead, which is the route expected by ordinary iframe
// nesting and browser deep links.
// Wujie is an application container, not the Brainy iframe protocol. It
// must therefore land on the regular full ChatPage even when an old portal
// URL still carries `from=iframe`. Plain embeds can request the same route
// explicitly with `chatMode=full`.
const fullChatRequested = useMemo(
() => searchParams.get('chatMode')?.trim().toLowerCase() === FULL_CHAT_MODE,
() =>
isWujieEnv() ||
searchParams.get('chatMode')?.trim().toLowerCase() === FULL_CHAT_MODE,
[searchParams],
);
const redirectPath = useMemo(
@@ -148,6 +150,15 @@ export default function LoginPage() {
const normalized = raw.startsWith('/') ? raw : `/${raw}`;
const [pathOnly, ownQuery = ''] = normalized.split('?');
const target = new URLSearchParams(ownQuery);
// Keep the Wujie/full-chat decision on the target URL. The destination
// route uses it to retain the full agent/history workspace sidebar.
if (
fullChatRequested &&
pathOnly === DEFAULT_REDIRECT_PATH &&
!target.has('chatMode')
) {
target.set('chatMode', FULL_CHAT_MODE);
}
// 登录流程自身消费的参数,不透传到目标页(尤以鉴权类为甚)。
const LOGIN_OWNED = new Set([
'redirect', 'route', 'ticket', 'userId', 'env', 'theme', 'yUserId',
@@ -160,7 +171,7 @@ export default function LoginPage() {
});
const qs = target.toString();
return qs ? `${pathOnly}?${qs}` : pathOnly;
}, [searchParams]);
}, [fullChatRequested, searchParams]);
const externalParams = useMemo(
() => ({
theme: searchParams.get('theme') ?? '',
+63 -24
View File
@@ -2,6 +2,7 @@ import type { CSSProperties, ReactNode } from 'react';
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { getStoredAuth } from '@/core/auth';
import { isWujieEnv } from '@/core/embed/wujie';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
@@ -254,6 +255,66 @@ function NewChatEntryRoute({
);
}
/**
* `chats_iframe` is reserved for the Brainy postMessage protocol. Some legacy
* Wujie links still target it (or carry `from=iframe` during login), but Wujie
* needs the normal full chat workspace instead. Redirect it before mounting
* IframeChatPage; `chatMode=iframe` remains an explicit opt-in escape hatch
* for a Wujie integration that really implements that protocol.
*/
function IframeChatEntryRoute({
layoutProps,
hasThreadParam,
}: {
layoutProps: {
pageSidebarCollapsed: boolean;
pageSidebarHidden: boolean;
workspaceSidebarHidden: boolean;
headerHidden: boolean;
compactWorkspaceSidebar: boolean;
compactActionsHidden: boolean;
pageSidebarDark: boolean;
forceWorkspaceSidebarCollapsed: boolean;
};
hasThreadParam: boolean;
}) {
const location = useLocation();
const { thread_id } = useParams<{ thread_id: string }>();
const requestedMode = new URLSearchParams(location.search)
.get('chatMode')
?.trim()
.toLowerCase();
if (isWujieEnv() && requestedMode !== 'iframe') {
const targetParams = new URLSearchParams(location.search);
targetParams.set('chatMode', 'full');
const targetPath = hasThreadParam && thread_id
? `/page/workspace/chats/${thread_id}`
: '/page/workspace/chats/new';
const targetSearch = targetParams.toString();
return (
<Navigate
to={targetSearch ? `${targetPath}?${targetSearch}` : targetPath}
replace
/>
);
}
return (
<WorkspaceLayout
{...layoutProps}
pageSidebarHidden
workspaceSidebarHidden
wrapperOverflowHidden={hasThreadParam}
insetOverflowHidden={hasThreadParam}
>
<ChatRuntime>
<IframeChatPage />
</ChatRuntime>
</WorkspaceLayout>
);
}
export default function WorkspaceRoutes({
pageSidebarCollapsed = false,
pageSidebarHidden = false,
@@ -312,33 +373,11 @@ export default function WorkspaceRoutes({
/>
<Route
path="chats_iframe/new"
element={
<WorkspaceLayout
{...layoutProps}
pageSidebarHidden
workspaceSidebarHidden
>
<ChatRuntime>
<IframeChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
element={<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam={false} />}
/>
<Route
path="chats_iframe/:thread_id"
element={
<WorkspaceLayout
{...layoutProps}
pageSidebarHidden
workspaceSidebarHidden
wrapperOverflowHidden
insetOverflowHidden
>
<ChatRuntime>
<IframeChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
element={<IframeChatEntryRoute layoutProps={layoutProps} hasThreadParam />}
/>
<Route
path="agents"
@@ -17,7 +17,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useArtifacts } from "@/components/workspace/artifacts";
import { useTheme } from "@/contexts/ThemeContext";
import { themeToBaseMode, useTheme } from "@/contexts/ThemeContext";
import { apiFetch } from "@/core/api";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import {
@@ -261,7 +261,8 @@ export function useStep3Report(
options;
// 页面主题:浅色沿用浅红效果,深色让报告智能体按「深蓝 UI 规范」绘制。
const { theme } = useTheme();
const { theme: activeTheme } = useTheme();
const theme = themeToBaseMode(activeTheme);
// onReportSaved 身份可能每次 render 变 —— 打到 ref,避免进 run() 的 deps 反复重建。
const onReportSavedRef = useRef(onReportSaved);
@@ -2996,7 +2996,7 @@ function RoundtableEntryRail({
<span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground">
<Target className="w-3 h-3" strokeWidth={2.5} />
</span>
<h2 className="text-sm font-semibold tracking-wide text-slate-900">当前行动</h2>
<h2 className="theme-brand-title text-sm font-semibold tracking-wide text-slate-900">当前行动</h2>
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">行动名称</p>
@@ -3011,7 +3011,7 @@ function RoundtableEntryRail({
<span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground">
<Clipboard className="w-3 h-3" strokeWidth={2.5} />
</span>
<h2 className="text-sm font-semibold tracking-wide text-slate-900">当前任务</h2>
<h2 className="theme-brand-title text-sm font-semibold tracking-wide text-slate-900">当前任务</h2>
</div>
<div className="space-y-2">
<div>
@@ -3036,7 +3036,7 @@ function RoundtableEntryRail({
<span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground">
<Layers className="w-3 h-3" strokeWidth={2.5} />
</span>
<h2 className="text-sm font-semibold tracking-wide text-slate-900">业务链</h2>
<h2 className="theme-brand-title text-sm font-semibold tracking-wide text-slate-900">业务链</h2>
<span className="ml-auto flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-neutral-100 text-sm font-bold tabular-nums leading-none text-neutral-500">
{chains.length}
</span>
@@ -3249,7 +3249,7 @@ function RoundtableEntryRail({
<span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground">
<History className="w-3 h-3" strokeWidth={2.5} />
</span>
<h2 className="text-sm font-semibold tracking-wide text-slate-900">分析记录</h2>
<h2 className="theme-brand-title text-sm font-semibold tracking-wide text-slate-900">分析记录</h2>
{/* 按业务链筛选 —— 仅当存在多于 1 个可选业务链时才显示图标按钮。 */}
{chainFilterOptions.length > 1 && (
<DropdownMenu>
+114
View File
@@ -1845,3 +1845,117 @@ html.lightyellow .gq-history-item--active .bg-primary {
.lightyellow .bg-primary\/70 { background-color: rgba(243, 251, 217, 0.70); }
.lightyellow .bg-primary\/90 { background-color: rgba(243, 251, 217, 0.90); }
}
/* Runtime-created themes deliberately use semantic variables rather than a
generated class per colour. These compatibility rules keep the selected,
hovered and compact-navigation states readable on Chrome 109 as well. */
html[data-custom-theme="true"] .agent-avatar {
background-color: var(--accent);
color: var(--accent-foreground);
box-shadow: inset 0 0 0 1px rgb(var(--legacy-workspace-brand-rgb) / 0.2);
}
html[data-custom-theme="true"] .agent-sidebar-card--active,
html[data-custom-theme="true"] .gq-sidebar .agent-sidebar-card--active,
html[data-custom-theme="true"] .gq-history-item--active {
border-color: rgb(var(--legacy-workspace-brand-rgb) / 0.52) !important;
background-color: var(--accent) !important;
color: var(--accent-foreground) !important;
}
html[data-custom-theme="true"] .workspace-compact-new-chat,
html[data-custom-theme="true"] .workspace-nav-new-chat {
border-color: rgb(var(--legacy-workspace-brand-rgb) / 0.38) !important;
color: var(--accent-foreground) !important;
}
html[data-custom-theme="true"] .workspace-compact-new-chat :is(svg, span),
html[data-custom-theme="true"] .workspace-nav-new-chat :is(svg, span),
html[data-custom-theme="true"] .workspace-nav-item:hover :is(svg, span),
html[data-custom-theme="true"] .workspace-nav-item[data-active="true"] :is(svg, span),
html[data-custom-theme="true"] .gq-history-item--active :is(svg, span),
html[data-custom-theme="true"] .gq-sidebar .agent-sidebar-card--active .text-primary {
color: var(--accent-foreground) !important;
}
html[data-custom-theme="true"] .workspace-compact-new-chat:hover,
html[data-custom-theme="true"] .workspace-compact-new-chat[data-active="true"],
html[data-custom-theme="true"] .workspace-nav-new-chat:hover,
html[data-custom-theme="true"] .workspace-nav-new-chat[data-active="true"],
html[data-custom-theme="true"] .workspace-nav-item:hover,
html[data-custom-theme="true"] .workspace-nav-item[data-active="true"] {
border-color: rgb(var(--legacy-workspace-brand-rgb) / 0.6) !important;
background-color: var(--accent) !important;
color: var(--accent-foreground) !important;
box-shadow: inset 3px 0 0 var(--brand-900);
}
html[data-custom-theme="true"] .gq-new-chat-button {
border-color: var(--brand-900) !important;
background-color: var(--accent) !important;
color: var(--accent-foreground) !important;
}
html[data-custom-theme="true"] .gq-new-chat-button:hover {
background-color: var(--theme-button-hover) !important;
}
/* The shared primary Button uses Tailwind's hover:bg-primary/90 utility.
Let a custom theme's optional button-hover override reach those buttons
too, without changing the fixed built-in theme palettes. */
html[data-custom-theme="true"] [class~="hover:bg-primary/90"]:hover {
background-color: var(--theme-button-hover) !important;
}
/* Keep the user-selected primary button text local to actions. Brand marks,
badges and other non-button primary surfaces retain their automatic, legible
foreground. */
html[data-custom-theme="true"] :is(button, a)[class~="bg-primary"] {
color: var(--button-foreground, var(--primary-foreground)) !important;
}
/* Scoped brand-title styling: built-in themes continue using their existing
title classes, while custom themes can tune the icon-adjacent headings. */
html[data-custom-theme="true"] .theme-brand-title {
color: var(--brand-title-foreground) !important;
}
/* The destructive token intentionally has its own advanced setting. */
html[data-custom-theme="true"] [class~="hover:bg-destructive/90"]:hover {
background-color: rgb(var(--legacy-destructive-rgb) / 0.9) !important;
}
html[data-custom-theme="true"] .gq-sidebar .agent-sidebar-card--active .bg-primary,
html[data-custom-theme="true"] .gq-history-item--active .bg-primary {
background-color: var(--brand-900) !important;
}
@supports not (color: oklch(1 0 0)) {
html[data-custom-theme="true"] .bg-primary\/5 { background-color: rgb(var(--legacy-primary-rgb) / 0.05); }
html[data-custom-theme="true"] .bg-primary\/8 { background-color: rgb(var(--legacy-primary-rgb) / 0.08); }
html[data-custom-theme="true"] .bg-primary\/10 { background-color: rgb(var(--legacy-primary-rgb) / 0.10); }
html[data-custom-theme="true"] .bg-primary\/14 { background-color: rgb(var(--legacy-primary-rgb) / 0.14); }
html[data-custom-theme="true"] .bg-primary\/15 { background-color: rgb(var(--legacy-primary-rgb) / 0.15); }
html[data-custom-theme="true"] .bg-primary\/20 { background-color: rgb(var(--legacy-primary-rgb) / 0.20); }
html[data-custom-theme="true"] .bg-primary\/25 { background-color: rgb(var(--legacy-primary-rgb) / 0.25); }
html[data-custom-theme="true"] .bg-primary\/30 { background-color: rgb(var(--legacy-primary-rgb) / 0.30); }
html[data-custom-theme="true"] .bg-primary\/60 { background-color: rgb(var(--legacy-primary-rgb) / 0.60); }
html[data-custom-theme="true"] .bg-primary\/70 { background-color: rgb(var(--legacy-primary-rgb) / 0.70); }
html[data-custom-theme="true"] .bg-primary\/90 { background-color: rgb(var(--legacy-primary-rgb) / 0.90); }
html[data-custom-theme="true"] .bg-destructive\/5 { background-color: rgb(var(--legacy-destructive-rgb) / 0.05); }
html[data-custom-theme="true"] .border-destructive\/30 { border-color: rgb(var(--legacy-destructive-rgb) / 0.30); }
html[data-custom-theme="true"] .border-primary\/15 { border-color: rgb(var(--legacy-primary-rgb) / 0.15); }
html[data-custom-theme="true"] .border-primary\/20 { border-color: rgb(var(--legacy-primary-rgb) / 0.20); }
html[data-custom-theme="true"] .border-primary\/25 { border-color: rgb(var(--legacy-primary-rgb) / 0.25); }
html[data-custom-theme="true"] .border-primary\/30 { border-color: rgb(var(--legacy-primary-rgb) / 0.30); }
html[data-custom-theme="true"] .border-primary\/35 { border-color: rgb(var(--legacy-primary-rgb) / 0.35); }
html[data-custom-theme="true"] .border-primary\/40 { border-color: rgb(var(--legacy-primary-rgb) / 0.40); }
html[data-custom-theme="true"] .border-primary\/50 { border-color: rgb(var(--legacy-primary-rgb) / 0.50); }
html[data-custom-theme="true"] .border-primary\/60 { border-color: rgb(var(--legacy-primary-rgb) / 0.60); }
html[data-custom-theme="true"] .ring-primary\/15 { --tw-ring-color: rgb(var(--legacy-primary-rgb) / 0.15); }
html[data-custom-theme="true"] .ring-primary\/20 { --tw-ring-color: rgb(var(--legacy-primary-rgb) / 0.20); }
html[data-custom-theme="true"] .ring-primary\/30 { --tw-ring-color: rgb(var(--legacy-primary-rgb) / 0.30); }
html[data-custom-theme="true"] .ring-primary\/40 { --tw-ring-color: rgb(var(--legacy-primary-rgb) / 0.40); }
html[data-custom-theme="true"] .ring-primary\/50 { --tw-ring-color: rgb(var(--legacy-primary-rgb) / 0.50); }
html[data-custom-theme="true"] .ring-primary\/60 { --tw-ring-color: rgb(var(--legacy-primary-rgb) / 0.60); }
}
@@ -1003,6 +1003,22 @@ When using `make dev` from root, the frontend automatically connects through ngi
## Key Features
### Administrator-managed custom themes
`system_settings.json` → `appearance` now stores `custom_themes` alongside
the built-in default theme. A row is `CustomThemePreset` (`id`, `name`,
six-digit `primary`, optional primary-button `text_color`, `mode`, `overrides`, `enabled`,
`sort_order`). `overrides` is intentionally a short interaction-only list:
`button_hover`, `selected_background`, `border`, `muted`, `muted_text`, `destructive`, `brand_title`, and `primary_foreground`.
All values must be six-digit hex strings; the frontend derives every remaining
semantic CSS/TDesign token so admins do not maintain a full raw token sheet.
Ids must be `custom-theme-*`, names and ids are unique, and the router limits
the list to 12. `GET/PUT /api/system-settings/appearance` is the single
read/write surface: reads are available to all users, writes require admin.
The router re-orders rows deterministically and only permits an enabled custom
theme as `default_theme`; removing or disabling the default safely falls back
to `light`. Regression coverage: `tests/test_appearance_theme.py`.
### File Upload
Multi-file upload with automatic document conversion:
@@ -414,6 +414,19 @@ make format # Format code (ruff)
uv run pytest
```
### Runtime appearance themes
The admin appearance settings are persisted in `.deer-flow/system_settings.json`.
Besides the built-in themes, an administrator can create up to 12 runtime
themes through `PUT /api/system-settings/appearance`. Each theme stores a
safe six-digit `primary` colour, an optional primary-button `text_color`,
`light`/`dark` mode, name, enabled flag and sort order. The frontend derives
the rest of the visual tokens; the optional `overrides` object only exposes
eight interaction colours (`button_hover`, `selected_background`, `border`,
`muted`, `muted_text`, `destructive`, `brand_title`, `primary_foreground`) for exceptional brand requirements. A custom theme id
must use the `custom-theme-` prefix. Disabled or removed custom themes cannot
remain the system default and automatically fall back to `light`.
---
## Technology Stack
@@ -14,8 +14,10 @@ from app.gateway.auth.password import hash_password_async
from app.gateway.deps import get_optional_user_from_request
from deerflow.config import get_app_config
from deerflow.config.system_settings import (
BUILTIN_THEME_IDS,
AppearanceRouteParam,
CompactShortcutButton,
CustomThemePreset,
LeaderboardSettings,
ModelConcurrencySettings,
PluginDomainAgentMapping,
@@ -24,7 +26,6 @@ from deerflow.config.system_settings import (
PublishSquare,
PublishSquaresSettings,
SkillCompressionSettings,
SystemSettings,
get_module_tag_ids,
list_publish_squares,
load_system_settings,
@@ -173,9 +174,7 @@ async def get_plugin_management() -> PluginManagementResponse:
@router.put("/plugin-management", response_model=PluginManagementResponse)
async def update_plugin_management(
body: PluginManagementUpdateRequest, request: Request
) -> PluginManagementResponse:
async def update_plugin_management(body: PluginManagementUpdateRequest, request: Request) -> PluginManagementResponse:
"""Replace the iframe plugin domain -> agent mappings. Admin only."""
from uuid import uuid4
@@ -201,12 +200,8 @@ async def update_plugin_management(
)
)
updated = current.plugin_management.model_copy(
update={"domain_agent_mappings": mappings}
)
saved = save_system_settings(
current.model_copy(update={"plugin_management": updated})
)
updated = current.plugin_management.model_copy(update={"domain_agent_mappings": mappings})
saved = save_system_settings(current.model_copy(update={"plugin_management": updated}))
return _plugin_management_to_response(saved.plugin_management)
@@ -395,15 +390,9 @@ async def update_citation_display(
await _require_admin(request)
current = load_system_settings()
words = [
str(item).strip()
for item in (body.cleanup_words or [])
if str(item).strip()
]
words = [str(item).strip() for item in (body.cleanup_words or []) if str(item).strip()]
updated = current.citation_display.model_copy(update={"cleanup_words": words})
saved = save_system_settings(
current.model_copy(update={"citation_display": updated})
)
saved = save_system_settings(current.model_copy(update={"citation_display": updated}))
return CitationDisplayResponse(cleanup_words=saved.citation_display.cleanup_words)
@@ -536,21 +525,21 @@ async def update_publish_squares(body: PublishSquaresUpdateRequest, request: Req
class AppearanceResponse(BaseModel):
"""Effective system-wide appearance defaults."""
default_theme: Literal["light", "dark", "lightblue", "lightgreen", "iceblue", "lightyellow"] = Field(
..., description="System default theme — light, dark, lightblue, lightgreen, iceblue or lightyellow"
)
default_theme: str = Field(..., description="System default theme — built-in id or enabled custom-theme-* id")
default_skill_view: Literal["card", "list"] = Field(..., description="System default skill list display mode — card or list")
default_agent_view: Literal["card", "list"] = Field(..., description="System default agent list display mode — card or list")
default_layout_mode: Literal["full", "compact"] = Field(..., description="System default interface layout — full (sidebar + header) or compact (hidden)")
compact_shortcut_buttons: list[CompactShortcutButton] = Field(default_factory=list, description="Shortcut buttons rendered in top bars")
custom_themes: list[CustomThemePreset] = Field(default_factory=list, description="Administrator-managed runtime theme presets")
class AppearanceUpdateRequest(BaseModel):
default_theme: Literal["light", "dark", "lightblue", "lightgreen", "iceblue", "lightyellow"] | None = Field(default=None)
default_theme: str | None = Field(default=None)
default_skill_view: Literal["card", "list"] | None = Field(default=None)
default_agent_view: Literal["card", "list"] | None = Field(default=None)
default_layout_mode: Literal["full", "compact"] | None = Field(default=None)
compact_shortcut_buttons: list[CompactShortcutButton] | None = Field(default=None)
custom_themes: list[CustomThemePreset] | None = Field(default=None)
def _normalize_compact_shortcut_buttons(
@@ -576,8 +565,7 @@ def _normalize_compact_shortcut_buttons(
AppearanceRouteParam(
key=key,
paramType="theme",
themeValue=(param.themeValue or "").strip()
or "red=red;green=green;blue=blue;dark-blue=dark-blue",
themeValue=(param.themeValue or "").strip() or "red=red;green=green;blue=blue;dark-blue=dark-blue",
)
)
continue
@@ -588,9 +576,7 @@ def _normalize_compact_shortcut_buttons(
key=key,
paramType="login",
source=source,
customValue=(param.customValue or "").strip()
if source == "other"
else None,
customValue=(param.customValue or "").strip() if source == "other" else None,
)
)
@@ -617,9 +603,46 @@ def _appearance_response(appearance) -> AppearanceResponse:
appearance.compact_shortcut_buttons,
key=lambda item: item.sort_order,
),
custom_themes=sorted(appearance.custom_themes, key=lambda item: item.sort_order),
)
_MAX_CUSTOM_THEMES = 12
def _normalize_custom_themes(themes: list[CustomThemePreset] | None) -> list[CustomThemePreset]:
"""Reject unsafe/ambiguous theme rows and assign a deterministic order."""
source = themes or []
if len(source) > _MAX_CUSTOM_THEMES:
raise HTTPException(status_code=422, detail=f"At most {_MAX_CUSTOM_THEMES} custom themes are allowed")
normalized: list[CustomThemePreset] = []
seen_ids: set[str] = set()
seen_names: set[str] = set()
for index, item in enumerate(source):
if item.id in seen_ids:
raise HTTPException(status_code=422, detail="Custom theme ids must be unique")
name_key = item.name.casefold()
if name_key in seen_names:
raise HTTPException(status_code=422, detail="Custom theme names must be unique")
seen_ids.add(item.id)
seen_names.add(name_key)
normalized.append(item.model_copy(update={"sort_order": index}))
return normalized
def _resolve_default_theme(theme_id: str | None, custom_themes: list[CustomThemePreset]) -> str:
"""Keep defaults valid if an administrator disables or removes a preset."""
normalized = (theme_id or "").strip().lower()
if normalized in BUILTIN_THEME_IDS:
return normalized
if any(item.id == normalized and item.enabled for item in custom_themes):
return normalized
return "light"
@router.get("/appearance", response_model=AppearanceResponse)
async def get_appearance_settings() -> AppearanceResponse:
"""Return the global appearance defaults. Any logged-in user may read this."""
@@ -627,27 +650,26 @@ async def get_appearance_settings() -> AppearanceResponse:
@router.put("/appearance", response_model=AppearanceResponse)
async def update_appearance_settings(
body: AppearanceUpdateRequest, request: Request
) -> AppearanceResponse:
async def update_appearance_settings(body: AppearanceUpdateRequest, request: Request) -> AppearanceResponse:
"""Update the global appearance defaults. Admin only."""
await _require_admin(request)
current = load_system_settings()
fields = body.model_dump(exclude_unset=True)
update: dict[str, object] = {}
for key in ("default_theme", "default_skill_view", "default_agent_view", "default_layout_mode"):
for key in ("default_skill_view", "default_agent_view", "default_layout_mode"):
if key in fields and fields[key] is not None:
update[key] = fields[key]
if "compact_shortcut_buttons" in fields and body.compact_shortcut_buttons is not None:
update["compact_shortcut_buttons"] = _normalize_compact_shortcut_buttons(
body.compact_shortcut_buttons
)
update["compact_shortcut_buttons"] = _normalize_compact_shortcut_buttons(body.compact_shortcut_buttons)
custom_themes = _normalize_custom_themes(body.custom_themes) if "custom_themes" in fields and body.custom_themes is not None else list(current.appearance.custom_themes)
if "custom_themes" in fields and body.custom_themes is not None:
update["custom_themes"] = custom_themes
requested_default = body.default_theme if "default_theme" in fields else current.appearance.default_theme
update["default_theme"] = _resolve_default_theme(requested_default, custom_themes)
if update:
updated_appearance = current.appearance.model_copy(update=update)
saved = save_system_settings(
current.model_copy(update={"appearance": updated_appearance})
)
saved = save_system_settings(current.model_copy(update={"appearance": updated_appearance}))
else:
saved = current
return _appearance_response(saved.appearance)
@@ -723,9 +745,7 @@ async def get_agent_module_config(key: str) -> AgentModuleConfigResponse:
@router.put("/agent-modules/{key}", response_model=AgentModuleConfigResponse)
async def update_agent_module_config(
key: str, body: AgentModuleConfigUpdateRequest, request: Request
) -> AgentModuleConfigResponse:
async def update_agent_module_config(key: str, body: AgentModuleConfigUpdateRequest, request: Request) -> AgentModuleConfigResponse:
"""Replace the agent tag ids configured for ``key``. Admin only."""
await _require_admin(request)
@@ -15,12 +15,13 @@ from __future__ import annotations
import json
import logging
import re
import threading
from pathlib import Path
from typing import Literal
from uuid import uuid4
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from deerflow.config.runtime_paths import runtime_home
@@ -29,6 +30,111 @@ logger = logging.getLogger(__name__)
_SETTINGS_FILE = "system_settings.json"
_lock = threading.Lock()
# Built-ins remain source-controlled, while administrator-created themes use a
# stable prefixed id and are persisted in the runtime settings JSON.
BUILTIN_THEME_IDS = frozenset({"light", "dark", "lightblue", "lightgreen", "iceblue", "lightyellow"})
CUSTOM_THEME_ID_PREFIX = "custom-theme-"
_CUSTOM_THEME_ID_RE = re.compile(r"^custom-theme-[a-z0-9-]{8,64}$")
_HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
def _normalize_optional_hex_color(value: str | None, field_name: str) -> str | None:
"""Accept an optional, safe six-digit colour without allowing raw CSS."""
if value is None or not str(value).strip():
return None
normalized = str(value).strip().upper()
if not _HEX_COLOR_RE.fullmatch(normalized):
raise ValueError(f"{field_name} must be a six-digit hexadecimal value such as #0062D9")
return normalized
class CustomThemeColorOverrides(BaseModel):
"""Optional advanced adjustments to an otherwise generated colour scale.
The short list intentionally covers interaction surfaces only. It avoids
turning the theme editor into a fragile form containing every CSS token.
"""
button_hover: str | None = Field(default=None, description="Primary button hover colour")
selected_background: str | None = Field(default=None, description="Selected/active item background")
border: str | None = Field(default=None, description="Card and input border colour")
muted: str | None = Field(default=None, description="Muted/secondary surface colour")
muted_text: str | None = Field(default=None, description="Secondary text colour")
destructive: str | None = Field(default=None, description="Delete and danger action colour")
brand_title: str | None = Field(default=None, description="Branded section-title text colour")
primary_foreground: str | None = Field(
default=None,
description="Text colour on primary surfaces such as the logo and status badges",
)
model_config = ConfigDict(extra="ignore")
@field_validator(
"button_hover",
"selected_background",
"border",
"muted",
"muted_text",
"destructive",
"brand_title",
"primary_foreground",
)
@classmethod
def _validate_color(cls, value: str | None, info) -> str | None:
return _normalize_optional_hex_color(value, f"Theme override {info.field_name}")
class CustomThemePreset(BaseModel):
"""One administrator-managed theme generated from a single brand color.
The frontend derives all surface, text, focus and hover tokens from these
safe inputs at runtime. Storing only the brand color and base mode keeps
arbitrary CSS out of runtime settings and lets contrast rules remain
centrally enforced.
"""
id: str = Field(default_factory=lambda: f"{CUSTOM_THEME_ID_PREFIX}{uuid4().hex}")
name: str = Field(default="自定义主题", min_length=1, max_length=32)
primary: str = Field(default="#0062D9", description="Six-digit CSS hexadecimal brand color")
text_color: str | None = Field(
default=None,
description="Optional primary-button text color; omitted = automatic button foreground",
)
mode: Literal["light", "dark"] = Field(default="light", description="Surface family used to derive the palette")
overrides: CustomThemeColorOverrides = Field(default_factory=CustomThemeColorOverrides)
enabled: bool = Field(default=True)
sort_order: int = Field(default=0, ge=0, le=999)
model_config = ConfigDict(extra="ignore")
@field_validator("id")
@classmethod
def _validate_id(cls, value: str) -> str:
normalized = str(value or "").strip().lower()
if not _CUSTOM_THEME_ID_RE.fullmatch(normalized):
raise ValueError("Custom theme id must use the custom-theme- prefix")
return normalized
@field_validator("name")
@classmethod
def _normalize_name(cls, value: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError("Theme name cannot be empty")
return normalized
@field_validator("primary")
@classmethod
def _validate_primary(cls, value: str) -> str:
normalized = str(value or "").strip().upper()
if not _HEX_COLOR_RE.fullmatch(normalized):
raise ValueError("Theme primary color must be a six-digit hexadecimal value such as #0062D9")
return normalized
@field_validator("text_color")
@classmethod
def _validate_text_color(cls, value: str | None) -> str | None:
return _normalize_optional_hex_color(value, "Theme text color")
class PromptPrefixSettings(BaseModel):
"""Admin-configurable first-message prompt prefix."""
@@ -102,9 +208,9 @@ class AppearanceSettings(BaseModel):
toggle, and any future first-time loads.
"""
default_theme: Literal["light", "dark", "lightblue", "lightgreen", "iceblue", "lightyellow"] = Field(
default_theme: str = Field(
default="light",
description="Theme applied for users who haven't picked their own (light / dark / lightblue / lightgreen / iceblue / lightyellow)",
description="Theme applied for users who haven't picked their own (a built-in id or custom-theme-* id)",
)
default_skill_view: Literal["card", "list"] = Field(
default="list",
@@ -122,8 +228,20 @@ class AppearanceSettings(BaseModel):
default_factory=_default_compact_shortcut_buttons,
description="Shortcut buttons rendered before the theme switcher in top bars",
)
custom_themes: list[CustomThemePreset] = Field(
default_factory=list,
description="Administrator-managed runtime themes available to all users",
)
model_config = ConfigDict(extra="ignore")
@field_validator("default_theme")
@classmethod
def _validate_default_theme(cls, value: str) -> str:
normalized = str(value or "").strip().lower()
if normalized in BUILTIN_THEME_IDS or _CUSTOM_THEME_ID_RE.fullmatch(normalized):
return normalized
raise ValueError("Unknown theme id")
class CitationDisplaySettings(BaseModel):
"""System-wide cleanup rules for citation cards and detail dialogs."""
@@ -290,9 +408,7 @@ def get_module_tag_ids(settings: AgentModuleSettings, key: str) -> list[str]:
return list(settings.modules.get(key, []))
def set_module_tag_ids(
settings: AgentModuleSettings, key: str, tag_ids: list[str]
) -> AgentModuleSettings:
def set_module_tag_ids(settings: AgentModuleSettings, key: str, tag_ids: list[str]) -> AgentModuleSettings:
"""Return a copy with ``key`` set to a de-duplicated, stripped tag-id list."""
cleaned = list(dict.fromkeys([str(t).strip() for t in tag_ids if str(t).strip()]))
modules = dict(settings.modules)