默认主题改为浅色 + 管理员可配置全局默认主题

- system_settings 新增 AppearanceSettings(default_theme),默认为 light,
  对应 GET /api/system-settings/appearance(任何人可读)+ PUT(admin 可写)
- App.tsx 把 NextThemesProvider 的 defaultTheme 从 dark 改为 light,并把
  QueryClientProvider 提到 ThemeProvider 外层以便后者使用 useQuery
- ThemeContext 新增 localStorage 标志 strategy-theme-user-set:用户调用
  toggleTheme / setTheme 时打标;每次启动若标志未设,则跟随 admin 配置的
  default_theme 同步主题,admin 改动用户刷新即生效;标志一旦存在永远尊重
  用户个人选择
- legacy 'theme' 键迁移视为用户显式选择,避免老用户被新默认覆盖
- frontend system-settings api/hook 增 appearance 拉取与更新

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.7 committed 2026-05-26 19:29:45 +08:00
1 parent 8b2f398015
commit c9c59b761f
6 files changed
+193 -21

No files matched your search

+10 -8
View File
@@ -26,18 +26,20 @@ function AppProviders({ children }: { children: React.ReactNode }) {
<NextThemesProvider <NextThemesProvider
attribute="class" attribute="class"
storageKey="strategy-theme" storageKey="strategy-theme"
defaultTheme="dark" defaultTheme="light"
enableSystem={false} enableSystem={false}
disableTransitionOnChange disableTransitionOnChange
forcedTheme={forcedTheme} forcedTheme={forcedTheme}
> >
<I18nProvider initialLocale={locale}> {/* QueryClientProvider must wrap ThemeProvider because the custom
<ThemeProvider> ThemeProvider reads the admin-configured default via useQuery. */}
<ChatProvider> <QueryClientProvider>
<QueryClientProvider>{children}</QueryClientProvider> <I18nProvider initialLocale={locale}>
</ChatProvider> <ThemeProvider>
</ThemeProvider> <ChatProvider>{children}</ChatProvider>
</I18nProvider> </ThemeProvider>
</I18nProvider>
</QueryClientProvider>
</NextThemesProvider> </NextThemesProvider>
); );
} }
+57 -13
View File
@@ -7,6 +7,8 @@ import React, {
} from 'react'; } from 'react';
import { useTheme as useNextTheme } from 'next-themes'; import { useTheme as useNextTheme } from 'next-themes';
import { useAppearanceSettings } from '@/core/system-settings/hooks';
// 主题类型 // 主题类型
export type ThemeType = 'dark' | 'light'; export type ThemeType = 'dark' | 'light';
@@ -83,13 +85,29 @@ const themes: Record<ThemeType, ThemeConfig> = {
}; };
const STORAGE_KEY = 'strategy-theme'; const STORAGE_KEY = 'strategy-theme';
// Marker that the user has explicitly toggled the theme (via the appearance
// settings page or any future toggle UI). When this flag is absent we treat
// any value stored in ``strategy-theme`` as one we put there ourselves while
// syncing the admin's default — so we'll re-sync on every boot, picking up
// admin changes. When the flag is present we never override the user.
const EXPLICIT_FLAG_KEY = 'strategy-theme-user-set';
function readPersistedTheme(): ThemeType { function readPersistedTheme(): ThemeType {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return 'dark'; return 'light';
} }
const saved = localStorage.getItem(STORAGE_KEY); const saved = localStorage.getItem(STORAGE_KEY);
return saved === 'light' ? 'light' : 'dark'; return saved === 'dark' ? 'dark' : 'light';
}
function hasExplicitChoice(): boolean {
if (typeof window === 'undefined') return false;
return localStorage.getItem(EXPLICIT_FLAG_KEY) === '1';
}
function markExplicitChoice(): void {
if (typeof window === 'undefined') return;
localStorage.setItem(EXPLICIT_FLAG_KEY, '1');
} }
// 主题上下文 // 主题上下文
@@ -112,42 +130,68 @@ interface ThemeProviderProps {
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => { export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const { resolvedTheme, setTheme: setNextTheme } = useNextTheme(); const { resolvedTheme, setTheme: setNextTheme } = useNextTheme();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
// Admin-configured global default. If the user hasn't explicitly chosen
// a theme yet, this value drives the rendered theme — and re-syncs on
// every mount so an admin change propagates without users clearing
// localStorage by hand.
const { data: appearance } = useAppearanceSettings();
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
}, []); }, []);
// 迁移 / 规范化 localStorage,避免与 next-themes 冲突导致闪回浅色 // 迁移 / 规范化 localStorage:去掉旧的 'theme' 键,并清掉一切非法值。
// 注意:不要在这里强制写入一个 fallback —— 那会让 next-themes 视作
// 「用户已选过」,admin 默认就再也注入不进来了。
useEffect(() => { useEffect(() => {
if (!mounted) return; if (!mounted) return;
const current = localStorage.getItem(STORAGE_KEY); const current = localStorage.getItem(STORAGE_KEY);
const legacy = localStorage.getItem('theme'); const legacy = localStorage.getItem('theme');
if (current !== 'light' && current !== 'dark') { if (current && current !== 'light' && current !== 'dark') {
const fallback = // Garbage value — wipe it so the boot-sync below can take over.
legacy === 'light' || legacy === 'dark' ? legacy : 'dark'; localStorage.removeItem(STORAGE_KEY);
setNextTheme(fallback); }
} else if (!current && legacy && (legacy === 'light' || legacy === 'dark')) { if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
setNextTheme(legacy); // Migrate the legacy key as the user's explicit choice (it was set
// long enough ago that we should preserve their pick rather than
// override with admin default).
setNextTheme(legacy);
markExplicitChoice();
} }
if (legacy) { if (legacy) {
localStorage.removeItem('theme'); localStorage.removeItem('theme');
} }
}, [mounted, setNextTheme]); }, [mounted, setNextTheme]);
// Boot-time sync: when the user has never picked a theme themselves,
// apply whatever the admin configured. Re-runs whenever the admin
// setting changes (so a logged-in user sees the new default after
// refreshing without having to clear cookies).
useEffect(() => {
if (!mounted) return;
if (hasExplicitChoice()) return;
const adminDefault = appearance?.default_theme;
if (!adminDefault) return;
if (resolvedTheme !== adminDefault) {
setNextTheme(adminDefault);
}
}, [mounted, appearance?.default_theme, resolvedTheme, setNextTheme]);
const theme: ThemeType = mounted const theme: ThemeType = mounted
? resolvedTheme === 'light' ? resolvedTheme === 'dark'
? 'light' ? 'dark'
: 'dark' : 'light'
: readPersistedTheme(); : readPersistedTheme();
const toggleTheme = () => { const toggleTheme = () => {
setNextTheme(theme === 'dark' ? 'light' : 'dark'); setNextTheme(theme === 'dark' ? 'light' : 'dark');
markExplicitChoice();
}; };
const setTheme = (newTheme: ThemeType) => { const setTheme = (newTheme: ThemeType) => {
setNextTheme(newTheme); setNextTheme(newTheme);
markExplicitChoice();
}; };
const themeConfig = themes[theme]; const themeConfig = themes[theme];
@@ -65,6 +65,48 @@ export async function loadPrivateSquareStatus(): Promise<PrivateSquareStatus> {
return (await response.json()) as PrivateSquareStatus; return (await response.json()) as PrivateSquareStatus;
} }
export type ThemeMode = "light" | "dark";
export interface AppearanceSettings {
default_theme: ThemeMode;
}
export interface AppearanceUpdate {
default_theme?: ThemeMode;
}
export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/appearance`,
);
if (!response.ok) {
throw new Error(
`Failed to load appearance settings: ${response.status}`,
);
}
return (await response.json()) as AppearanceSettings;
}
export async function updateAppearanceSettings(
body: AppearanceUpdate,
): Promise<AppearanceSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/appearance`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update appearance" }));
throw new Error(detail.detail ?? "Failed to update appearance");
}
return (await response.json()) as AppearanceSettings;
}
export async function updatePrivateSquareSettings( export async function updatePrivateSquareSettings(
body: PrivateSquareUpdate, body: PrivateSquareUpdate,
): Promise<PrivateSquareStatus> { ): Promise<PrivateSquareStatus> {
@@ -1,16 +1,20 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
loadAppearanceSettings,
loadPrivateSquareStatus, loadPrivateSquareStatus,
loadPromptPrefixSettings, loadPromptPrefixSettings,
updateAppearanceSettings,
updatePrivateSquareSettings, updatePrivateSquareSettings,
updatePromptPrefixSettings, updatePromptPrefixSettings,
type AppearanceUpdate,
type PrivateSquareUpdate, type PrivateSquareUpdate,
type PromptPrefixUpdate, type PromptPrefixUpdate,
} from "./api"; } from "./api";
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const; const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const; const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const;
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
export function usePromptPrefixSettings() { export function usePromptPrefixSettings() {
return useQuery({ return useQuery({
@@ -51,3 +55,25 @@ export function useUpdatePrivateSquareSettings() {
}, },
}); });
} }
export function useAppearanceSettings() {
return useQuery({
queryKey: APPEARANCE_KEY,
queryFn: () => loadAppearanceSettings(),
staleTime: 60_000,
refetchOnWindowFocus: false,
// The provider runs even on the login page (where the user has no auth)
// — gracefully tolerate the fetch failing rather than retrying.
retry: false,
});
}
export function useUpdateAppearanceSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: AppearanceUpdate) => updateAppearanceSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(APPEARANCE_KEY, data);
},
});
}
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Literal
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -124,3 +125,42 @@ async def update_private_square(body: PrivateSquareUpdateRequest, request: Reque
enabled=saved.private_square.enabled, enabled=saved.private_square.enabled,
password_set=bool(saved.private_square.password_hash), password_set=bool(saved.private_square.password_hash),
) )
class AppearanceResponse(BaseModel):
"""Effective system-wide appearance defaults."""
default_theme: Literal["light", "dark"] = Field(..., description="System default theme — light or dark")
class AppearanceUpdateRequest(BaseModel):
default_theme: Literal["light", "dark"] | None = Field(default=None)
@router.get("/appearance", response_model=AppearanceResponse)
async def get_appearance_settings() -> AppearanceResponse:
"""Return the global default theme. Any logged-in user may read this."""
settings = load_system_settings().appearance
return AppearanceResponse(default_theme=settings.default_theme)
@router.put("/appearance", response_model=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] = {}
if "default_theme" in fields and fields["default_theme"] is not None:
update["default_theme"] = fields["default_theme"]
if update:
updated_appearance = current.appearance.model_copy(update=update)
saved = save_system_settings(
current.model_copy(update={"appearance": updated_appearance})
)
else:
saved = current
return AppearanceResponse(default_theme=saved.appearance.default_theme)
@@ -17,6 +17,7 @@ import json
import logging import logging
import threading import threading
from pathlib import Path from pathlib import Path
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -37,6 +38,22 @@ class PromptPrefixSettings(BaseModel):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
class AppearanceSettings(BaseModel):
"""System-wide appearance defaults that any *new* user inherits.
Once a user explicitly toggles their theme in the UI, that choice is
persisted in the browser and supersedes this default — so changing
``default_theme`` here only affects users who have never touched the
toggle, and any future first-time loads.
"""
default_theme: Literal["light", "dark"] = Field(
default="light",
description="Theme applied for users who haven't picked their own (light / dark)",
)
model_config = ConfigDict(extra="ignore")
class PrivateSquareSettings(BaseModel): class PrivateSquareSettings(BaseModel):
"""Admin-configurable settings for the agent private-square (私密广场). """Admin-configurable settings for the agent private-square (私密广场).
@@ -56,6 +73,7 @@ class SystemSettings(BaseModel):
prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings) prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings)
private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings) private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings)
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")