289 lines
10 KiB
TypeScript
289 lines
10 KiB
TypeScript
"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>
|
||
);
|
||
}
|