Files
deerflow-code/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx
T

320 lines
11 KiB
TypeScript

"use client";
import { MoonIcon, SunIcon } from "lucide-react";
import { useMemo, type ComponentType, type SVGProps } from "react";
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
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 { 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() {
const { t, locale, changeLocale } = useI18n();
const { theme, setTheme } = useTheme();
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 cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
const handleSetGlobalDefault = async (value: "light" | "dark") => {
if (value === globalDefault) return;
try {
await updateAppearance.mutateAsync({ default_theme: value });
toast.success(
value === "light"
? "已将系统默认主题设置为浅色"
: "已将系统默认主题设置为深色",
);
} 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,
},
],
[
t.settings.appearance.dark,
t.settings.appearance.darkDescription,
t.settings.appearance.light,
t.settings.appearance.lightDescription,
],
);
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"}
onSelect={(value) => setTheme(value)}
/>
))}
</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>
<p className="text-muted-foreground ml-2 text-xs">
当前系统默认:
{globalDefault === "light" ? "浅色" : "深色"}
</p>
</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>
</div>
);
}
function ThemePreviewCard({
icon: Icon,
label,
description,
active,
mode,
onSelect,
}: {
icon: ComponentType<SVGProps<SVGSVGElement>>;
label: string;
description: string;
active: boolean;
mode: "light" | "dark";
onSelect: (mode: "light" | "dark") => 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"
: "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" : "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>
);
}