* feat(canvas): 设计token系统 - 建立三层架构骨架 #94 #96 建立 Primitive→Semantic→Component 三层设计 token 体系,覆盖 9 个维度: 色彩/间距/字号/圆角/阴影/Elevation/动效/描边/不透明度。 - Primitive 层:纯值无语义,含 neutral 色板+节点类型色板+状态色、 4px 栅格间距、10档字号、10档圆角、阴影+glow、12档 z-index、 动效(duration+easing+delay+motion-scale)、描边、不透明度 - Semantic 层:引用 Primitive,随主题切换,含通用语义色、 Canvas 语义(--cn-*)、Aceternity spatial 语义、节点类型语义、状态语义 - Component 层:引用 Semantic,含 Dock/Node/Modal/Panel/Prompt面板/ 进度条/缩放控制专属 token - @theme inline 映射:节点类型色、状态色、Canvas 语义色、字号映射到 Tailwind utility - prefers-reduced-motion 三级降级(L0正常/L1简化/L2禁用) - AGENTS.md 新增 Design Token 使用规范,禁止新增裸 px 值 - 附带修复 canvas-drawing-excalidraw-editor.tsx TS7006 类型注解 - 附带修复 canvas-drawing-editor-modal.tsx TS2339 engine 字段缺失 Wayfinder 决策来源:#95 #96 #97 #98 #99 * fix(canvas): 设计token系统 - 修复CSS变量循环引用和命名兼容性 #94 #96 修复两个问题: 1. CSS变量循环引用:Primitive层 --color-node-*/--color-status-* 与 @theme inline 中的 --color-node-*/--color-status-* 同名,经 Semantic 层中转后形成循环(A→B→A)。改用 --palette- 前缀隔离 Primitive 色板, 打断循环链路。 2. 变量名小数点兼容性:--space-0.5/1.5/2.5/3.5 中的小数点被部分 CSS linter 误判为类选择器。改为 --space-half/-1-half/-2-half/-3-half, 保持语义清晰的同时提升工具链兼容性。 * refactor(canvas): 画布核心组件 - 迁移硬编码值到设计token #94 批次1:5个画布核心组件的裸 text-[Npx]/rounded-[Npx]/z-[N] 替换为 对应设计token,统一视觉语言: - canvas-node.tsx:text-[10/11/9/17px]→fs-tiny/label/micro/heading-lg, rounded-[18px]→node-radius - canvas-node-hover-toolbar.tsx:text-[11/17/10/9px]→fs-*, rounded-[14/9px]→dock-radius/dock-item-radius,z-[70]→z-panel-floating - canvas-toolbar.tsx:text-[9/10/11px]→fs-*, rounded-[17/16/11/10px]→panel-radius/dock-item-radius-*, z-[30/40]→z-toolbar/z-toolbar-floating - canvas-assistant-panel.tsx:text-[8/9/10/11px]→fs-* - canvas-script-node.tsx:text-[9/10/11px]→fs-* 视觉零回归(token值与原硬编码值一致),代码可维护性提升。 * refactor(canvas): 画布辅助组件 - 迁移硬编码值到设计token #94 批次2:6个画布辅助组件的裸 text-[Npx]/rounded-[Npx]/z-[N] 替换为 对应设计token: - canvas-style-picker-modal.tsx:text-[10/11px]→fs-tiny/label - canvas-file-drop-overlay.tsx:z-[110]→z-modal-overlay, rounded-[28/20px]→r-5xl/r-3xl - canvas-character-reference-node.tsx:text-[10px]→fs-tiny - canvas-drawing-editor-modal.tsx:text-[11px]→fs-label - canvas-video-prompt-tools.tsx:z-[1500]→z-toast, rounded-[12/10px]→r-lg/dock-item-radius - asset-picker-modal.tsx:text-[10px]→fs-tiny 视觉零回归(token值与原硬编码值一致)。 * refactor(*): 全项目硬编码值 - 批量迁移到设计token #94 批次3:全项目 .tsx 文件中 372 处裸 text-[Npx]/rounded-[Npx] 批量 替换为对应设计token,剩余 25 处无直接 token 对应的低频值保留。 替换覆盖(按频率): - text-[10px]→fs-tiny (132处) - text-[11px]→fs-label (100处) - text-[9px]→fs-micro (45处) - text-[12px]→fs-caption, text-[13/14px]→fs-body, text-[15px]→fs-body-lg - text-[18px]→fs-heading-lg, text-[22px]→fs-title, text-[8px]→fs-micro - rounded-[9/10px]→dock-item-radius, rounded-[12px]→r-lg - rounded-[14px]→dock-radius, rounded-[16px]→r-2xl - rounded-[17px]→panel-radius, rounded-[8px]→r-md 涉及 components/ 和 pages/ 下所有 .tsx 文件。 视觉零回归(token值与原硬编码值一致)。 * feat(canvas): 节点视觉层次规范 - 色带+状态徽章+四态递进 #97 #94 基于 Wayfinder 地图 #97 决议执行节点视觉重设计: 1. 节点类型色带(决策1) - 新增 nodeTypeColorVar() 函数,10种节点类型对应色带色 - 节点左侧3px色条,引用 --cn-type-* token - 远距离最强信号,用户扫视画布即可识别节点类型 2. 节点状态徽章(决策2) - 新增 NodeStatusBadge 组件,loading/success/error 三态 - 左上角圆角徽章,backdrop-blur 玻璃质感 - success 态 2s 后自动淡出(canvas-status-success-fade 动画) 3. 四态递进视觉权重(决策3) - idle:无额外阴影 - hover:轻微抬升阴影(16px 48px) - selected:2px 边框环(accent.primary 40% 透明度) - selected-primary:4px 软色环 + resize handle - active:6px 外发光环 + inner glow(最强强调) - 120-150ms ease-out 过渡 4. 阴影迁移 - boxShadow 从硬编码改为分层语义化表达 - 边框色 hover 态使用 color-mix 动态加深 Closes #97 --------- Co-authored-by: sikongyue <3155157671@qq.com>
137 lines
8.7 KiB
TypeScript
137 lines
8.7 KiB
TypeScript
import { Infinity as InfinityIcon, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
|
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
|
|
import { Link, useLocation, useNavigate } from "react-router";
|
|
|
|
import { ModelSetupGuide } from "@/components/layout/model-setup-guide";
|
|
import { WorkspaceSidebarFooter } from "@/components/layout/workspace-sidebar-footer";
|
|
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
|
|
import { cn } from "@/lib/utils";
|
|
import { isSpatialWorkbenchPath } from "@/lib/workspace-routes";
|
|
|
|
export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
|
const { pathname } = useLocation();
|
|
const navigate = useNavigate();
|
|
const [mobileSidebarExpanded, setMobileSidebarExpanded] = useState(false);
|
|
const scrollRef = useRef<HTMLElement>(null);
|
|
const [scrollState, setScrollState] = useState({
|
|
hasTopFade: false,
|
|
hasBottomFade: false,
|
|
});
|
|
const hideChrome = pathname.startsWith("/admin") || /^\/canvas\/[^/]+/.test(pathname);
|
|
const spatialWorkbench = isSpatialWorkbenchPath(pathname);
|
|
const creationWorkspace = pathname === "/create";
|
|
const slug = pathname.split("/").filter(Boolean)[0];
|
|
const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined;
|
|
const visibleNavigationTools = spatialWorkbench ? navigationTools : navigationTools.filter((tool) => tool.section === "创作空间");
|
|
|
|
const handleScroll = () => {
|
|
const element = scrollRef.current;
|
|
if (!element) return;
|
|
const { scrollTop, scrollHeight, clientHeight } = element;
|
|
setScrollState({
|
|
hasTopFade: scrollTop > 0,
|
|
hasBottomFade: scrollTop + clientHeight < scrollHeight - 1,
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
const handleWorkspaceNavigation = (rawEvent: Event) => {
|
|
const event = rawEvent as CustomEvent<{ to?: string }>;
|
|
if (!event.detail?.to) return;
|
|
event.preventDefault();
|
|
navigate(event.detail.to);
|
|
};
|
|
window.addEventListener("workspace:navigate", handleWorkspaceNavigation);
|
|
return () => window.removeEventListener("workspace:navigate", handleWorkspaceNavigation);
|
|
}, [navigate]);
|
|
|
|
useEffect(() => {
|
|
handleScroll();
|
|
}, [visibleNavigationTools.length, mobileSidebarExpanded]);
|
|
|
|
return (
|
|
<>
|
|
<div className={cn("app-workspace-shell flex h-dvh min-h-0 w-full overflow-hidden", spatialWorkbench && "is-spatial", creationWorkspace && "is-creation-workspace")}>
|
|
{!hideChrome && mobileSidebarExpanded ? <button type="button" className="app-workspace-sidebar-scrim lg:hidden" aria-label="收起侧栏" onClick={() => setMobileSidebarExpanded(false)} /> : null}
|
|
{!hideChrome ? (
|
|
<aside className={cn("app-workspace-sidebar flex shrink-0 flex-col overflow-hidden transition-all duration-200", mobileSidebarExpanded ? "is-mobile-expanded w-[196px]" : "w-0 lg:w-[88px] lg:shrink-0")}>
|
|
<div
|
|
className={cn(
|
|
"flex h-14 shrink-0 items-center border-b border-border/55 text-foreground",
|
|
mobileSidebarExpanded ? "gap-2 px-3" : "justify-center",
|
|
"lg:justify-center lg:px-0",
|
|
)}
|
|
>
|
|
<Link to="/" className={cn("min-w-0 items-center gap-2", mobileSidebarExpanded || spatialWorkbench ? "flex" : "hidden", "lg:flex")} title="影策">
|
|
<span className="app-workspace-brand-mark grid size-7 shrink-0 place-items-center rounded-md bg-foreground text-background"><InfinityIcon className="size-4" /></span>
|
|
{spatialWorkbench ? <span className="min-w-0"><span className="block truncate text-[var(--fs-body)] font-semibold">影策</span><span className="block truncate text-[var(--fs-micro)] text-foreground/36">AI 叙事工作台</span></span> : <span className="truncate text-[var(--fs-body)] font-semibold">影策</span>}
|
|
</Link>
|
|
</div>
|
|
|
|
<nav
|
|
ref={scrollRef}
|
|
onScroll={handleScroll}
|
|
className={cn(
|
|
"app-workspace-sidebar-scroll-area flex min-h-0 flex-1 flex-col px-2 py-3",
|
|
scrollState.hasTopFade && "has-top-fade",
|
|
scrollState.hasBottomFade && "has-bottom-fade",
|
|
)}
|
|
>
|
|
{visibleNavigationTools.map((tool, index) => {
|
|
const Icon = tool.icon;
|
|
const active = tool.slug === activeToolSlug;
|
|
const showSection = index === 0 || tool.section !== visibleNavigationTools[index - 1]?.section;
|
|
return (
|
|
<Fragment key={tool.slug}>
|
|
{showSection ? <div className={cn("mb-2 px-2 text-[var(--fs-tiny)] font-medium text-foreground/34", index > 0 && "mt-4", mobileSidebarExpanded ? "block" : "hidden", "lg:hidden")}>{tool.section}</div> : null}
|
|
<Link
|
|
to={`/${tool.slug}`}
|
|
title={tool.label}
|
|
onClick={() => {
|
|
if (window.innerWidth < 1024) setMobileSidebarExpanded(false);
|
|
}}
|
|
className={cn(
|
|
"app-workspace-nav-link relative mb-1 flex shrink-0 items-center rounded-md text-[var(--fs-body)] transition-colors",
|
|
spatialWorkbench ? "h-11" : "h-9",
|
|
mobileSidebarExpanded ? "gap-3 px-2.5" : "justify-center px-0",
|
|
"lg:justify-center lg:px-0",
|
|
active ? "is-active font-medium" : "text-foreground/55 hover:bg-foreground/[0.045] hover:text-foreground/85",
|
|
)}
|
|
>
|
|
<Icon className="size-4 shrink-0" />
|
|
<span className={cn("truncate", mobileSidebarExpanded ? "inline" : "hidden", "lg:inline")}>{tool.label}</span>
|
|
</Link>
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</nav>
|
|
<div className="shrink-0 border-t border-border/55 p-2">
|
|
<WorkspaceSidebarFooter
|
|
collapsedClassName={cn(
|
|
mobileSidebarExpanded ? "justify-start gap-2 px-2" : "justify-center gap-0 px-0",
|
|
"lg:justify-center lg:gap-0 lg:px-0",
|
|
)}
|
|
expandedClassName={cn(mobileSidebarExpanded ? "flex" : "hidden", "lg:hidden")}
|
|
accountClassName={cn(
|
|
mobileSidebarExpanded ? "flex-row gap-2 px-2" : "flex-col gap-0.5 px-0 py-1",
|
|
"lg:flex-col lg:gap-0.5 lg:px-0 lg:py-1",
|
|
)}
|
|
/>
|
|
</div>
|
|
</aside>
|
|
) : null}
|
|
|
|
<div className="app-workspace-stage relative min-h-0 min-w-0 flex-1 overflow-hidden">
|
|
{!hideChrome ? (
|
|
<button type="button" className="app-workspace-sidebar-toggle absolute left-2 top-2 z-30 grid size-8 place-items-center rounded-md border border-border/60 bg-background/75 text-foreground/80 backdrop-blur transition hover:bg-foreground/[0.06] lg:hidden" aria-label={mobileSidebarExpanded ? "收起侧栏" : "展开侧栏"} onClick={() => setMobileSidebarExpanded((current) => !current)}>
|
|
{mobileSidebarExpanded ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
|
|
</button>
|
|
) : null}
|
|
{children}
|
|
</div>
|
|
</div>
|
|
<ModelSetupGuide hidden={pathname === "/login" || pathname === "/register" || pathname.startsWith("/admin")} />
|
|
</>
|
|
);
|
|
}
|