feat(layout): 简洁模式右上角加换肤 + 界面模式下拉切换
抽出共享 appearance-controls(ThemeSwitcher 按 tone 适配 banner/content 配色,新增图标+下拉的 LayoutModeSwitcher);Header 复用换肤组件,简洁模式 ChatPage 顶栏右上角补上换肤与界面模式切换,与全量模式保持一致。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
548cfd1719
commit
79f1a004a0
3 files changed
+191
-91
No files matched your search
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { Check, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
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 { useLayoutMode } from "@/core/system-settings/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* 换肤 / 界面模式 顶栏控件。深色顶栏(Header)与简洁模式页面级顶栏(ChatPage 等)共用,
|
||||
* 通过 `tone` 适配不同底色:
|
||||
* - "banner-dark" / "banner-light":Header 落在主题 banner 上(白字 / 浅色 hover)。
|
||||
* - "content":简洁模式聊天页浅底顶栏,走 foreground / muted-foreground。
|
||||
*/
|
||||
export type AppearanceControlTone = "banner-dark" | "banner-light" | "content";
|
||||
|
||||
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),深蓝色=深色主题(dark)
|
||||
export const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [
|
||||
{ id: "light", label: "红色", color: "#cf1322" },
|
||||
{ id: "lightblue", label: "蓝色", color: "#0062d9" },
|
||||
{ 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]"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground";
|
||||
|
||||
export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const cancelClose = () => {
|
||||
if (closeTimer.current) {
|
||||
clearTimeout(closeTimer.current);
|
||||
closeTimer.current = null;
|
||||
}
|
||||
};
|
||||
const scheduleClose = () => {
|
||||
cancelClose();
|
||||
closeTimer.current = setTimeout(() => setOpen(false), 120);
|
||||
};
|
||||
|
||||
useEffect(() => () => cancelClose(), []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
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 items-center gap-2 rounded-lg border border-black/5 bg-white 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-black/5 bg-white" />
|
||||
{THEME_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,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 界面模式(全量 / 简洁)下拉切换。简洁模式下全局顶栏被隐藏,故把外观设置里的
|
||||
* 「界面模式」切换也搬到页面级顶栏右上角,图标 + 下拉,与换肤并排。
|
||||
*/
|
||||
export function LayoutModeSwitcher({ tone }: { tone: AppearanceControlTone }) {
|
||||
const [mode, setLayoutMode] = useLayoutMode();
|
||||
// 未显式设置过时简洁模式才会渲染本控件(全量模式有全局顶栏),故当前态按 compact 兜底。
|
||||
const current = mode === "full" ? "full" : "compact";
|
||||
const TriggerIcon = current === "full" ? PanelLeftIcon : LayoutIcon;
|
||||
|
||||
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="界面模式"
|
||||
>
|
||||
<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 = current === option.id;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
onClick={() => setLayoutMode(option.id)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,10 @@ import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { QuestionNavigator } from "@/components/workspace/question-navigator";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import {
|
||||
LayoutModeSwitcher,
|
||||
ThemeSwitcher,
|
||||
} from "@/components/workspace/appearance-controls";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
@@ -484,8 +488,12 @@ export default function ChatPage() {
|
||||
<ArtifactTrigger />
|
||||
{/* 不带全局顶栏(紧凑模式)时,名称+岗位回落到聊天页顶栏右侧,
|
||||
与全量模式 Header 右上角保持一致。全量模式已有顶栏则不重复展示。 */}
|
||||
{/* 简洁模式无全局顶栏:把换肤 + 界面模式切换搬到页面级顶栏右上角,
|
||||
与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */}
|
||||
{!compactChrome && (
|
||||
<>
|
||||
<ThemeSwitcher tone="content" />
|
||||
<LayoutModeSwitcher tone="content" />
|
||||
<div
|
||||
className="bg-border/70 mx-1 h-6 w-px shrink-0"
|
||||
aria-hidden
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Bell, ChevronDown, FileText, Shirt, User } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Bell, ChevronDown, FileText, User } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import "@/components/page-sdebar.css";
|
||||
import { ThemeSwitcher } from "@/components/workspace/appearance-controls";
|
||||
import { NotificationDialog } from "@/components/workspace/notification-dialog";
|
||||
import { getStoredAuth, getAccountDisplayName } from "@/core/auth";
|
||||
import { isWujieEnv } from "@/core/embed/wujie";
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useTheme, type ThemeType } from "../contexts/ThemeContext";
|
||||
import { useTheme } from "../contexts/ThemeContext";
|
||||
|
||||
const iconButtonClass = (isDark: boolean) =>
|
||||
cn(
|
||||
@@ -32,93 +33,6 @@ const iconButtonClass = (isDark: boolean) =>
|
||||
const LEFT_MENU_IDS = ["qa-center", "task-management", "light-app-center"];
|
||||
const RIGHT_MENU_IDS = ["comprehensive-management", "resource-management"];
|
||||
|
||||
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),深蓝色=深色主题(dark)
|
||||
const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [
|
||||
{ id: "light", label: "红色", color: "#cf1322" },
|
||||
{ id: "lightblue", label: "蓝色", color: "#0062d9" },
|
||||
{ id: "dark", label: "深蓝色", color: "#002466" },
|
||||
];
|
||||
|
||||
function ThemeSwitcher({ isDark }: { isDark: boolean }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const cancelClose = () => {
|
||||
if (closeTimer.current) {
|
||||
clearTimeout(closeTimer.current);
|
||||
closeTimer.current = null;
|
||||
}
|
||||
};
|
||||
const scheduleClose = () => {
|
||||
cancelClose();
|
||||
closeTimer.current = setTimeout(() => setOpen(false), 120);
|
||||
};
|
||||
|
||||
useEffect(() => () => cancelClose(), []);
|
||||
|
||||
// 切换按钮:悬浮只改背景,不改图标颜色(显式锁定 hover 文字色等于常态色)
|
||||
const triggerClass = cn(
|
||||
"size-8 shrink-0 rounded-sm",
|
||||
isDark
|
||||
? "text-slate-400 hover:bg-slate-800/50 hover:text-slate-400"
|
||||
: "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]",
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => {
|
||||
cancelClose();
|
||||
setOpen(true);
|
||||
}}
|
||||
onMouseLeave={scheduleClose}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={triggerClass}
|
||||
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 items-center gap-2 rounded-lg border border-black/5 bg-white 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-black/5 bg-white" />
|
||||
{THEME_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>
|
||||
);
|
||||
}
|
||||
|
||||
type HeaderProps = {
|
||||
collapsed?: boolean;
|
||||
};
|
||||
@@ -210,7 +124,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
// 右侧操作区(换肤/通知/用户)—— 深浅两套布局共用
|
||||
const actionCluster = (
|
||||
<>
|
||||
<ThemeSwitcher isDark={isDark} />
|
||||
<ThemeSwitcher tone={isDark ? "banner-dark" : "banner-light"} />
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
Reference in new issue
Block a user