默认主题改为浅色 + 管理员可配置全局默认主题
- 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:
1 parent
650102cedb
commit
ae3e2fb4e8
5 files changed
+135
-7
No files matched your search
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user