1287 lines
47 KiB
TypeScript
1287 lines
47 KiB
TypeScript
"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>
|
||
);
|
||
}
|