- 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
507 lines
18 KiB
TypeScript
507 lines
18 KiB
TypeScript
"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>
|
||
);
|
||
}
|