样式修改

This commit is contained in:
fengxiangdebaozi committed 2026-07-03 16:35:52 +08:00
1 parent 96947d7636
commit f3511e5cd0
15 files changed
+895 -46

No files matched your search

+2 -2
View File
@@ -14,8 +14,8 @@ VITE_MOCK_SQ_REPORT=false
VITE_MOCK_COP_TASK_DETAIL=false VITE_MOCK_COP_TASK_DETAIL=false
# Gateway / DeerFlow API # Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://47.88.25.99:7001 VITE_BACKEND_BASE_URL=http://10.19.200.5:2026
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api VITE_LANGGRAPH_BASE_URL=http://10.19.200.5:2026/api
# 轻应用「新建笔记」系列接口 # 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000 VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
@@ -260,37 +260,68 @@ export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string })
key={agent.id} key={agent.id}
href={href} href={href}
className={cn( className={cn(
"group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 transition-colors", "group relative flex w-full items-start gap-3 rounded-xl border px-3 py-2.5 transition-all duration-150",
isActive isActive
? "border-primary/40 bg-primary/5" ? isDark
: "border-transparent hover:border-border hover:bg-accent/50", ? "border-[#2b8ffe] bg-[#0b4fae]/65 shadow-[0_0_0_1px_rgba(43,143,254,0.5),0_12px_28px_rgba(37,128,255,0.25)]"
: "border-primary/50 bg-primary/10 shadow-sm"
: isDark
? "border-[#2b8ffe]/20 bg-[#042b59]/35 hover:border-[#2b8ffe]/60 hover:bg-[#063b77]/60"
: "border-transparent hover:border-border hover:bg-accent/50",
)} )}
> >
<span <span
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-sm" className={cn(
"mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-lg text-white shadow-sm",
isDark &&
isActive &&
"ring-2 ring-[#83c1ff]/35 shadow-[0_0_18px_rgba(43,143,254,0.45)]",
)}
style={{ style={{
backgroundImage: `linear-gradient(135deg, ${from}, ${to})`, backgroundImage: `linear-gradient(135deg, ${from}, ${to})`,
}} }}
> >
<Bot className="size-[18px]" /> <Bot className="size-[19px]" />
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span <span
className={cn( className={cn(
"block truncate text-[13px] font-semibold", "block truncate text-[16px] leading-5 font-bold",
isActive ? "text-primary" : "text-foreground", isActive
? isDark
? "text-white"
: "text-primary"
: isDark
? "text-white/95"
: "text-foreground",
)} )}
> >
{desensitize(agent.name)} {desensitize(agent.name)}
</span> </span>
<span className="mt-0.5 block truncate text-[11px] leading-snug text-muted-foreground"> <span
className={cn(
"mt-1 block truncate text-[13px] leading-[1.35]",
isDark
? isActive
? "text-[#d6eaff]"
: "text-[#9ebfe2]"
: "text-muted-foreground",
)}
>
{agent.description {agent.description
? desensitize(agent.description) ? desensitize(agent.description)
: "智能体助手"} : "智能体助手"}
</span> </span>
</span> </span>
{isActive && ( {isActive && (
<span className="absolute top-1/2 right-1 h-6 w-1 -translate-y-1/2 rounded-full bg-primary" /> <span
className={cn(
"absolute top-1/2 right-1.5 h-8 w-1.5 -translate-y-1/2 rounded-full",
isDark
? "bg-[#2b8ffe] shadow-[0_0_14px_rgba(43,143,254,0.85)]"
: "bg-primary",
)}
/>
)} )}
</Link> </Link>
); );
@@ -219,33 +219,64 @@ export function AgentModuleSidebar({
} }
}} }}
className={cn( className={cn(
"group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 transition-colors", "group relative flex w-full items-start gap-3 rounded-xl border px-3 py-2.5 transition-all duration-150",
isActive isActive
? "border-primary/40 bg-primary/5" ? isDark
: "border-transparent hover:border-border hover:bg-accent/50", ? "border-[#2b8ffe] bg-[#0b4fae]/65 shadow-[0_0_0_1px_rgba(43,143,254,0.5),0_12px_28px_rgba(37,128,255,0.25)]"
: "border-primary/50 bg-primary/10 shadow-sm"
: isDark
? "border-[#2b8ffe]/20 bg-[#042b59]/35 hover:border-[#2b8ffe]/60 hover:bg-[#063b77]/60"
: "border-transparent hover:border-border hover:bg-accent/50",
)} )}
> >
<span <span
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-sm" className={cn(
"mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-lg text-white shadow-sm",
isDark &&
isActive &&
"ring-2 ring-[#83c1ff]/35 shadow-[0_0_18px_rgba(43,143,254,0.45)]",
)}
style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }} style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }}
> >
<Bot className="size-[18px]" /> <Bot className="size-[19px]" />
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span <span
className={cn( className={cn(
"block truncate text-[13px] font-semibold", "block truncate text-[16px] leading-5 font-bold",
isActive ? "text-primary" : "text-foreground", isActive
? isDark
? "text-white"
: "text-primary"
: isDark
? "text-white/95"
: "text-foreground",
)} )}
> >
{desensitize(agent.name)} {desensitize(agent.name)}
</span> </span>
<span className="mt-0.5 block truncate text-[11px] leading-snug text-muted-foreground"> <span
className={cn(
"mt-1 block truncate text-[13px] leading-[1.35]",
isDark
? isActive
? "text-[#d6eaff]"
: "text-[#9ebfe2]"
: "text-muted-foreground",
)}
>
{agent.description ? desensitize(agent.description) : "智能体助手"} {agent.description ? desensitize(agent.description) : "智能体助手"}
</span> </span>
</span> </span>
{isActive && ( {isActive && (
<span className="absolute top-1/2 right-1 h-6 w-1 -translate-y-1/2 rounded-full bg-primary" /> <span
className={cn(
"absolute top-1/2 right-1.5 h-8 w-1.5 -translate-y-1/2 rounded-full",
isDark
? "bg-[#2b8ffe] shadow-[0_0_14px_rgba(43,143,254,0.85)]"
: "bg-primary",
)}
/>
)} )}
</Link> </Link>
); );
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Check, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react"; import { Check, ExternalLink, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -14,8 +14,9 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext"; import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import { useLayoutMode } from "@/core/system-settings/hooks"; import { useAppearanceSettings, useLayoutMode } from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
/** /**
* 换肤 / 界面模式 顶栏控件。深色顶栏(Header)与简洁模式页面级顶栏(ChatPage 等)共用, * 换肤 / 界面模式 顶栏控件。深色顶栏(Header)与简洁模式页面级顶栏(ChatPage 等)共用,
@@ -39,6 +40,43 @@ const triggerToneClass = (tone: AppearanceControlTone) =>
? "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]" ? "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]"
: "text-muted-foreground hover:bg-muted hover:text-foreground"; : "text-muted-foreground hover:bg-muted hover:text-foreground";
export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }) {
const { data: appearance } = useAppearanceSettings();
const buttons = (appearance?.compact_shortcut_buttons ?? [])
.filter((button) => button.enabled && button.label.trim() && button.url.trim())
.sort((a, b) => a.sort_order - b.sort_order);
if (buttons.length === 0) return null;
return (
<>
{buttons.map((button) => (
<Button
key={button.id}
type="button"
variant="ghost"
size="sm"
className={cn(
"h-8 max-w-[168px] shrink-0 gap-1.5 rounded-sm px-2 text-xs font-medium",
triggerToneClass(tone),
)}
title={button.label}
onClick={() => {
const url = buildLightAppUrl({
url: button.url,
routeParams: button.route_params.map((param) => ({ ...param })),
});
window.open(url, "_blank", "noopener,noreferrer");
}}
>
<ExternalLink className="size-4 shrink-0" />
<span className="truncate">{button.label}</span>
</Button>
))}
</>
);
}
export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) { export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -349,33 +349,64 @@ export function GeneralQaSidebar() {
type="button" type="button"
onClick={() => handleSelectAgent(agent.id)} onClick={() => handleSelectAgent(agent.id)}
className={cn( className={cn(
"group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 text-left transition-colors", "group relative flex w-full items-start gap-3 rounded-xl border px-3 py-2.5 text-left transition-all duration-150",
isActive isActive
? "border-primary/40 bg-primary/5" ? isDark
: "border-transparent hover:border-border hover:bg-accent/50", ? "border-[#2b8ffe] bg-[#0b4fae]/65 shadow-[0_0_0_1px_rgba(43,143,254,0.5),0_12px_28px_rgba(37,128,255,0.25)]"
: "border-primary/50 bg-primary/10 shadow-sm"
: isDark
? "border-[#2b8ffe]/20 bg-[#042b59]/35 hover:border-[#2b8ffe]/60 hover:bg-[#063b77]/60"
: "border-transparent hover:border-border hover:bg-accent/50",
)} )}
> >
<span <span
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-sm" className={cn(
"mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-lg text-white shadow-sm",
isDark &&
isActive &&
"ring-2 ring-[#83c1ff]/35 shadow-[0_0_18px_rgba(43,143,254,0.45)]",
)}
style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }} style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }}
> >
<Bot className="size-[18px]" /> <Bot className="size-[19px]" />
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span <span
className={cn( className={cn(
"block truncate text-[13px] font-semibold", "block truncate text-[16px] leading-5 font-bold",
isActive ? "text-primary" : "text-foreground", isActive
? isDark
? "text-white"
: "text-primary"
: isDark
? "text-white/95"
: "text-foreground",
)} )}
> >
{desensitize(agent.name)} {desensitize(agent.name)}
</span> </span>
<span className="mt-0.5 block truncate text-[11px] leading-snug text-muted-foreground"> <span
className={cn(
"mt-1 block truncate text-[13px] leading-[1.35]",
isDark
? isActive
? "text-[#d6eaff]"
: "text-[#9ebfe2]"
: "text-muted-foreground",
)}
>
{agent.description ? desensitize(agent.description) : "智能体助手"} {agent.description ? desensitize(agent.description) : "智能体助手"}
</span> </span>
</span> </span>
{isActive && ( {isActive && (
<span className="absolute top-1/2 right-1 h-6 w-1 -translate-y-1/2 rounded-full bg-primary" /> <span
className={cn(
"absolute top-1/2 right-1.5 h-8 w-1.5 -translate-y-1/2 rounded-full",
isDark
? "bg-[#2b8ffe] shadow-[0_0_14px_rgba(43,143,254,0.85)]"
: "bg-primary",
)}
/>
)} )}
</button> </button>
); );
@@ -1,7 +1,20 @@
"use client"; "use client";
import { DropletIcon, LayoutGridIcon, LayoutIcon, ListIcon, MoonIcon, PanelLeftIcon, SunIcon } from "lucide-react"; import {
import { useMemo, useState, type ComponentType, type SVGProps } from "react"; ArrowDown,
ArrowUp,
DropletIcon,
ExternalLink,
LayoutGridIcon,
LayoutIcon,
ListIcon,
MoonIcon,
PanelLeftIcon,
Plus,
SunIcon,
Trash2,
} from "lucide-react";
import { useEffect, useMemo, useState, type ComponentType, type SVGProps } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -9,6 +22,7 @@ import { useTheme } from "@/contexts/ThemeContext";
import { FONT_SCALE_OPTIONS } from "@/components/font-scale"; import { FONT_SCALE_OPTIONS } from "@/components/font-scale";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -32,7 +46,17 @@ import {
useUpdateAppearanceSettings, useUpdateAppearanceSettings,
useUpdateCitationDisplaySettings, useUpdateCitationDisplaySettings,
} from "@/core/system-settings/hooks"; } from "@/core/system-settings/hooks";
import type {
CompactShortcutButton,
CompactShortcutRouteParam,
} from "@/core/system-settings/api";
import { cn } from "@/lib/utils"; 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 PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage"; import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
@@ -46,6 +70,97 @@ const languageOptions: { value: Locale; label: string }[] = [
{ value: "zh-CN", label: zhCN.locale.localName }, { value: "zh-CN", label: zhCN.locale.localName },
]; ];
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;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 }) { export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const { t, locale, changeLocale } = useI18n(); const { t, locale, changeLocale } = useI18n();
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
@@ -55,6 +170,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const isAdmin = getStoredAuth()?.system_role === "admin"; const isAdmin = getStoredAuth()?.system_role === "admin";
const { data: appearance } = useAppearanceSettings(); const { data: appearance } = useAppearanceSettings();
const updateAppearance = useUpdateAppearanceSettings(); const updateAppearance = useUpdateAppearanceSettings();
const [shortcutDraft, setShortcutDraft] = useState<CompactShortcutButton[]>([]);
const { data: citationDisplay } = useCitationDisplaySettings(); const { data: citationDisplay } = useCitationDisplaySettings();
const updateCitationDisplay = useUpdateCitationDisplaySettings(); const updateCitationDisplay = useUpdateCitationDisplaySettings();
const globalDefault = appearance?.default_theme ?? "light"; const globalDefault = appearance?.default_theme ?? "light";
@@ -66,6 +182,12 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const cleanupWords = const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? []; citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
useEffect(() => {
setShortcutDraft(
normalizeShortcutDraft(appearance?.compact_shortcut_buttons),
);
}, [appearance?.compact_shortcut_buttons]);
// 独立登录会话(本标签页):开关切换鉴权存储 localStorage ↔ sessionStorage(见 scoped-storage)。 // 独立登录会话(本标签页):开关切换鉴权存储 localStorage ↔ sessionStorage(见 scoped-storage)。
const [isolatedSession, setIsolatedSessionState] = useState(() => isIsolatedSession()); const [isolatedSession, setIsolatedSessionState] = useState(() => isIsolatedSession());
const handleToggleIsolatedSession = (checked: boolean) => { const handleToggleIsolatedSession = (checked: boolean) => {
@@ -136,6 +258,102 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
} }
}; };
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( const themeOptions = useMemo(
() => [ () => [
{ {
@@ -261,6 +479,40 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
</> </>
)} )}
{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 && ( {isAdmin && (
<> <>
<Separator /> <Separator />
@@ -518,6 +770,298 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
); );
} }
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({ function ThemePreviewCard({
icon: Icon, icon: Icon,
label, label,
@@ -279,12 +279,31 @@ export async function updatePublishSquares(
export type ThemeMode = "light" | "dark" | "lightblue"; export type ThemeMode = "light" | "dark" | "lightblue";
export type ViewMode = "card" | "list"; export type ViewMode = "card" | "list";
export type LayoutMode = "full" | "compact"; export type LayoutMode = "full" | "compact";
export type CompactShortcutParamType = "login" | "theme";
export interface CompactShortcutRouteParam {
key: string;
paramType: CompactShortcutParamType;
source?: string | null;
customValue?: string | null;
themeValue?: string | null;
}
export interface CompactShortcutButton {
id: string;
label: string;
url: string;
route_params: CompactShortcutRouteParam[];
enabled: boolean;
sort_order: number;
}
export interface AppearanceSettings { export interface AppearanceSettings {
default_theme: ThemeMode; default_theme: ThemeMode;
default_skill_view: ViewMode; default_skill_view: ViewMode;
default_agent_view: ViewMode; default_agent_view: ViewMode;
default_layout_mode: LayoutMode; default_layout_mode: LayoutMode;
compact_shortcut_buttons: CompactShortcutButton[];
} }
export interface AppearanceUpdate { export interface AppearanceUpdate {
@@ -292,6 +311,7 @@ export interface AppearanceUpdate {
default_skill_view?: ViewMode; default_skill_view?: ViewMode;
default_agent_view?: ViewMode; default_agent_view?: ViewMode;
default_layout_mode?: LayoutMode; default_layout_mode?: LayoutMode;
compact_shortcut_buttons?: CompactShortcutButton[];
} }
export async function loadAppearanceSettings(): Promise<AppearanceSettings> { export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
@@ -450,7 +450,7 @@ function AIWritingBody({
{showSidebar && ( {showSidebar && (
<div <div
className={cn( className={cn(
'relative h-full shrink-0 overflow-hidden border-r border-slate-100 bg-white text-slate-700 shadow-[1px_0_18px_rgba(15,23,42,0.03)] transition-[width] duration-300 ease-in-out', 'relative h-full shrink-0 overflow-hidden border-r border-slate-100 bg-white text-slate-700 shadow-[1px_0_18px_rgba(15,23,42,0.03)] transition-[width] duration-300 ease-in-out dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80 dark:text-[#c5cfe0] dark:shadow-none dark:backdrop-blur-md',
sidebarCollapsed ? 'w-11' : 'w-[320px]', sidebarCollapsed ? 'w-11' : 'w-[320px]',
)} )}
> >
@@ -465,11 +465,11 @@ function AIWritingBody({
onClick={() => setSidebarCollapsed(false)} onClick={() => setSidebarCollapsed(false)}
title="展开侧栏" title="展开侧栏"
aria-label="展开侧栏" aria-label="展开侧栏"
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700" className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700 dark:text-[#8aa0c4] dark:hover:bg-white/10 dark:hover:text-[#e8edf5]"
> >
<PanelLeftOpen className="size-4" /> <PanelLeftOpen className="size-4" />
</button> </button>
<History className="size-4 text-slate-300" /> <History className="size-4 text-slate-300 dark:text-[#6e88b0]" />
</div> </div>
<aside <aside
className={cn( className={cn(
@@ -477,8 +477,8 @@ function AIWritingBody({
sidebarCollapsed ? 'pointer-events-none -translate-x-4 opacity-0' : 'translate-x-0 opacity-100', sidebarCollapsed ? 'pointer-events-none -translate-x-4 opacity-0' : 'translate-x-0 opacity-100',
)} )}
> >
<div className="flex shrink-0 items-center gap-2 border-b border-slate-100 px-3 py-2"> <div className="flex shrink-0 items-center gap-2 border-b border-slate-100 px-3 py-2 dark:border-[#2b8ffe]/20">
<div className="grid flex-1 grid-cols-2 rounded-lg bg-slate-100 p-1"> <div className="grid flex-1 grid-cols-2 rounded-lg bg-slate-100 p-1 dark:bg-white/10">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -489,8 +489,8 @@ function AIWritingBody({
className={cn( className={cn(
'rounded-md px-2 py-1.5 text-xs font-semibold transition-colors', 'rounded-md px-2 py-1.5 text-xs font-semibold transition-colors',
sidebarTab === 'agents' sidebarTab === 'agents'
? 'bg-white text-slate-900 shadow-sm' ? 'bg-white text-slate-900 shadow-sm dark:bg-[#122347]/90 dark:text-[#e8edf5] dark:shadow-none'
: 'text-slate-500 hover:text-slate-800', : 'text-slate-500 hover:text-slate-800 dark:text-[#8aa0c4] dark:hover:bg-white/10 dark:hover:text-[#e8edf5]',
)} )}
> >
课题研究 课题研究
@@ -505,8 +505,8 @@ function AIWritingBody({
className={cn( className={cn(
'rounded-md px-2 py-1.5 text-xs font-semibold transition-colors', 'rounded-md px-2 py-1.5 text-xs font-semibold transition-colors',
sidebarTab === 'research' sidebarTab === 'research'
? 'bg-white text-slate-900 shadow-sm' ? 'bg-white text-slate-900 shadow-sm dark:bg-[#122347]/90 dark:text-[#e8edf5] dark:shadow-none'
: 'text-slate-500 hover:text-slate-800', : 'text-slate-500 hover:text-slate-800 dark:text-[#8aa0c4] dark:hover:bg-white/10 dark:hover:text-[#e8edf5]',
)} )}
> >
ai写作 ai写作
@@ -517,7 +517,7 @@ function AIWritingBody({
onClick={() => setSidebarCollapsed(true)} onClick={() => setSidebarCollapsed(true)}
title="收起侧栏" title="收起侧栏"
aria-label="收起侧栏" aria-label="收起侧栏"
className="rounded-md p-1 text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700" className="rounded-md p-1 text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700 dark:text-[#8aa0c4] dark:hover:bg-white/10 dark:hover:text-[#e8edf5]"
> >
<PanelLeftClose className="size-4" /> <PanelLeftClose className="size-4" />
</button> </button>
@@ -594,7 +594,7 @@ function AIWritingBody({
</div> </div>
)} )}
{agentsActive && ( {agentsActive && (
<div className="absolute inset-0 bg-background"> <div className="workspace-chat-bg absolute inset-0">
<AIWritingAgentRoutes <AIWritingAgentRoutes
base={routeBase} base={routeBase}
compactAppearanceActions={compactAppearanceActions} compactAppearanceActions={compactAppearanceActions}
+2
View File
@@ -27,6 +27,7 @@ import { QuestionNavigator } from "@/components/workspace/question-navigator";
import { ThreadTitle } from "@/components/workspace/thread-title"; import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list"; import { TodoList } from "@/components/workspace/todo-list";
import { import {
CompactShortcutButtons,
LayoutModeSwitcher, LayoutModeSwitcher,
ThemeSwitcher, ThemeSwitcher,
} from "@/components/workspace/appearance-controls"; } from "@/components/workspace/appearance-controls";
@@ -508,6 +509,7 @@ export default function ChatPage() {
与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */} 与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */}
{!compactChrome && ( {!compactChrome && (
<> <>
<CompactShortcutButtons tone="content" />
<ThemeSwitcher tone="content" /> <ThemeSwitcher tone="content" />
<LayoutModeSwitcher tone="content" /> <LayoutModeSwitcher tone="content" />
<div <div
+2
View File
@@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs"; import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs";
import { import {
CompactShortcutButtons,
LayoutModeSwitcher, LayoutModeSwitcher,
ThemeSwitcher, ThemeSwitcher,
} from "@/components/workspace/appearance-controls"; } from "@/components/workspace/appearance-controls";
@@ -183,6 +184,7 @@ export default function ChatsPage({
rightActions={ rightActions={
compactChrome ? ( compactChrome ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<CompactShortcutButtons tone="content" />
<ThemeSwitcher tone="content" /> <ThemeSwitcher tone="content" />
<LayoutModeSwitcher tone="content" /> <LayoutModeSwitcher tone="content" />
</div> </div>
@@ -44,6 +44,7 @@ import {
WorkspaceHeader, WorkspaceHeader,
} from "@/components/workspace/workspace-container"; } from "@/components/workspace/workspace-container";
import { import {
CompactShortcutButtons,
LayoutModeSwitcher, LayoutModeSwitcher,
ThemeSwitcher, ThemeSwitcher,
} from "@/components/workspace/appearance-controls"; } from "@/components/workspace/appearance-controls";
@@ -502,6 +503,7 @@ export default function ScheduledTasksPage({
rightActions={ rightActions={
compactChrome ? ( compactChrome ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<CompactShortcutButtons tone="content" />
<ThemeSwitcher tone="content" /> <ThemeSwitcher tone="content" />
<LayoutModeSwitcher tone="content" /> <LayoutModeSwitcher tone="content" />
</div> </div>
+18 -1
View File
@@ -6,6 +6,7 @@ import { getStoredAuth } from '@/core/auth';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { PromptInputProvider } from '@/components/ai-elements/prompt-input'; import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
import { import {
CompactShortcutButtons,
LayoutModeSwitcher, LayoutModeSwitcher,
ThemeSwitcher, ThemeSwitcher,
} from '@/components/workspace/appearance-controls'; } from '@/components/workspace/appearance-controls';
@@ -91,6 +92,7 @@ function shouldShowCompactTopActions(pathname: string): boolean {
function CompactWorkspaceTopActions() { function CompactWorkspaceTopActions() {
return ( return (
<div className="z-[60] flex h-12 shrink-0 items-center justify-end gap-2 border-b border-border/70 bg-card/90 px-4 shadow-sm backdrop-blur-md"> <div className="z-[60] flex h-12 shrink-0 items-center justify-end gap-2 border-b border-border/70 bg-card/90 px-4 shadow-sm backdrop-blur-md">
<CompactShortcutButtons tone="content" />
<ThemeSwitcher tone="content" /> <ThemeSwitcher tone="content" />
<LayoutModeSwitcher tone="content" /> <LayoutModeSwitcher tone="content" />
<div className="bg-border/70 mx-1 h-6 w-px shrink-0" aria-hidden /> <div className="bg-border/70 mx-1 h-6 w-px shrink-0" aria-hidden />
@@ -99,6 +101,16 @@ function CompactWorkspaceTopActions() {
); );
} }
function CompactAppearanceActions() {
return (
<>
<CompactShortcutButtons tone="content" />
<ThemeSwitcher tone="content" />
<LayoutModeSwitcher tone="content" />
</>
);
}
export function WorkspaceLayout({ export function WorkspaceLayout({
children, children,
pageSidebarCollapsed, pageSidebarCollapsed,
@@ -342,7 +354,12 @@ export default function WorkspaceRoutes({
element={ element={
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps}>
<ChatRuntime> <ChatRuntime>
<AgentChatPage showAgentList /> <AgentChatPage
showAgentList
compactAppearanceActions={
headerHidden ? <CompactAppearanceActions /> : undefined
}
/>
</ChatRuntime> </ChatRuntime>
</WorkspaceLayout> </WorkspaceLayout>
} }
@@ -4,7 +4,11 @@ import { useLocation, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import "@/components/page-sdebar.css"; import "@/components/page-sdebar.css";
import { LayoutModeSwitcher, ThemeSwitcher } from "@/components/workspace/appearance-controls"; import {
CompactShortcutButtons,
LayoutModeSwitcher,
ThemeSwitcher,
} from "@/components/workspace/appearance-controls";
import { NotificationDialog } from "@/components/workspace/notification-dialog"; import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { getStoredAuth, getAccountDisplayName } from "@/core/auth"; import { getStoredAuth, getAccountDisplayName } from "@/core/auth";
import { isWujieEnv } from "@/core/embed/wujie"; import { isWujieEnv } from "@/core/embed/wujie";
@@ -124,6 +128,7 @@ const Header = ({ collapsed }: HeaderProps) => {
// 右侧操作区(换肤/通知/用户)—— 深浅两套布局共用 // 右侧操作区(换肤/通知/用户)—— 深浅两套布局共用
const actionCluster = ( const actionCluster = (
<> <>
<CompactShortcutButtons tone={isDark ? "banner-dark" : "banner-light"} />
<ThemeSwitcher tone={isDark ? "banner-dark" : "banner-light"} /> <ThemeSwitcher tone={isDark ? "banner-dark" : "banner-light"} />
<LayoutModeSwitcher <LayoutModeSwitcher
@@ -5,6 +5,7 @@ from __future__ import annotations
import logging import logging
from typing import Literal from typing import Literal
from urllib.parse import urlparse from urllib.parse import urlparse
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -13,6 +14,8 @@ from app.gateway.auth.password import hash_password_async
from app.gateway.deps import get_optional_user_from_request from app.gateway.deps import get_optional_user_from_request
from deerflow.config import get_app_config from deerflow.config import get_app_config
from deerflow.config.system_settings import ( from deerflow.config.system_settings import (
AppearanceRouteParam,
CompactShortcutButton,
LeaderboardSettings, LeaderboardSettings,
ModelConcurrencySettings, ModelConcurrencySettings,
PluginDomainAgentMapping, PluginDomainAgentMapping,
@@ -537,6 +540,7 @@ class AppearanceResponse(BaseModel):
default_skill_view: Literal["card", "list"] = Field(..., description="System default skill list display mode — card or list") default_skill_view: Literal["card", "list"] = Field(..., description="System default skill list display mode — card or list")
default_agent_view: Literal["card", "list"] = Field(..., description="System default agent list display mode — card or list") default_agent_view: Literal["card", "list"] = Field(..., description="System default agent list display mode — card or list")
default_layout_mode: Literal["full", "compact"] = Field(..., description="System default interface layout — full (sidebar + header) or compact (hidden)") default_layout_mode: Literal["full", "compact"] = Field(..., description="System default interface layout — full (sidebar + header) or compact (hidden)")
compact_shortcut_buttons: list[CompactShortcutButton] = Field(default_factory=list, description="Shortcut buttons rendered in top bars")
class AppearanceUpdateRequest(BaseModel): class AppearanceUpdateRequest(BaseModel):
@@ -544,6 +548,61 @@ class AppearanceUpdateRequest(BaseModel):
default_skill_view: Literal["card", "list"] | None = Field(default=None) default_skill_view: Literal["card", "list"] | None = Field(default=None)
default_agent_view: Literal["card", "list"] | None = Field(default=None) default_agent_view: Literal["card", "list"] | None = Field(default=None)
default_layout_mode: Literal["full", "compact"] | None = Field(default=None) default_layout_mode: Literal["full", "compact"] | None = Field(default=None)
compact_shortcut_buttons: list[CompactShortcutButton] | None = Field(default=None)
def _normalize_compact_shortcut_buttons(
buttons: list[CompactShortcutButton] | None,
) -> list[CompactShortcutButton]:
normalized: list[CompactShortcutButton] = []
seen_ids: set[str] = set()
for index, item in enumerate(buttons or []):
label = (item.label or "").strip() or "快捷入口"
url = (item.url or "").strip()
row_id = (item.id or "").strip() or uuid4().hex
if row_id in seen_ids:
row_id = uuid4().hex
seen_ids.add(row_id)
route_params: list[AppearanceRouteParam] = []
for param in item.route_params or []:
key = (param.key or "").strip()
if not key:
continue
if param.paramType == "theme":
route_params.append(
AppearanceRouteParam(
key=key,
paramType="theme",
themeValue=(param.themeValue or "").strip()
or "red=red;blue=blue;dark-blue=dark-blue",
)
)
continue
source = (param.source or "access_token").strip() or "access_token"
route_params.append(
AppearanceRouteParam(
key=key,
paramType="login",
source=source,
customValue=(param.customValue or "").strip()
if source == "other"
else None,
)
)
normalized.append(
CompactShortcutButton(
id=row_id,
label=label,
url=url,
route_params=route_params,
enabled=bool(item.enabled),
sort_order=index,
)
)
return normalized
def _appearance_response(appearance) -> AppearanceResponse: def _appearance_response(appearance) -> AppearanceResponse:
@@ -552,6 +611,10 @@ def _appearance_response(appearance) -> AppearanceResponse:
default_skill_view=appearance.default_skill_view, default_skill_view=appearance.default_skill_view,
default_agent_view=appearance.default_agent_view, default_agent_view=appearance.default_agent_view,
default_layout_mode=appearance.default_layout_mode, default_layout_mode=appearance.default_layout_mode,
compact_shortcut_buttons=sorted(
appearance.compact_shortcut_buttons,
key=lambda item: item.sort_order,
),
) )
@@ -574,6 +637,10 @@ async def update_appearance_settings(
for key in ("default_theme", "default_skill_view", "default_agent_view", "default_layout_mode"): for key in ("default_theme", "default_skill_view", "default_agent_view", "default_layout_mode"):
if key in fields and fields[key] is not None: if key in fields and fields[key] is not None:
update[key] = fields[key] update[key] = fields[key]
if "compact_shortcut_buttons" in fields and body.compact_shortcut_buttons is not None:
update["compact_shortcut_buttons"] = _normalize_compact_shortcut_buttons(
body.compact_shortcut_buttons
)
if update: if update:
updated_appearance = current.appearance.model_copy(update=update) updated_appearance = current.appearance.model_copy(update=update)
saved = save_system_settings( saved = save_system_settings(
@@ -18,6 +18,7 @@ import logging
import threading import threading
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
from uuid import uuid4
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -38,6 +39,60 @@ class PromptPrefixSettings(BaseModel):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
class AppearanceRouteParam(BaseModel):
"""Runtime query-param descriptor for top-bar shortcut buttons.
The frontend resolves login params from ``userInfo`` and theme params from
the current UI theme, matching the light-app center's link builder.
"""
key: str = Field(default="", description="Query parameter name appended to the shortcut URL")
paramType: Literal["login", "theme"] = Field(default="login", description="Where the parameter value comes from")
source: str | None = Field(default=None, description="login param source field in userInfo, or 'other'")
customValue: str | None = Field(default=None, description="Literal value when source is 'other'")
themeValue: str | None = Field(default=None, description="Theme-value mapping string, e.g. red=red;dark-blue=dark")
model_config = ConfigDict(extra="ignore")
def _default_shortcut_route_params() -> list[AppearanceRouteParam]:
return [
AppearanceRouteParam(
key="access_token",
paramType="login",
source="access_token",
customValue="",
themeValue="red=red;blue=blue;dark-blue=dark-blue",
)
]
class CompactShortcutButton(BaseModel):
"""Configurable shortcut shown in top bars before the theme switcher."""
id: str = Field(default_factory=lambda: uuid4().hex, description="Stable row id for editing")
label: str = Field(default="快捷入口", description="Button text shown next to the icon")
url: str = Field(default="", description="Target URL opened in a new tab")
route_params: list[AppearanceRouteParam] = Field(
default_factory=_default_shortcut_route_params,
description="Query parameters resolved at launch time and appended to url",
)
enabled: bool = Field(default=True, description="Whether this shortcut is rendered")
sort_order: int = Field(default=0, description="Display order in top bars")
model_config = ConfigDict(extra="ignore")
def _default_compact_shortcut_buttons() -> list[CompactShortcutButton]:
return [
CompactShortcutButton(
id="default-compact-shortcut",
label="快捷入口",
url="",
enabled=True,
sort_order=0,
)
]
class AppearanceSettings(BaseModel): class AppearanceSettings(BaseModel):
"""System-wide appearance defaults that any *new* user inherits. """System-wide appearance defaults that any *new* user inherits.
@@ -63,6 +118,10 @@ class AppearanceSettings(BaseModel):
default="full", default="full",
description="Default interface layout for users who haven't picked their own — full (sidebar + header) / compact (hidden)", description="Default interface layout for users who haven't picked their own — full (sidebar + header) / compact (hidden)",
) )
compact_shortcut_buttons: list[CompactShortcutButton] = Field(
default_factory=_default_compact_shortcut_buttons,
description="Shortcut buttons rendered before the theme switcher in top bars",
)
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")