feat: 更新主题配置与系统设置
This commit is contained in:
1 parent
1555d5344b
commit
151ecceb85
12 files changed
+145
-27
No files matched your search
@@ -99,7 +99,7 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
VITE_AIPI_KNOWES_URL: "http://ch1.b.uat.4.cn/knowes",
|
||||
|
||||
// MaxKey 鉴权地址:onlineTicket 换 token 使用。
|
||||
VITE_AUTH_BASE_URL: "https://auth.y.sit.4.cn",
|
||||
VITE_AUTH_BASE_URL: "https://auth.y.uat.4.cn",
|
||||
|
||||
// PG 中任务 iframe 地址。
|
||||
VITE_AIPI_PGLINKTO_URL: "https://pg.b.uat.4.cn",
|
||||
|
||||
@@ -607,6 +607,27 @@ html.lightyellow .light-topnav-item--active::after {
|
||||
}
|
||||
|
||||
/* ── 侧边栏:二级图标卡片列(96px)+ 右侧三级列(展开时整体变宽,带过渡)──────── */
|
||||
/* Runtime themes use their configurable header gradient rather than the
|
||||
red/blue banner artwork. Navigation copy follows an automatically chosen
|
||||
contrasting foreground so both pale and deep brand colours remain legible. */
|
||||
html[data-custom-theme="true"] .light-topnav-item,
|
||||
html[data-custom-theme="true"] .top-submenu-item {
|
||||
color: var(--navigation-foreground);
|
||||
}
|
||||
|
||||
html[data-custom-theme="true"] .light-topnav-item:hover,
|
||||
html[data-custom-theme="true"] .light-topnav-item--active,
|
||||
html[data-custom-theme="true"] .light-topnav-item--active:hover,
|
||||
html[data-custom-theme="true"] .top-submenu-item:hover,
|
||||
html[data-custom-theme="true"] .top-submenu-item--active {
|
||||
color: var(--navigation-foreground);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
html[data-custom-theme="true"] .light-topnav-item--active::after {
|
||||
background: var(--navigation-foreground);
|
||||
}
|
||||
|
||||
.light-sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
|
||||
@@ -26,7 +26,7 @@ import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
|
||||
* - "banner-dark" / "banner-light":Header 落在主题 banner 上(白字 / 浅色 hover)。
|
||||
* - "content":简洁模式聊天页浅底顶栏,走 foreground / muted-foreground。
|
||||
*/
|
||||
export type AppearanceControlTone = "banner-dark" | "banner-light" | "content";
|
||||
export type AppearanceControlTone = "banner-dark" | "banner-light" | "banner-custom" | "content";
|
||||
|
||||
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),冰川蓝=柔和浅蓝主题(iceblue),浅黄色=柔和黄主题(lightyellow),深蓝色=深色主题(dark)
|
||||
export const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [
|
||||
@@ -43,6 +43,8 @@ const triggerToneClass = (tone: AppearanceControlTone) =>
|
||||
? "text-slate-400 hover:bg-slate-800/50 hover:text-slate-400"
|
||||
: tone === "banner-light"
|
||||
? "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]"
|
||||
: tone === "banner-custom"
|
||||
? "text-[var(--navigation-foreground)] hover:bg-white/20 hover:text-[var(--navigation-foreground)] active:bg-white/30"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground";
|
||||
|
||||
export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }) {
|
||||
|
||||
@@ -70,6 +70,18 @@ const ADVANCED_COLOR_FIELDS: Array<{
|
||||
description: "主按钮悬停时的背景色",
|
||||
token: "--theme-button-hover",
|
||||
},
|
||||
{
|
||||
key: "navigation_gradient_start",
|
||||
label: "导航渐变起点",
|
||||
description: "全量模式顶部导航栏左侧的渐变色",
|
||||
token: "--navigation-gradient-start",
|
||||
},
|
||||
{
|
||||
key: "navigation_gradient_end",
|
||||
label: "导航渐变终点",
|
||||
description: "全量模式顶部导航栏右侧的渐变色",
|
||||
token: "--navigation-gradient-end",
|
||||
},
|
||||
{
|
||||
key: "selected_background",
|
||||
label: "选中背景",
|
||||
@@ -268,14 +280,20 @@ function CustomThemePreview({ theme }: { theme: CustomThemePreset }) {
|
||||
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">
|
||||
<div
|
||||
className="flex items-center gap-2 border-b border-[var(--border)] px-3 py-2"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(105deg, var(--navigation-gradient-start) 0%, var(--primary) 52%, var(--navigation-gradient-end) 100%)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex size-5 items-center justify-center rounded-md bg-[var(--primary)] text-[10px] font-bold text-[var(--primary-foreground)] ring-2 ring-[var(--ring)]"
|
||||
className="flex size-5 items-center justify-center rounded-md bg-white/25 text-[10px] font-bold text-[var(--navigation-foreground)] ring-2 ring-white/35"
|
||||
aria-hidden
|
||||
>
|
||||
C
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-[var(--foreground)]">
|
||||
<span className="text-xs font-semibold text-[var(--navigation-foreground)]">
|
||||
实时预览
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -229,6 +229,8 @@ function markExplicitChoice(): void {
|
||||
// 主题上下文
|
||||
interface ThemeContextType {
|
||||
theme: ThemeType;
|
||||
/** Resolved visual mode; custom themes keep their own light/dark setting. */
|
||||
baseMode: 'light' | 'dark';
|
||||
themeConfig: ThemeConfig;
|
||||
toggleTheme: () => void;
|
||||
setTheme: (theme: ThemeType) => void;
|
||||
@@ -309,6 +311,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
const theme: ThemeType = isBuiltinTheme(requestedTheme)
|
||||
? requestedTheme
|
||||
: customTheme?.id ?? 'light';
|
||||
const baseMode: 'light' | 'dark' = customTheme?.mode ?? (theme === 'dark' ? 'dark' : 'light');
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
@@ -377,7 +380,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{ theme, themeConfig, toggleTheme, setTheme, mounted }}
|
||||
value={{ theme, baseMode, themeConfig, toggleTheme, setTheme, mounted }}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
|
||||
@@ -291,6 +291,8 @@ export type CustomThemeBaseMode = "light" | "dark";
|
||||
|
||||
export interface CustomThemeColorOverrides {
|
||||
button_hover?: string | null;
|
||||
navigation_gradient_start?: string | null;
|
||||
navigation_gradient_end?: string | null;
|
||||
selected_background?: string | null;
|
||||
border?: string | null;
|
||||
muted?: string | null;
|
||||
|
||||
@@ -13,6 +13,8 @@ const BLACK: Rgb = { r: 0, g: 0, b: 0 };
|
||||
|
||||
export const CUSTOM_THEME_ADVANCED_KEYS = [
|
||||
"button_hover",
|
||||
"navigation_gradient_start",
|
||||
"navigation_gradient_end",
|
||||
"selected_background",
|
||||
"border",
|
||||
"muted",
|
||||
@@ -146,6 +148,18 @@ export function getCustomThemeTokens(theme: CustomThemeTokenInput): Record<strin
|
||||
const primaryHover = optionalHexColor(overrides.button_hover)
|
||||
? toRgb(overrides.button_hover!)
|
||||
: defaultPrimaryHover;
|
||||
// Keep the same left → main-colour → right treatment used by the built-in
|
||||
// themes. These two values remain automatic unless the administrator opens
|
||||
// “更多配色” and explicitly overrides one of them.
|
||||
const defaultNavigationGradientStart = mix(primary, BLACK, isDark ? 0.46 : 0.3);
|
||||
const defaultNavigationGradientEnd = mix(primary, WHITE, isDark ? 0.14 : 0.42);
|
||||
const navigationGradientStart = optionalHexColor(overrides.navigation_gradient_start)
|
||||
? toRgb(overrides.navigation_gradient_start!)
|
||||
: defaultNavigationGradientStart;
|
||||
const navigationGradientEnd = optionalHexColor(overrides.navigation_gradient_end)
|
||||
? toRgb(overrides.navigation_gradient_end!)
|
||||
: defaultNavigationGradientEnd;
|
||||
const navigationForeground = readableForeground(mix(navigationGradientStart, navigationGradientEnd, 0.5));
|
||||
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;
|
||||
@@ -168,6 +182,8 @@ export function getCustomThemeTokens(theme: CustomThemeTokenInput): Record<strin
|
||||
const secondaryHex = toHex(secondary);
|
||||
const primaryHoverHex = toHex(primaryHover);
|
||||
const primaryActiveHex = toHex(primaryActive);
|
||||
const navigationGradientStartHex = toHex(navigationGradientStart);
|
||||
const navigationGradientEndHex = toHex(navigationGradientEnd);
|
||||
const sidebarHex = toHex(sidebar);
|
||||
|
||||
return {
|
||||
@@ -177,6 +193,9 @@ export function getCustomThemeTokens(theme: CustomThemeTokenInput): Record<strin
|
||||
"--brand-900": actionHex,
|
||||
"--brand-950": actionActiveHex,
|
||||
"--theme-button-hover": primaryHoverHex,
|
||||
"--navigation-gradient-start": navigationGradientStartHex,
|
||||
"--navigation-gradient-end": navigationGradientEndHex,
|
||||
"--navigation-foreground": navigationForeground,
|
||||
"--theme-selected-background": accentHex,
|
||||
"--background": backgroundHex,
|
||||
"--foreground": foreground,
|
||||
|
||||
@@ -29,10 +29,12 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
import { useTheme } from "../contexts/ThemeContext";
|
||||
|
||||
const iconButtonClass = (isDark: boolean, isPaleLightTheme: boolean) =>
|
||||
const iconButtonClass = (isDark: boolean, isPaleLightTheme: boolean, isCustomTheme: boolean) =>
|
||||
cn(
|
||||
"size-8 shrink-0 rounded-sm",
|
||||
isDark
|
||||
isCustomTheme
|
||||
? "text-[var(--navigation-foreground)] hover:bg-white/20 hover:text-[var(--navigation-foreground)] active:bg-white/30"
|
||||
: isDark
|
||||
? "text-slate-400 hover:bg-slate-800/50 hover:text-white"
|
||||
: isPaleLightTheme
|
||||
? "text-[var(--foreground)] hover:bg-white/55 hover:text-[var(--foreground)] active:bg-white/75"
|
||||
@@ -48,9 +50,22 @@ type HeaderProps = {
|
||||
};
|
||||
|
||||
const Header = ({ collapsed }: HeaderProps) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const { theme, baseMode } = useTheme();
|
||||
const isDark = baseMode === "dark";
|
||||
const isCustomTheme = theme.startsWith("custom-theme-");
|
||||
const isPaleLightTheme = theme === "iceblue" || theme === "lightyellow";
|
||||
const headerTone = isCustomTheme
|
||||
? "banner-custom"
|
||||
: isDark
|
||||
? "banner-dark"
|
||||
: isPaleLightTheme
|
||||
? "content"
|
||||
: "banner-light";
|
||||
const headerTextClass = isCustomTheme
|
||||
? "text-[var(--navigation-foreground)]"
|
||||
: isPaleLightTheme
|
||||
? "text-[var(--foreground)]"
|
||||
: "text-white";
|
||||
const darkLogoPosition =
|
||||
readRuntimeEnv("VITE_DARK_LOGO_POSITION") === "left" ? "left" : "center";
|
||||
const isDarkLogoOnLeft = darkLogoPosition === "left";
|
||||
@@ -152,11 +167,11 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
// 右侧操作区(换肤/通知/用户)—— 深浅两套布局共用
|
||||
const actionCluster = (
|
||||
<>
|
||||
<CompactShortcutButtons tone={isDark ? "banner-dark" : isPaleLightTheme ? "content" : "banner-light"} />
|
||||
<ThemeSwitcher tone={isDark ? "banner-dark" : isPaleLightTheme ? "content" : "banner-light"} />
|
||||
<CompactShortcutButtons tone={headerTone} />
|
||||
<ThemeSwitcher tone={headerTone} />
|
||||
|
||||
<LayoutModeSwitcher
|
||||
tone={isDark ? "banner-dark" : isPaleLightTheme ? "content" : "banner-light"}
|
||||
tone={headerTone}
|
||||
fallback="full"
|
||||
/>
|
||||
|
||||
@@ -165,7 +180,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setNotificationOpen(true)}
|
||||
className={cn(iconButtonClass(isDark, isPaleLightTheme), "relative")}
|
||||
className={cn(iconButtonClass(isDark, isPaleLightTheme, isCustomTheme), "relative")}
|
||||
title="通知"
|
||||
>
|
||||
<Bell className="size-4" />
|
||||
@@ -186,7 +201,13 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
<div
|
||||
className={cn(
|
||||
"mx-1 h-8 w-px",
|
||||
isDark ? "bg-blue-800/30" : isPaleLightTheme ? "bg-[var(--brand-900)]/25" : "bg-white/30",
|
||||
isCustomTheme
|
||||
? "bg-[var(--navigation-foreground)]/25"
|
||||
: isDark
|
||||
? "bg-blue-800/30"
|
||||
: isPaleLightTheme
|
||||
? "bg-[var(--brand-900)]/25"
|
||||
: "bg-white/30",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
@@ -196,7 +217,9 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 gap-2 rounded-sm px-2",
|
||||
isDark
|
||||
isCustomTheme
|
||||
? "text-[var(--navigation-foreground)] hover:bg-white/20 active:bg-white/30"
|
||||
: isDark
|
||||
? "text-slate-200 hover:bg-slate-800/50"
|
||||
: isPaleLightTheme
|
||||
? "text-[var(--foreground)] hover:bg-white/55 active:bg-white/75"
|
||||
@@ -206,7 +229,9 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-sm",
|
||||
isDark
|
||||
isCustomTheme
|
||||
? "bg-white/25"
|
||||
: isDark
|
||||
? "bg-gradient-to-br from-blue-500 to-cyan-400"
|
||||
: "bg-white",
|
||||
)}
|
||||
@@ -214,16 +239,16 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
<User
|
||||
className={cn(
|
||||
"size-4",
|
||||
isDark ? "text-white" : "text-[var(--brand-900)]",
|
||||
isDark && !isCustomTheme ? "text-white" : "text-[var(--brand-900)]",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden text-left sm:block">
|
||||
<p className={cn("text-sm leading-tight font-medium", isPaleLightTheme ? "text-[var(--foreground)]" : "text-white")}>
|
||||
<p className={cn("text-sm leading-tight font-medium", headerTextClass)}>
|
||||
{displayName}
|
||||
</p>
|
||||
{positionName && (
|
||||
<p className={cn("text-[11px] leading-tight", isPaleLightTheme ? "text-[var(--muted-foreground)]" : "text-white/70")}>
|
||||
<p className={cn("text-[11px] leading-tight", isCustomTheme ? "text-[var(--navigation-foreground)]/75" : isPaleLightTheme ? "text-[var(--muted-foreground)]" : "text-white/70")}>
|
||||
{positionName}
|
||||
</p>
|
||||
)}
|
||||
@@ -231,7 +256,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
isDark ? "text-slate-500" : isPaleLightTheme ? "text-[var(--muted-foreground)]" : "text-white/80",
|
||||
isCustomTheme ? "text-[var(--navigation-foreground)]/80" : isDark ? "text-slate-500" : isPaleLightTheme ? "text-[var(--muted-foreground)]" : "text-white/80",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
@@ -323,7 +348,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
onError={handleBrandLogoError}
|
||||
/>
|
||||
</div>
|
||||
<span className={cn("whitespace-nowrap text-lg font-semibold", isPaleLightTheme ? "text-[var(--foreground)]" : "text-white")}>
|
||||
<span className={cn("whitespace-nowrap text-lg font-semibold", headerTextClass)}>
|
||||
{desensitizeText("CM业务中心")}
|
||||
</span>
|
||||
</div>
|
||||
@@ -342,7 +367,9 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg",
|
||||
isDark
|
||||
isCustomTheme
|
||||
? "bg-white/25"
|
||||
: isDark
|
||||
? "bg-gradient-to-br from-blue-500 to-cyan-400"
|
||||
: isPaleLightTheme
|
||||
? "bg-white/55"
|
||||
@@ -358,7 +385,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
/>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className={cn("text-sm font-semibold", isPaleLightTheme ? "text-[var(--foreground)]" : "text-white")}>
|
||||
<span className={cn("text-sm font-semibold", headerTextClass)}>
|
||||
{desensitizeText("CM业务中心")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1849,6 +1849,16 @@ html.lightyellow .gq-history-item--active .bg-primary {
|
||||
/* 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"] .strategy-app-header {
|
||||
background-color: var(--primary);
|
||||
background-image: linear-gradient(
|
||||
105deg,
|
||||
var(--navigation-gradient-start) 0%,
|
||||
var(--primary) 52%,
|
||||
var(--navigation-gradient-end) 100%
|
||||
);
|
||||
}
|
||||
|
||||
html[data-custom-theme="true"] .agent-avatar {
|
||||
background-color: var(--accent);
|
||||
color: var(--accent-foreground);
|
||||
|
||||
@@ -1009,7 +1009,10 @@ When using `make dev` from root, the frontend automatically connects through ngi
|
||||
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`.
|
||||
`button_hover`, `navigation_gradient_start`, `navigation_gradient_end`,
|
||||
`selected_background`, `border`, `muted`, `muted_text`, `destructive`,
|
||||
`brand_title`, and `primary_foreground`. The navigation gradient endpoints
|
||||
default from `primary` and are only stored when an administrator overrides them.
|
||||
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
|
||||
|
||||
@@ -422,8 +422,11 @@ 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
|
||||
ten interaction colours (`button_hover`, `navigation_gradient_start`,
|
||||
`navigation_gradient_end`, `selected_background`, `border`, `muted`,
|
||||
`muted_text`, `destructive`, `brand_title`, `primary_foreground`) for
|
||||
exceptional brand requirements. The two navigation values override the
|
||||
automatically derived top-bar gradient. 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`.
|
||||
|
||||
|
||||
@@ -57,6 +57,14 @@ class CustomThemeColorOverrides(BaseModel):
|
||||
"""
|
||||
|
||||
button_hover: str | None = Field(default=None, description="Primary button hover colour")
|
||||
navigation_gradient_start: str | None = Field(
|
||||
default=None,
|
||||
description="Left colour for the configurable top-navigation gradient",
|
||||
)
|
||||
navigation_gradient_end: str | None = Field(
|
||||
default=None,
|
||||
description="Right colour for the configurable top-navigation gradient",
|
||||
)
|
||||
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")
|
||||
@@ -71,6 +79,8 @@ class CustomThemeColorOverrides(BaseModel):
|
||||
|
||||
@field_validator(
|
||||
"button_hover",
|
||||
"navigation_gradient_start",
|
||||
"navigation_gradient_end",
|
||||
"selected_background",
|
||||
"border",
|
||||
"muted",
|
||||
|
||||
Reference in new issue
Block a user