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

1287 lines
47 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 {
ArrowDown,
ArrowUp,
DropletIcon,
ExternalLink,
LayoutGridIcon,
LayoutIcon,
LeafIcon,
ListIcon,
MoonIcon,
PanelLeftIcon,
PaletteIcon,
Plus,
SnowflakeIcon,
SunIcon,
Trash2,
} from "lucide-react";
import { useEffect, useMemo, useState, type ComponentType, type SVGProps } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import { FONT_SCALE_OPTIONS } from "@/components/font-scale";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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 { migrateAuthToSession } from "@/core/auth/scoped-storage";
import { isIsolatedSession, setIsolatedSession } from "@/core/embed/embed-session";
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
import { useI18n } from "@/core/i18n/hooks";
import { useLocalSettings } from "@/core/settings";
import {
useAppearanceSettings,
useCitationDisplaySettings,
useLayoutMode,
useUpdateAppearanceSettings,
useUpdateCitationDisplaySettings,
} from "@/core/system-settings/hooks";
import type {
CompactShortcutButton,
CompactShortcutRouteParam,
} from "@/core/system-settings/api";
import { cn } from "@/lib/utils";
import {
APP_THEME_COLORS,
LOGIN_SOURCE_OPTIONS,
parseThemeMap,
stringifyThemeMap,
} from "@/strategy-components/lib/light-app";
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage";
import { CustomThemeLibrarySettings } from "./custom-theme-library-settings";
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 },
];
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;green=green;blue=blue;dark-blue=dark-blue";
function makeShortcutId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `shortcut-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function newShortcutParam(): CompactShortcutRouteParam {
return {
key: "access_token",
paramType: "login",
source: "access_token",
customValue: "",
themeValue: DEFAULT_SHORTCUT_THEME_MAP,
};
}
function newShortcutButton(): CompactShortcutButton {
return {
id: makeShortcutId(),
label: "快捷入口",
url: "",
route_params: [newShortcutParam()],
enabled: true,
sort_order: 0,
};
}
function normalizeShortcutDraft(
buttons: CompactShortcutButton[] | undefined,
): CompactShortcutButton[] {
const source = buttons?.length ? buttons : [newShortcutButton()];
return [...source]
.sort((a, b) => a.sort_order - b.sort_order)
.map((button, index) => ({
...button,
id: button.id || makeShortcutId(),
label: button.label || "快捷入口",
url: button.url || "",
route_params: (button.route_params?.length
? button.route_params
: [newShortcutParam()]
).map((param) => ({
key: param.key || "",
paramType: param.paramType || "login",
source: param.source || "access_token",
customValue: param.customValue ?? "",
themeValue: param.themeValue || DEFAULT_SHORTCUT_THEME_MAP,
})),
enabled: button.enabled ?? true,
sort_order: index,
}));
}
function cleanShortcutParams(
params: CompactShortcutRouteParam[],
): CompactShortcutRouteParam[] {
return params
.filter((param) => param.key.trim())
.map((param) => {
const base: CompactShortcutRouteParam = {
key: param.key.trim(),
paramType: param.paramType,
};
if (param.paramType === "theme") {
base.themeValue = stringifyThemeMap(parseThemeMap(param.themeValue));
return base;
}
base.source = param.source || "access_token";
if (base.source === "other") {
base.customValue = (param.customValue ?? "").trim();
}
return base;
});
}
function cleanShortcutButtons(
buttons: CompactShortcutButton[],
): CompactShortcutButton[] {
return buttons.map((button, index) => ({
id: button.id || makeShortcutId(),
label: button.label.trim() || "快捷入口",
url: button.url.trim(),
route_params: cleanShortcutParams(button.route_params),
enabled: button.enabled,
sort_order: index,
}));
}
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 [shortcutDraft, setShortcutDraft] = useState<CompactShortcutButton[]>([]);
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 globalLayoutMode = appearance?.default_layout_mode ?? "full";
const globalDefaultLabel = useMemo(() => {
const customTheme = (appearance?.custom_themes ?? []).find(
(preset) => preset.id === globalDefault,
);
if (customTheme) return customTheme.name;
if (globalDefault === "light") return "浅色";
if (globalDefault === "dark") return "深色";
if (globalDefault === "lightgreen") return "绿色";
if (globalDefault === "iceblue") return "冰川蓝";
if (globalDefault === "lightyellow") return "浅黄色";
return "浅蓝";
}, [appearance?.custom_themes, globalDefault]);
// 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则会话级回退)。
const [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
useEffect(() => {
setShortcutDraft(
normalizeShortcutDraft(appearance?.compact_shortcut_buttons),
);
}, [appearance?.compact_shortcut_buttons]);
// 独立登录会话(本标签页):开关切换鉴权存储 localStorage ↔ sessionStorage(见 scoped-storage)。
const [isolatedSession, setIsolatedSessionState] = useState(() => isIsolatedSession());
const handleToggleIsolatedSession = (checked: boolean) => {
if (checked) {
// 先把当前账号从 localStorage 快照搬进 sessionStorage,再翻标志,避免切换存储后被登出。
migrateAuthToSession();
setIsolatedSession(true);
setIsolatedSessionState(true);
toast.success("已开启独立登录会话", {
description:
"本标签页的登录已与其它标签页隔离:在别的窗口登录或切换其它账号都不会影响这里,你也可以在此单独登录另一个账号。该独立会话只存在于当前标签页,关闭此标签后即结束(需重新登录)。",
duration: 9000,
});
} else {
setIsolatedSession(false);
setIsolatedSessionState(false);
toast.info("已关闭独立登录会话", {
description: "本标签页恢复与其它标签页共享同一登录。如页面显示未及时同步,请刷新本页。",
duration: 6000,
});
}
};
const handleSetGlobalDefault = async (
value: "light" | "dark" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow",
) => {
if (value === globalDefault) return;
try {
await updateAppearance.mutateAsync({ default_theme: value });
const label =
value === "light"
? "浅色"
: value === "dark"
? "深色"
: value === "lightgreen"
? "绿色"
: value === "iceblue"
? "冰川蓝"
: value === "lightyellow"
? "浅黄色"
: "浅蓝";
toast.success(`已将系统默认主题设置为${label}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
}
};
const handleSetDefaultLayoutMode = async (value: "full" | "compact") => {
if (value === globalLayoutMode) return;
try {
await updateAppearance.mutateAsync({ default_layout_mode: value });
toast.success(
`已将系统默认界面模式设置为${value === "full" ? "全量模式" : "简洁模式"}`,
);
} 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 patchShortcut = (
id: string,
patch: Partial<CompactShortcutButton>,
) => {
setShortcutDraft((items) =>
items.map((item) => (item.id === id ? { ...item, ...patch } : item)),
);
};
const patchShortcutParam = (
id: string,
index: number,
patch: Partial<CompactShortcutRouteParam>,
) => {
setShortcutDraft((items) =>
items.map((item) =>
item.id === id
? {
...item,
route_params: item.route_params.map((param, paramIndex) =>
paramIndex === index ? { ...param, ...patch } : param,
),
}
: item,
),
);
};
const moveShortcut = (id: string, direction: -1 | 1) => {
setShortcutDraft((items) => {
const index = items.findIndex((item) => item.id === id);
const target = index + direction;
if (index < 0 || target < 0 || target >= items.length) return items;
const next = [...items];
[next[index], next[target]] = [next[target]!, next[index]!];
return next.map((item, order) => ({ ...item, sort_order: order }));
});
};
const addShortcutParam = (id: string) => {
setShortcutDraft((items) =>
items.map((item) =>
item.id === id
? {
...item,
route_params: [...item.route_params, newShortcutParam()],
}
: item,
),
);
};
const removeShortcutParam = (id: string, index: number) => {
setShortcutDraft((items) =>
items.map((item) =>
item.id === id
? {
...item,
route_params: item.route_params.filter((_, paramIndex) => paramIndex !== index),
}
: item,
),
);
};
const handleSaveShortcuts = async () => {
for (const item of shortcutDraft) {
if (item.enabled && !item.url.trim()) {
toast.error(`请填写「${item.label.trim() || "快捷入口"}」的跳转链接,或先关闭该按钮`);
return;
}
for (const param of item.route_params) {
if (!param.key.trim()) {
toast.error("路由参数键名不可为空");
return;
}
if (
param.paramType === "login" &&
param.source === "other" &&
!param.customValue?.trim()
) {
toast.error("登录参数自定义值不可为空");
return;
}
}
}
try {
await updateAppearance.mutateAsync({
compact_shortcut_buttons: cleanShortcutButtons(shortcutDraft),
});
toast.success("顶部快捷按钮已保存");
} 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,
},
{
id: "lightgreen",
label: t.settings.appearance.lightgreen,
description: t.settings.appearance.lightgreenDescription,
icon: LeafIcon,
},
{
id: "iceblue",
label: t.settings.appearance.iceblue,
description: t.settings.appearance.iceblueDescription,
icon: SnowflakeIcon,
},
{
id: "lightyellow",
label: t.settings.appearance.lightyellow,
description: t.settings.appearance.lightyellowDescription,
icon: SunIcon,
},
],
[
t.settings.appearance.dark,
t.settings.appearance.darkDescription,
t.settings.appearance.light,
t.settings.appearance.lightDescription,
t.settings.appearance.lightblue,
t.settings.appearance.lightblueDescription,
t.settings.appearance.lightgreen,
t.settings.appearance.lightgreenDescription,
t.settings.appearance.iceblue,
t.settings.appearance.iceblueDescription,
t.settings.appearance.lightyellow,
t.settings.appearance.lightyellowDescription,
],
);
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" | "lightgreen" | "iceblue" | "lightyellow"}
onSelect={(value) => setTheme(value)}
/>
))}
{(appearance?.custom_themes ?? [])
.filter((preset) => preset.enabled)
.sort((a, b) => a.sort_order - b.sort_order)
.map((preset) => (
<ThemePreviewCard
key={preset.id}
icon={PaletteIcon}
label={preset.name}
description={`自定义主题 · 主色 ${preset.primary}`}
active={currentTheme === preset.id}
mode={preset.id}
previewDark={preset.mode === "dark"}
accentColor={preset.primary}
onSelect={(value) => setTheme(value)}
/>
))}
</div>
</SettingsSection>
<Separator />
<SettingsSection
title="界面模式"
description="全量模式展示侧边栏与顶栏导航;简洁模式隐藏侧边栏与顶栏,获得更大的工作区。"
>
<div className="flex gap-2">
<Button
type="button"
variant={effectiveLayoutMode !== "compact" ? "default" : "outline"}
size="sm"
onClick={() => {
setLayoutMode("full");
onClose?.();
navigate("/page/workspace/chats/new");
}}
>
<PanelLeftIcon className="mr-1.5 size-4" />
全量模式
</Button>
<Button
type="button"
variant={effectiveLayoutMode === "compact" ? "default" : "outline"}
size="sm"
onClick={() => {
setLayoutMode("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={globalLayoutMode === "full" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetDefaultLayoutMode("full")}
disabled={updateAppearance.isPending}
>
<PanelLeftIcon className="mr-1.5 size-4" />
全量模式
</Button>
<Button
type="button"
variant={globalLayoutMode === "compact" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetDefaultLayoutMode("compact")}
disabled={updateAppearance.isPending}
>
<LayoutIcon className="mr-1.5 size-4" />
简洁模式
</Button>
<p className="text-muted-foreground ml-2 text-xs">
当前系统默认:
{globalLayoutMode === "full" ? "全量模式" : "简洁模式"}
</p>
</div>
</SettingsSection>
</>
)}
{isAdmin && (
<>
<Separator />
<SettingsSection
title="顶部快捷按钮(管理员)"
description="配置全量/简洁模式右上角、换肤按钮左侧显示的跳转按钮。支持多个按钮、启停和与应用管理一致的登录/主题参数。"
>
<CompactShortcutSettings
buttons={shortcutDraft}
saving={updateAppearance.isPending}
onAdd={() =>
setShortcutDraft((items) => [
...items,
{ ...newShortcutButton(), sort_order: items.length },
])
}
onPatch={patchShortcut}
onMove={moveShortcut}
onRemove={(id) =>
setShortcutDraft((items) =>
items
.filter((item) => item.id !== id)
.map((item, index) => ({ ...item, sort_order: index })),
)
}
onAddParam={addShortcutParam}
onPatchParam={patchShortcutParam}
onRemoveParam={removeShortcutParam}
onSave={handleSaveShortcuts}
/>
</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>
<Button
type="button"
variant={globalDefault === "lightgreen" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetGlobalDefault("lightgreen")}
disabled={updateAppearance.isPending}
>
<LeafIcon className="mr-1.5 size-4" />
绿色
</Button>
<Button
type="button"
variant={globalDefault === "iceblue" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetGlobalDefault("iceblue")}
disabled={updateAppearance.isPending}
>
<SnowflakeIcon className="mr-1.5 size-4" />
冰川蓝
</Button>
<Button
type="button"
variant={globalDefault === "lightyellow" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetGlobalDefault("lightyellow")}
disabled={updateAppearance.isPending}
>
<SunIcon className="mr-1.5 size-4" />
浅黄色
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
document
.getElementById("custom-theme-library")
?.scrollIntoView({ behavior: "smooth", block: "start" })
}
>
<PaletteIcon className="mr-1.5 size-4" />
管理自定义主题
</Button>
<p className="text-muted-foreground ml-2 text-xs">
当前系统默认:{globalDefaultLabel}
</p>
</div>
</SettingsSection>
</>
)}
{isAdmin && (
<div id="custom-theme-library" className="scroll-mt-4">
<Separator />
<SettingsSection
title="自定义主题库(管理员)"
description="管理员可按品牌主色创建主题,并设置为系统默认;用户只会看到已启用的主题并可自行切换。"
>
<CustomThemeLibrarySettings />
</SettingsSection>
</div>
)}
{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">
开启后,本标签页的登录状态仅保存在当前标签页:在别的窗口登录或切换其它账号都不会影响这里,
你也可以在此单独登录另一个账号。关闭浏览器标签后该独立会话即结束(需重新登录)。
</p>
</div>
<Switch
checked={isolatedSession}
onCheckedChange={handleToggleIsolatedSession}
aria-label="独立登录会话"
/>
</div>
</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 />
<PromptPrefixSettingsPage embedded />
<Separator />
<PrivateSquareSettingsPage embedded />
<Separator />
<PublishSquaresSettingsPage embedded />
<Separator />
<SkillCompressionSettingsPage embedded />
</>
)}
</div>
);
}
function CompactShortcutSettings({
buttons,
saving,
onAdd,
onPatch,
onMove,
onRemove,
onAddParam,
onPatchParam,
onRemoveParam,
onSave,
}: {
buttons: CompactShortcutButton[];
saving: boolean;
onAdd: () => void;
onPatch: (id: string, patch: Partial<CompactShortcutButton>) => void;
onMove: (id: string, direction: -1 | 1) => void;
onRemove: (id: string) => void;
onAddParam: (id: string) => void;
onPatchParam: (
id: string,
index: number,
patch: Partial<CompactShortcutRouteParam>,
) => void;
onRemoveParam: (id: string, index: number) => void;
onSave: () => void;
}) {
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-muted-foreground text-xs">
已配置 {buttons.length} 个按钮;只有启用且填写了跳转链接的按钮会显示。
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={onAdd}>
<Plus className="mr-1.5 size-4" />
添加按钮
</Button>
<Button
type="button"
size="sm"
onClick={onSave}
disabled={saving}
>
保存按钮配置
</Button>
</div>
</div>
{buttons.length === 0 ? (
<div className="text-muted-foreground rounded-lg border border-dashed p-6 text-center text-sm">
暂无按钮,点击“添加按钮”创建一个。
</div>
) : (
<div className="space-y-3">
{buttons.map((button, index) => (
<div key={button.id} className="space-y-4 rounded-lg border p-4">
<div className="flex flex-col gap-3 xl:flex-row xl:items-start">
<div className="flex items-center gap-2 pt-6">
<Switch
checked={button.enabled}
onCheckedChange={(checked) =>
onPatch(button.id, { enabled: checked })
}
aria-label="启用快捷按钮"
/>
<span className="text-muted-foreground text-xs">
{button.enabled ? "启用" : "停用"}
</span>
</div>
<div className="grid min-w-0 flex-1 gap-3 md:grid-cols-[220px_minmax(0,1fr)]">
<div className="space-y-1.5">
<div className="text-xs font-medium">按钮名称</div>
<Input
value={button.label}
onChange={(event) =>
onPatch(button.id, { label: event.target.value })
}
placeholder="如:业务系统"
/>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium">跳转链接</div>
<Input
value={button.url}
onChange={(event) =>
onPatch(button.id, { url: event.target.value })
}
placeholder="https://example.com/page"
className="font-mono"
/>
</div>
</div>
<div className="flex shrink-0 items-center gap-1 pt-6">
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => onMove(button.id, -1)}
disabled={index === 0}
title="上移"
>
<ArrowUp className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => onMove(button.id, 1)}
disabled={index === buttons.length - 1}
title="下移"
>
<ArrowDown className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-destructive hover:text-destructive"
onClick={() => onRemove(button.id)}
title="删除"
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
<div className="rounded-lg border bg-muted/20 p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-sm font-medium">
<ExternalLink className="size-4" />
路由参数
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onAddParam(button.id)}
>
<Plus className="mr-1.5 size-4" />
添加参数
</Button>
</div>
{button.route_params.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed bg-background/70 p-4 text-center text-xs">
不追加额外参数。
</div>
) : (
<div className="space-y-2">
{button.route_params.map((param, paramIndex) => {
const themeMap =
param.paramType === "theme"
? parseThemeMap(param.themeValue)
: {};
return (
<div
key={`${button.id}-${paramIndex}`}
className="space-y-2 rounded-md border bg-background p-2"
>
<div className="grid gap-2 md:grid-cols-[minmax(120px,1fr)_130px_minmax(180px,1.4fr)_32px]">
<Input
value={param.key}
onChange={(event) =>
onPatchParam(button.id, paramIndex, {
key: event.target.value,
})
}
placeholder="参数名 key"
className="font-mono text-xs"
/>
<Select
value={param.paramType}
onValueChange={(value) =>
onPatchParam(button.id, paramIndex, {
paramType: value as "login" | "theme",
source:
value === "login"
? param.source || "access_token"
: undefined,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="login">登录参数</SelectItem>
<SelectItem value="theme">主题参数</SelectItem>
</SelectContent>
</Select>
{param.paramType === "login" ? (
<div className="flex min-w-0 gap-2">
<Select
value={param.source || "access_token"}
onValueChange={(value) =>
onPatchParam(button.id, paramIndex, {
source: value,
})
}
>
<SelectTrigger className="min-w-0 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOGIN_SOURCE_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={option.value}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{param.source === "other" && (
<Input
value={param.customValue ?? ""}
onChange={(event) =>
onPatchParam(button.id, paramIndex, {
customValue: event.target.value,
})
}
placeholder="自定义值"
className="w-32 text-xs"
/>
)}
</div>
) : (
<div className="text-muted-foreground flex items-center text-xs">
按当前主题取值
</div>
)}
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-destructive hover:text-destructive"
onClick={() =>
onRemoveParam(button.id, paramIndex)
}
title="删除参数"
>
<Trash2 className="size-4" />
</Button>
</div>
{param.paramType === "theme" && (
<div className="grid gap-2 rounded-md bg-muted/40 p-2 md:grid-cols-3">
{APP_THEME_COLORS.map((color) => (
<label
key={color.key}
className="flex min-w-0 items-center gap-2"
>
<span
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: color.dot }}
/>
<span className="text-muted-foreground shrink-0 text-xs">
{color.label}
</span>
<Input
value={themeMap[color.key] ?? color.key}
onChange={(event) =>
onPatchParam(button.id, paramIndex, {
themeValue: stringifyThemeMap({
...themeMap,
[color.key]: event.target.value,
}),
})
}
placeholder={color.key}
className="h-8 min-w-0 font-mono text-xs"
/>
</label>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
function ThemePreviewCard({
icon: Icon,
label,
description,
active,
mode,
previewDark,
accentColor,
onSelect,
}: {
icon: ComponentType<SVGProps<SVGSVGElement>>;
label: string;
description: string;
active: boolean;
mode: ThemeType;
previewDark?: boolean;
accentColor?: string;
onSelect: (mode: ThemeType) => void;
}) {
const previewMode = mode;
const isDarkPreview = previewDark ?? previewMode === "dark";
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",
isDarkPreview
? "border-neutral-800 bg-neutral-900 text-neutral-200"
: previewMode === "lightblue"
? "border-blue-200 bg-white text-slate-900"
: previewMode === "lightgreen"
? "border-emerald-200 bg-white text-emerald-950"
: previewMode === "iceblue"
? "border-sky-200 bg-[#F7FBFE] text-[#18324A]"
: previewMode === "lightyellow"
? "border-lime-200 bg-[#FCFDF5] text-[#303D23]"
: "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",
accentColor
? ""
: isDarkPreview
? "bg-emerald-400"
: previewMode === "lightblue"
? "bg-blue-500"
: previewMode === "lightgreen"
? "bg-[#07c05f]"
: previewMode === "iceblue"
? "bg-[#9BCEF1]"
: previewMode === "lightyellow"
? "bg-[#F3FBD9]"
: "bg-[#cf1322]",
)}
style={accentColor ? { backgroundColor: accentColor } : undefined}
/>
<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>
);
}