Files
deerflow-code/frontend-web/src/components/workspace/appearance-controls.tsx
T
2026-09-06 20:49:35 +08:00

289 lines
10 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 { Check, ExternalLink, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import type { CompactShortcutRouteParam } from "@/core/system-settings/api";
import { useAppearanceSettings, useLayoutMode } from "@/core/system-settings/hooks";
import { isHideChromeActive } from "@/core/embed/embed-session";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { buildLightAppUrl, resolveRouteParamValue } from "@/strategy-components/lib/light-app";
/**
* 换肤 / 界面模式 顶栏控件。深色顶栏(Header)与简洁模式页面级顶栏(ChatPage 等)共用,
* 通过 `tone` 适配不同底色:
* - "banner-dark" / "banner-light":Header 落在主题 banner 上(白字 / 浅色 hover)。
* - "content":简洁模式聊天页浅底顶栏,走 foreground / muted-foreground。
*/
export type AppearanceControlTone = "banner-dark" | "banner-light" | "banner-custom" | "content";
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),冰川蓝=柔和浅蓝主题(iceblue),浅黄色=柔和黄主题(lightyellow),深蓝色=深色主题(dark)
export const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [
{ id: "light", label: "红色", color: "#cf1322" },
{ id: "lightgreen", label: "绿色", color: "#07c05f" },
{ id: "lightblue", label: "蓝色", color: "#0062d9" },
{ id: "iceblue", label: "冰川蓝", color: "#9BCEF1" },
{ id: "lightyellow", label: "浅黄色", color: "#F3FBD9" },
{ id: "dark", label: "深蓝色", color: "#002466" },
];
const triggerToneClass = (tone: AppearanceControlTone) =>
tone === "banner-dark"
? "text-slate-400 hover:bg-slate-800/50 hover:text-slate-400"
: tone === "banner-light"
? "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]"
: tone === "banner-custom"
? "text-[var(--navigation-foreground)] hover:bg-white/20 hover:text-[var(--navigation-foreground)] active:bg-white/30"
: "text-muted-foreground hover:bg-muted hover:text-foreground";
function appendShortcutPathParams(url: string, params: CompactShortcutRouteParam[]) {
const pathSegments = params
.filter((param) => param.paramType === "path")
.map((param) =>
resolveRouteParamValue({ ...param, paramType: "login" }).trim(),
)
.filter(Boolean)
.map((value) => encodeURIComponent(value));
if (pathSegments.length === 0) return url;
const hashIndex = url.indexOf("#");
if (hashIndex < 0) {
const [path, query = ""] = url.split("?", 2);
return `${path.replace(/\/+$/, "")}/${pathSegments.join("/")}${query ? `?${query}` : ""}`;
}
const beforeHash = url.slice(0, hashIndex);
const fragment = url.slice(hashIndex + 1);
const [routePath, query = ""] = fragment.split("?", 2);
return `${beforeHash}#${routePath.replace(/\/+$/, "")}/${pathSegments.join("/")}${query ? `?${query}` : ""}`;
}
function isQueryShortcutParam(
param: CompactShortcutRouteParam,
): param is CompactShortcutRouteParam & { paramType: "login" | "theme" } {
return param.paramType === "login" || param.paramType === "theme";
}
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 routeParams = (button.route_params ?? []).map((param) => ({ ...param }));
const url = buildLightAppUrl({
url: appendShortcutPathParams(button.url, routeParams),
routeParams: routeParams.filter(isQueryShortcutParam),
});
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 }) {
const { theme, setTheme } = useTheme();
const { data: appearance } = useAppearanceSettings();
const [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const swatches = useMemo(
() => [
...THEME_SWATCHES,
...(appearance?.custom_themes ?? [])
.filter((preset) => preset.enabled)
.sort((a, b) => a.sort_order - b.sort_order)
.map((preset) => ({
id: preset.id as ThemeType,
label: preset.name,
color: preset.primary,
})),
],
[appearance?.custom_themes],
);
const cancelClose = () => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
};
const scheduleClose = () => {
cancelClose();
closeTimer.current = setTimeout(() => setOpen(false), 120);
};
useEffect(() => () => cancelClose(), []);
if (env.FORCE_COLLAPSED_SIDEBAR || isHideChromeActive()) return null;
return (
<div
className="relative"
data-compact-guide="theme-switcher"
onMouseEnter={() => {
cancelClose();
setOpen(true);
}}
onMouseLeave={scheduleClose}
>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn("size-8 shrink-0 rounded-sm", triggerToneClass(tone))}
title="换肤"
aria-haspopup="true"
aria-expanded={open}
>
<Shirt className="size-4" />
</Button>
{open ? (
<div className="absolute top-full left-1/2 z-50 -translate-x-1/2 pt-2">
<div className="relative flex max-w-64 flex-wrap items-center justify-center gap-2 rounded-lg border border-border bg-popover px-2.5 py-1.5 shadow-lg">
<div className="absolute -top-1 left-1/2 size-2 -translate-x-1/2 rotate-45 rounded-[1px] border-t border-l border-border bg-popover" />
{swatches.map((swatch) => {
const active = theme === swatch.id;
return (
<button
key={swatch.id}
type="button"
title={swatch.label}
aria-label={swatch.label}
aria-pressed={active}
onClick={() => {
setTheme(swatch.id);
setOpen(false);
}}
className="size-5 rounded-full transition-transform hover:scale-110"
style={{
backgroundColor: swatch.color,
boxShadow: active
? `0 0 0 1.5px #ffffff, 0 0 0 3px ${swatch.color}`
: undefined,
}}
/>
);
})}
</div>
</div>
) : null}
</div>
);
}
const LAYOUT_OPTIONS = [
{
id: "full" as const,
label: "全量模式",
description: "展示侧边栏与顶栏导航",
icon: PanelLeftIcon,
},
{
id: "compact" as const,
label: "简洁模式",
description: "隐藏侧边栏与顶栏,获得更大工作区",
icon: LayoutIcon,
},
];
/**
* 界面模式(全量 / 简洁)下拉切换。换肤旁并排,图标 + 下拉:
* - 简洁模式 ChatPage 顶栏(全局顶栏被隐藏)。
* - 全量模式 Header 右上角。
* 当前态为有效界面模式(用户自选 > 管理员系统默认 > 内置简洁默认)——
* useLayoutMode 总会返回具体值,不再有 undefined 兜底。
*/
export function LayoutModeSwitcher({
tone,
navigateTo = "/page/workspace/chats/new",
}: {
tone: AppearanceControlTone;
navigateTo?: string | null;
}) {
const [mode, setLayoutMode] = useLayoutMode();
const navigate = useNavigate();
const TriggerIcon = mode === "full" ? PanelLeftIcon : LayoutIcon;
if (env.FORCE_COLLAPSED_SIDEBAR || isHideChromeActive()) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn("size-8 shrink-0 rounded-sm", triggerToneClass(tone))}
title="界面模式"
aria-label="界面模式"
data-compact-guide="layout-mode"
>
<TriggerIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8} className="min-w-56 rounded-lg">
<DropdownMenuLabel>界面模式</DropdownMenuLabel>
<DropdownMenuSeparator />
{LAYOUT_OPTIONS.map((option) => {
const Icon = option.icon;
const active = mode === option.id;
return (
<DropdownMenuItem
key={option.id}
onClick={() => {
setLayoutMode(option.id);
if (navigateTo) {
navigate(navigateTo);
}
}}
className="gap-2"
>
<Icon className="size-4 shrink-0" />
<div className="flex min-w-0 flex-col">
<span className="text-sm leading-tight">{option.label}</span>
<span className="text-muted-foreground text-xs leading-tight">
{option.description}
</span>
</div>
{active && <Check className="ml-auto size-4 shrink-0" />}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}