功能:完善自定义主题与独立登录会话
This commit is contained in:
1 parent
3779bb69ef
commit
f1e53ef5c0
23 files changed
+1742
-196
No files matched your search
@@ -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"}
|
||||
|
||||
@@ -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(' ');
|
||||
|
||||
@@ -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: "大屏绘制",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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]",
|
||||
|
||||
@@ -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') ?? '',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+122
-6
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user