From ae3e2fb4e8e0bb7d632a14447cff5fdde2653a02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Tue, 26 May 2026 19:29:45 +0800 Subject: [PATCH] =?UTF-8?q?=E9=BB=98=E8=AE=A4=E4=B8=BB=E9=A2=98=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E6=B5=85=E8=89=B2=20+=20=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E5=8F=AF=E9=85=8D=E7=BD=AE=E5=85=A8=E5=B1=80=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E4=B8=BB=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- frontend-web/src/App.tsx | 16 +++---- frontend-web/src/core/system-settings/api.ts | 42 +++++++++++++++++++ .../src/core/system-settings/hooks.ts | 26 ++++++++++++ .../app/gateway/routers/system_settings.py | 40 ++++++++++++++++++ .../deerflow/config/system_settings.py | 18 ++++++++ 5 files changed, 135 insertions(+), 7 deletions(-) diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 155c3f64..b7508f12 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -31,13 +31,15 @@ function AppProviders({ children }: { children: React.ReactNode }) { disableTransitionOnChange forcedTheme={forcedTheme} > - - - - {children} - - - + {/* QueryClientProvider must wrap ThemeProvider because the custom + ThemeProvider reads the admin-configured default via useQuery. */} + + + + {children} + + + ); } diff --git a/frontend-web/src/core/system-settings/api.ts b/frontend-web/src/core/system-settings/api.ts index 697e88c5..e6335706 100644 --- a/frontend-web/src/core/system-settings/api.ts +++ b/frontend-web/src/core/system-settings/api.ts @@ -65,6 +65,48 @@ export async function loadPrivateSquareStatus(): Promise { 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 { + 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 { + 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( body: PrivateSquareUpdate, ): Promise { diff --git a/frontend-web/src/core/system-settings/hooks.ts b/frontend-web/src/core/system-settings/hooks.ts index 9c2ee5fd..903737a5 100644 --- a/frontend-web/src/core/system-settings/hooks.ts +++ b/frontend-web/src/core/system-settings/hooks.ts @@ -1,16 +1,20 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + loadAppearanceSettings, loadPrivateSquareStatus, loadPromptPrefixSettings, + updateAppearanceSettings, updatePrivateSquareSettings, updatePromptPrefixSettings, + type AppearanceUpdate, type PrivateSquareUpdate, type PromptPrefixUpdate, } from "./api"; const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const; const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const; +const APPEARANCE_KEY = ["systemSettings", "appearance"] as const; export function usePromptPrefixSettings() { 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); + }, + }); +} diff --git a/offline-backend-20260512/backend/app/gateway/routers/system_settings.py b/offline-backend-20260512/backend/app/gateway/routers/system_settings.py index 48940387..aadb877e 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/system_settings.py +++ b/offline-backend-20260512/backend/app/gateway/routers/system_settings.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Literal from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field @@ -124,3 +125,42 @@ async def update_private_square(body: PrivateSquareUpdateRequest, request: Reque enabled=saved.private_square.enabled, 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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py index 0a2d41ea..67e1a4d9 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py @@ -17,6 +17,7 @@ import json import logging import threading from pathlib import Path +from typing import Literal from pydantic import BaseModel, ConfigDict, Field @@ -37,6 +38,22 @@ class PromptPrefixSettings(BaseModel): 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): """Admin-configurable settings for the agent private-square (私密广场). @@ -56,6 +73,7 @@ class SystemSettings(BaseModel): prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings) private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings) + appearance: AppearanceSettings = Field(default_factory=AppearanceSettings) model_config = ConfigDict(extra="ignore")