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

- 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 authored and xuhd committed 2026-05-28 18:28:49 +08:00
1 parent 650102cedb
commit ae3e2fb4e8
5 files changed
+135 -7

No files matched your search

+9 -7
View File
@@ -31,13 +31,15 @@ function AppProviders({ children }: { children: React.ReactNode }) {
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>
); );
} }
@@ -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")