Files
deerflow-code/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx
T
董宏凯 56449ed129 feat(ui): theme & layout improvements
- Dark-blue theme background assets and styling integration
- Appearance settings with layoutMode (full/compact) preference
- Settings dialog and auth context updates
- AI writing form and panel refinements
- Open Canvas context updates
- Roundtable planning styling and dashboard enhancements
- Strategy components and header updates
- Light app management and routing improvements
- Global style enhancements for dark theme
- Various page layout and workspace routing optimizations
2026-06-14 19:30:54 +08:00

507 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { DropletIcon, LayoutGridIcon, LayoutIcon, ListIcon, MoonIcon, PanelLeftIcon, SunIcon } from "lucide-react";
import { useMemo, type ComponentType, type SVGProps } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
import { FONT_SCALE_OPTIONS } from "@/components/font-scale";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { getStoredAuth } from "@/core/auth";
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
import { useI18n } from "@/core/i18n/hooks";
import { useLocalSettings } from "@/core/settings";
import {
useAppearanceSettings,
useCitationDisplaySettings,
useUpdateAppearanceSettings,
useUpdateCitationDisplaySettings,
} from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils";
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
import RecommendedQuestionsPage from "@/pages/RecommendedQuestionsPage";
import { SettingsSection } from "./settings-section";
const languageOptions: { value: Locale; label: string }[] = [
{ value: "en-US", label: enUS.locale.localName },
{ value: "zh-CN", label: zhCN.locale.localName },
];
export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const { t, locale, changeLocale } = useI18n();
const { theme, setTheme } = useTheme();
const navigate = useNavigate();
const currentTheme = theme;
const [settings, setSettings] = useLocalSettings();
const isAdmin = getStoredAuth()?.system_role === "admin";
const { data: appearance } = useAppearanceSettings();
const updateAppearance = useUpdateAppearanceSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const updateCitationDisplay = useUpdateCitationDisplaySettings();
const globalDefault = appearance?.default_theme ?? "light";
const globalSkillView = appearance?.default_skill_view ?? "list";
const globalAgentView = appearance?.default_agent_view ?? "card";
const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
const handleSetGlobalDefault = async (
value: "light" | "dark" | "lightblue",
) => {
if (value === globalDefault) return;
try {
await updateAppearance.mutateAsync({ default_theme: value });
const label =
value === "light" ? "浅色" : value === "dark" ? "深色" : "浅蓝";
toast.success(`已将系统默认主题设置为${label}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
}
};
const handleSetDefaultView = async (
kind: "skill" | "agent",
value: "card" | "list",
) => {
const current = kind === "skill" ? globalSkillView : globalAgentView;
if (value === current) return;
try {
await updateAppearance.mutateAsync(
kind === "skill"
? { default_skill_view: value }
: { default_agent_view: value },
);
const label = kind === "skill" ? "技能" : "智能体";
toast.success(
`已将${label}默认展示模式设置为${value === "card" ? "卡片" : "列表"}`,
);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
}
};
const themeOptions = useMemo(
() => [
{
id: "light",
label: t.settings.appearance.light,
description: t.settings.appearance.lightDescription,
icon: SunIcon,
},
{
id: "dark",
label: t.settings.appearance.dark,
description: t.settings.appearance.darkDescription,
icon: MoonIcon,
},
{
id: "lightblue",
label: t.settings.appearance.lightblue,
description: t.settings.appearance.lightblueDescription,
icon: DropletIcon,
},
],
[
t.settings.appearance.dark,
t.settings.appearance.darkDescription,
t.settings.appearance.light,
t.settings.appearance.lightDescription,
t.settings.appearance.lightblue,
t.settings.appearance.lightblueDescription,
],
);
return (
<div className="space-y-8">
<SettingsSection
title={t.settings.appearance.themeTitle}
description={t.settings.appearance.themeDescription}
>
<div className="grid gap-3 lg:grid-cols-2">
{themeOptions.map((option) => (
<ThemePreviewCard
key={option.id}
icon={option.icon}
label={option.label}
description={option.description}
active={currentTheme === option.id}
mode={option.id as "light" | "dark" | "lightblue"}
onSelect={(value) => setTheme(value)}
/>
))}
</div>
</SettingsSection>
<Separator />
<SettingsSection
title="界面模式"
description="全量模式展示侧边栏与顶栏导航;简洁模式隐藏侧边栏与顶栏,获得更大的工作区。"
>
<div className="flex gap-2">
<Button
type="button"
variant={settings.display.layoutMode !== "compact" ? "default" : "outline"}
size="sm"
onClick={() => {
setSettings("display", { layoutMode: "full" });
onClose?.();
navigate("/page/workspace/chats/new");
}}
>
<PanelLeftIcon className="mr-1.5 size-4" />
全量模式
</Button>
<Button
type="button"
variant={settings.display.layoutMode === "compact" ? "default" : "outline"}
size="sm"
onClick={() => {
setSettings("display", { layoutMode: "compact" });
onClose?.();
navigate("/page/workspace/chats/new");
}}
>
<LayoutIcon className="mr-1.5 size-4" />
简洁模式
</Button>
</div>
</SettingsSection>
{isAdmin && (
<>
<Separator />
<SettingsSection
title="系统默认主题(管理员)"
description="给所有还没有手动切换过主题的用户的默认主题;已经自己切换过的用户保留其个人设置不受影响。"
>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant={globalDefault === "light" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetGlobalDefault("light")}
disabled={updateAppearance.isPending}
>
<SunIcon className="mr-1.5 size-4" />
浅色
</Button>
<Button
type="button"
variant={globalDefault === "dark" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetGlobalDefault("dark")}
disabled={updateAppearance.isPending}
>
<MoonIcon className="mr-1.5 size-4" />
深色
</Button>
<Button
type="button"
variant={globalDefault === "lightblue" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetGlobalDefault("lightblue")}
disabled={updateAppearance.isPending}
>
<DropletIcon className="mr-1.5 size-4" />
浅蓝
</Button>
<p className="text-muted-foreground ml-2 text-xs">
当前系统默认:
{globalDefault === "light"
? "浅色"
: globalDefault === "dark"
? "深色"
: "浅蓝"}
</p>
</div>
</SettingsSection>
</>
)}
{isAdmin && (
<>
<Separator />
<SettingsSection
title="默认展示模式(管理员)"
description="技能与智能体列表对所有还没有手动切换过的用户的默认展示模式;已经自己切换过的用户保留其个人选择不受影响。"
>
<div className="flex flex-col gap-4">
{(
[
{ kind: "skill", label: "技能", current: globalSkillView },
{ kind: "agent", label: "智能体", current: globalAgentView },
] as const
).map(({ kind, label, current }) => (
<div key={kind} className="flex flex-wrap items-center gap-2">
<span className="w-14 text-sm font-medium">{label}</span>
<Button
type="button"
variant={current === "card" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetDefaultView(kind, "card")}
disabled={updateAppearance.isPending}
>
<LayoutGridIcon className="mr-1.5 size-4" />
卡片
</Button>
<Button
type="button"
variant={current === "list" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetDefaultView(kind, "list")}
disabled={updateAppearance.isPending}
>
<ListIcon className="mr-1.5 size-4" />
列表
</Button>
<p className="text-muted-foreground ml-2 text-xs">
当前系统默认:{current === "card" ? "卡片" : "列表"}
</p>
</div>
))}
</div>
</SettingsSection>
</>
)}
<Separator />
<SettingsSection
title="字号"
description="调整界面整体字号,方便放大或缩小阅读。默认 16px(标准)。"
>
<div className="space-y-3">
<Select
value={String(settings.display.fontScale ?? 1)}
onValueChange={(value) => {
const scale = Number(value);
if (Number.isFinite(scale) && scale > 0) {
setSettings("display", { fontScale: scale });
}
}}
>
<SelectTrigger className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FONT_SCALE_OPTIONS.map((item) => (
<SelectItem key={item.value} value={String(item.value)}>
{item.label}({item.px}px)
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-muted-foreground rounded-lg border p-4 text-sm leading-relaxed">
预览:多智能体圆桌研讨 · The quick brown fox · 0123456789
</div>
</div>
</SettingsSection>
<Separator />
<SettingsSection
title={t.settings.appearance.languageTitle}
description={t.settings.appearance.languageDescription}
>
<Select
value={locale}
onValueChange={(value) => {
if (isLocale(value)) {
changeLocale(value);
}
}}
>
<SelectTrigger className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{languageOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsSection>
<Separator />
<SettingsSection
title="对话体验"
description="问答过程中的辅助显示。"
>
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
<div className="text-sm font-medium">回答速度指示器</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
在生成回答时显示一个小胶囊,实时估算当前 token/s
并用绿到红的渐变条直观反映快慢。默认开启。
</p>
</div>
<Switch
checked={settings.display.streamSpeedMeterEnabled}
onCheckedChange={(checked) =>
setSettings("display", { streamSpeedMeterEnabled: checked })
}
aria-label="回答速度指示器"
/>
</div>
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
<div className="text-sm font-medium">RAG 引用模式</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。
</p>
</div>
<Switch
checked={settings.display.ragModeEnabled}
onCheckedChange={(checked) =>
setSettings("display", { ragModeEnabled: checked })
}
aria-label="RAG 引用模式"
/>
</div>
{isAdmin && (
<div className="rounded-lg border p-4">
<div className="mb-3">
<div className="text-sm font-medium">参考文献去除词</div>
<p className="text-muted-foreground mt-1 max-w-2xl text-xs leading-relaxed">
管理员配置。每行一个词或短句,参考文献卡片和详情弹框会自动移除这些广告词、导航词或免责声明。
</p>
</div>
<Textarea
value={cleanupWords.join("\n")}
onChange={(event) => {
const words = event.currentTarget.value
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
setSettings("display", { citationCleanupWords: words });
updateCitationDisplay.mutate({ cleanup_words: words });
}}
placeholder={"广告\n点击下载APP\n打开客户端\n免责声明\n责任编辑"}
className="min-h-32 text-sm"
/>
</div>
)}
</SettingsSection>
{isAdmin && (
<>
<Separator />
<RecommendedQuestionsPage embedded />
<Separator />
<PromptPrefixSettingsPage embedded />
<Separator />
<PrivateSquareSettingsPage embedded />
<Separator />
<PublishSquaresSettingsPage embedded />
</>
)}
</div>
);
}
function ThemePreviewCard({
icon: Icon,
label,
description,
active,
mode,
onSelect,
}: {
icon: ComponentType<SVGProps<SVGSVGElement>>;
label: string;
description: string;
active: boolean;
mode: "light" | "dark" | "lightblue";
onSelect: (mode: "light" | "dark" | "lightblue") => void;
}) {
const previewMode = mode;
return (
<button
type="button"
onClick={() => onSelect(mode)}
className={cn(
"group flex h-full flex-col gap-3 rounded-lg border p-4 text-left transition-all",
active
? "border-primary ring-primary/30 shadow-sm ring-2"
: "hover:border-border hover:shadow-sm",
)}
>
<div className="flex items-start gap-3">
<div className="bg-muted rounded-md p-2">
<Icon className="size-4" />
</div>
<div className="space-y-1">
<div className="text-sm leading-none font-semibold">{label}</div>
<p className="text-muted-foreground text-xs leading-snug">
{description}
</p>
</div>
</div>
<div
className={cn(
"relative overflow-hidden rounded-md border text-xs transition-colors",
previewMode === "dark"
? "border-neutral-800 bg-neutral-900 text-neutral-200"
: previewMode === "lightblue"
? "border-blue-200 bg-white text-slate-900"
: "border-slate-200 bg-white text-slate-900",
)}
>
<div className="border-border/50 flex items-center gap-2 border-b px-3 py-2">
<div
className={cn(
"h-2 w-2 rounded-full",
previewMode === "dark"
? "bg-emerald-400"
: previewMode === "lightblue"
? "bg-blue-500"
: "bg-emerald-500",
)}
/>
<div className="h-2 w-10 rounded-full bg-current/20" />
<div className="h-2 w-6 rounded-full bg-current/15" />
</div>
<div className="grid grid-cols-[1fr_240px] gap-3 px-3 py-3">
<div className="space-y-2">
<div className="h-3 w-3/4 rounded-full bg-current/15" />
<div className="h-3 w-1/2 rounded-full bg-current/10" />
<div className="h-[90px] rounded-md border border-current/10 bg-current/5" />
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-md bg-current/10" />
<div className="space-y-2">
<div className="h-2 w-14 rounded-full bg-current/15" />
<div className="h-2 w-10 rounded-full bg-current/10" />
</div>
</div>
<div className="flex flex-col gap-1 rounded-md border border-dashed border-current/15 p-2">
<div className="h-2 w-3/5 rounded-full bg-current/15" />
<div className="h-2 w-2/5 rounded-full bg-current/10" />
</div>
</div>
</div>
</div>
</button>
);
}