From 4ad8655a88c72b2cec6e632b523a177616c55afc Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 6 Nov 2026 00:59:51 +0800 Subject: [PATCH] refactor: unify floating toolbar sizing and show graph title --- README.md | 4 +++ VALIDATION.md | 8 ++++++ src/app/dashboard/dashboard.tsx | 9 ++++++- src/app/dashboard/layout.tsx | 15 ++++++----- src/components/canvas/canvas.module.css | 26 +++++++------------ src/components/canvas/graph-canvas.tsx | 9 ++++--- src/components/ui/icon-button.module.css | 7 ++--- src/components/ui/toolbar.module.css | 18 +++++++++++++ src/components/workspace/active-board.tsx | 22 ++++++++++++++++ src/components/workspace/graph-page.tsx | 9 +++++++ src/components/workspace/workspace.module.css | 12 ++++++--- 11 files changed, 106 insertions(+), 33 deletions(-) create mode 100644 src/components/ui/toolbar.module.css create mode 100644 src/components/workspace/active-board.tsx diff --git a/README.md b/README.md index 7dbb49e..d03b6bf 100644 --- a/README.md +++ b/README.md @@ -94,3 +94,7 @@ API 测试使用本机临时后端和独立前端进程,检查会话 Cookie、 - 底部可添加文字、图片、脚本或三节点创作模板。图片支持 PNG/JPEG/WebP/GIF,单张上限 2 MB。 - Ctrl / ⌘ + Z 撤销,Shift + Ctrl / ⌘ + Z 重做;支持 JSON 导入导出和 40 步撤销历史。 - 状态与导入回归:`node --experimental-strip-types --test tests/canvas.test.mjs tests/workspace.test.mjs`。 + +## 悬浮栏尺寸 + +左上角显示当前图谱标题,复用页面已读取的资料,不额外请求后端;长标题省略并可悬停查看。左上、右上、底部与帮助按钮共用 `toolbar.module.css`:图标/Logo/浮框头像 16px,图标按钮 28×24px,主文字 12px,容器内边距 4px,间隙 2px,圆角 8px。悬浮栏距屏幕边缘统一为 12px;右上角窄屏时下移避免与标题重叠。 diff --git a/VALIDATION.md b/VALIDATION.md index f86fc02..3de3912 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -68,3 +68,11 @@ - 13 项自动测试通过:节点位置变化与撤销重做、删除连线清理、文字编辑事务、导入导出、恶意图片与无效图谱拒绝、草稿隔离、历史上限及既有工作空间流程。 - 类型检查、隔离 Webpack 生产构建和完整 API 冒烟回归通过;原有 88 个品牌资源与账户/Foundry 创建接口保持通过。 - 遵循用户不访问浏览器的安排,未做实际浏览器拖动、触控手势和视觉验收;不将状态测试表述为已完成浏览器手感验收。 + +## 悬浮栏标题与尺寸统一(2026-11-06) + +- 当前图谱标题通过共享 ActiveBoardProvider 从图谱页面传到左上悬浮栏,按图谱 ID 与 workspace 校验,避免错显上一张图谱;不增加重复 API 请求,长标题省略并保留完整 title 属性。 +- 统一所有悬浮栏的 surface 样式:16px 图标、28×24px border-box 按钮、12px 字体、4px 内边距、2px 间隙、8px 圆角;浮框头像缩为 16px,账户菜单内头像仍保持原尺寸。 +- 删除 canvas 工具栏的 content-box 与 8px/9px 内边距覆盖,缩放文字和帮助按钮采用同一高度、字号及容器;下方和右侧距屏幕边缘统一 12px。 +- 沿用不访问浏览器的安排,本次验证以类型检查、生产构建和现有接口回归为准,未进行浏览器视觉验收。 +- 本次类型检查、隔离 Webpack 生产构建和完整 API/品牌资源冒烟检查均通过。 diff --git a/src/app/dashboard/dashboard.tsx b/src/app/dashboard/dashboard.tsx index 65ea2ae..8a716a5 100644 --- a/src/app/dashboard/dashboard.tsx +++ b/src/app/dashboard/dashboard.tsx @@ -8,6 +8,8 @@ import { Check, ChevronsUpDown, FilePlus2, Home, Network, PanelLeftClose, PanelL import { IconButton } from "@/components/ui/icon-button"; import { api, errorText, type Profile } from "@/lib/api"; import { boardHref, createBoard, DEFAULT_BOARD_TITLE, loadWorkspaceHome, type Board, type Workspace } from "@/lib/workspace"; +import { useActiveBoard } from "@/components/workspace/active-board"; +import toolbar from "@/components/ui/toolbar.module.css"; import { UserAvatar } from "@/components/workspace/user-avatar"; import { AccountPanel } from "@/components/workspace/account-panel"; import { readDemoBoards } from "@/lib/demo-workspace"; @@ -15,6 +17,7 @@ import styles from "@/components/workspace/workspace.module.css"; export default function DashboardSidebar() { const router = useRouter(); + const { board: activeBoard } = useActiveBoard(); const reduceMotion = useReducedMotion(); const params = useParams<{ workspace?: string; board?: string }>(); const searchParams = useSearchParams(); @@ -128,6 +131,9 @@ export default function DashboardSidebar() { } catch (e) { setError(errorText(e)); } } + const currentTitle = activeBoard && activeBoard.id === params.board && activeBoard.workspace_id === workspace + ? activeBoard.title + : boards.find((board) => board.id === params.board)?.title || (params.board ? "正在读取图谱…" : "工作空间"); const name = profile?.display_name || profile?.username || "账户"; const currentWorkspace = workspaces.find((w) => w.id === workspace); const filtered = boards.filter((b) => b.title.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())); @@ -142,7 +148,7 @@ export default function DashboardSidebar() { <> {collapsed && ( - +

{currentTitle}

diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 5dd8dc0..81cea96 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -1,15 +1,18 @@ import "@xyflow/react/dist/style.css"; import { Suspense, type ReactNode } from "react"; +import { ActiveBoardProvider } from "@/components/workspace/active-board"; import DashboardSidebar from "./dashboard"; import styles from "@/components/workspace/workspace.module.css"; export default function DashboardLayout({ children }: { children: ReactNode }) { return ( -
- }> - - - {children} -
+ +
+ }> + + + {children} +
+
); } diff --git a/src/components/canvas/canvas.module.css b/src/components/canvas/canvas.module.css index 2a459a2..a18ecc7 100644 --- a/src/components/canvas/canvas.module.css +++ b/src/components/canvas/canvas.module.css @@ -10,28 +10,22 @@ .node textarea::placeholder { color: #b2a99e; } .image { display: block; max-width: 100%; max-height: 280px; margin: auto; border-radius: 5px; object-fit: contain; } .node :global(.react-flow__handle) { width: 8px; height: 8px; background: #a47764; border: 2px solid #fff; } -.toolbar { display: flex; align-items: center; gap: 5px; padding: 9px; border: 1px solid #e2dcd3; border-radius: 14px; background: #fffffff5; color: #4b443d; box-shadow: 0 7px 24px #32251a0a, 0 1px 3px #32251a05; backdrop-filter: blur(12px); } -.toolbar button { box-sizing: content-box; padding: 8px; border-radius: 7px; } .toolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; } .toolbar button:disabled { cursor: not-allowed; } -.divider { width: 1px; height: 22px; margin: 0 4px; background: #ece6df; } -.topRight { position: absolute; top: 12px; right: 12px; z-index: 10; display: flex; flex-direction: column; align-items: flex-end; gap: 4px; } -.toolbar .zoomValue { box-sizing: border-box; width: 48px; height: 32px; padding: 0; font-size: 12px; font-variant-numeric: tabular-nums; } +.divider { flex: 0 0 1px; width: 1px; height: 16px; margin: 0 2px; background: #ece6df; } +.topRight { position: absolute; top: 12px; right: 12px; z-index: 10; display: flex; flex-direction: column; align-items: flex-end; gap: 3px; } +.toolbar .zoomValue { box-sizing: border-box; width: 40px; height: 24px; padding: 0; border-radius: 5px; font: inherit; font-variant-numeric: tabular-nums; } +.toolbar .zoomValue:hover { background: #e9e7e3; } .saveStatus { padding-right: 5px; font-size: 10px; color: #a59b90; } -.bottomToolbar { position: absolute; left: 50%; bottom: 18px; transform: translateX(-50%); z-index: 10; } -.helpButton { position: absolute; right: 16px; bottom: 22px; display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid #e2dcd3; border-radius: 50%; background: white; color: #74675c; box-shadow: 0 3px 12px #32251a0a; z-index: 10; } -.helpPopup { position: fixed; inset: auto 16px 68px auto; margin: 0; width: 330px; max-width: calc(100vw - 32px); max-height: calc(100dvh - 110px); overflow-y: auto; padding: 18px; border: 1px solid #e2dcd3; border-radius: 12px; background: white; color: #6d6054; box-shadow: 0 8px 30px #32251a14; font-size: 12px; line-height: 1.7; } +.bottomToolbar { position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); z-index: 10; } +.helpPopup { position: fixed; inset: auto 12px 54px auto; margin: 0; width: 330px; max-width: calc(100vw - 32px); max-height: calc(100dvh - 110px); overflow-y: auto; padding: 18px; border: 1px solid #e2dcd3; border-radius: 12px; background: white; color: #6d6054; box-shadow: 0 8px 30px #32251a14; font-size: 12px; line-height: 1.7; } .helpPopup strong { color: #49372e; font-size: 13px; } .helpPopup p { margin-top: 9px; } -.error { position: absolute; top: 78px; left: 50%; transform: translateX(-50%); max-width: min(500px, calc(100vw - 32px)); padding: 10px 14px; border: 1px solid #e5c7b8; border-radius: 8px; color: #a25439; background: #fff9f3; font-size: 12px; z-index: 15; } +.error { position: absolute; top: 62px; left: 50%; transform: translateX(-50%); max-width: min(500px, calc(100vw - 32px)); padding: 10px 14px; border: 1px solid #e5c7b8; border-radius: 8px; color: #a25439; background: #fff9f3; font-size: 12px; z-index: 15; } +.helpToolbar { position: absolute; right: 12px; bottom: 12px; z-index: 10; } @media (max-width: 700px) { - .topRight { top: 58px; right: 10px; } - .toolbar { padding: 5px; gap: 2px; border-radius: 11px; } - .toolbar button { padding: 6px; } - .divider { margin: 0 2px; } - .bottomToolbar { bottom: 16px; max-width: calc(100vw - 48px); } - .helpButton { right: 7px; bottom: 20px; width: 28px; height: 28px; } - .saveStatus { font-size: 9px; } + .topRight { top: 54px; right: 12px; } + .bottomToolbar { bottom: 12px; max-width: calc(100vw - 68px); } } .handMode .node textarea, .handMode .node img { pointer-events: none; } diff --git a/src/components/canvas/graph-canvas.tsx b/src/components/canvas/graph-canvas.tsx index 19b34a3..fa2bc5a 100644 --- a/src/components/canvas/graph-canvas.tsx +++ b/src/components/canvas/graph-canvas.tsx @@ -7,6 +7,7 @@ import { } from "@xyflow/react"; import { Download, FileText, Hand, HelpCircle, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, Type, Undo2, Upload } from "lucide-react"; import { useReducedMotion } from "motion/react"; +import toolbar from "@/components/ui/toolbar.module.css"; import { IconButton } from "@/components/ui/icon-button"; import { canvasReducer, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode, type CanvasHistory, type CanvasNode } from "@/lib/canvas"; import type { Board } from "@/lib/workspace"; @@ -168,7 +169,7 @@ function CanvasEditor({ board }: { board: Board }) {
-
+
dispatch({ type: "undo" })} /> dispatch({ type: "redo" })} /> @@ -181,7 +182,7 @@ function CanvasEditor({ board }: { board: Board }) {
{saved ? "保存在此浏览器" : "正在保存到此浏览器…"}
-
+
setTool("select")} /> setTool("hand")} /> @@ -192,7 +193,9 @@ function CanvasEditor({ board }: { board: Board }) { projectInput.current?.click()} />
- +
+ +
自由创作,从移动画布开始

拖动空白处平移,拖动节点移动位置。

diff --git a/src/components/ui/icon-button.module.css b/src/components/ui/icon-button.module.css index 4f057c3..96323e9 100644 --- a/src/components/ui/icon-button.module.css +++ b/src/components/ui/icon-button.module.css @@ -3,8 +3,9 @@ align-items: center; justify-content: center; flex: 0 0 auto; - width: 28px; - height: 24px; + box-sizing: border-box; + width: var(--toolbar-control-width, 28px); + height: var(--toolbar-control-height, 24px); padding: 4px 6px; border: 0; border-radius: 5px; @@ -12,7 +13,7 @@ color: inherit; cursor: pointer; } -.button svg { width: 16px; height: 16px; flex: 0 0 16px; } +.button svg { width: var(--toolbar-icon-size, 16px); height: var(--toolbar-icon-size, 16px); flex: 0 0 var(--toolbar-icon-size, 16px); } .button:hover { background: #e9e7e3; } .button:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; } .button:disabled { opacity: 0.45; cursor: not-allowed; } diff --git a/src/components/ui/toolbar.module.css b/src/components/ui/toolbar.module.css new file mode 100644 index 0000000..6175b9e --- /dev/null +++ b/src/components/ui/toolbar.module.css @@ -0,0 +1,18 @@ +.surface { + --toolbar-icon-size: 16px; + --toolbar-control-width: 28px; + --toolbar-control-height: 24px; + display: flex; + align-items: center; + gap: 2px; + padding: 4px; + border: 1px solid #e2dcd3; + border-radius: 8px; + background: #fffffff5; + color: #4b443d; + box-shadow: 0 3px 14px #32251a0a; + backdrop-filter: blur(12px); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + font-size: 12px; + line-height: 20px; +} diff --git a/src/components/workspace/active-board.tsx b/src/components/workspace/active-board.tsx new file mode 100644 index 0000000..6388f82 --- /dev/null +++ b/src/components/workspace/active-board.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { createContext, useContext, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react"; +import type { Board } from "@/lib/workspace"; + +type ActiveBoard = Pick | null; +const ActiveBoardContext = createContext<{ + board: ActiveBoard; + setBoard: Dispatch>; +} | null>(null); + +export function ActiveBoardProvider({ children }: { children: ReactNode }) { + const [board, setBoard] = useState(null); + const value = useMemo(() => ({ board, setBoard }), [board]); + return {children}; +} + +export function useActiveBoard() { + const context = useContext(ActiveBoardContext); + if (!context) throw new Error("useActiveBoard requires ActiveBoardProvider"); + return context; +} diff --git a/src/components/workspace/graph-page.tsx b/src/components/workspace/graph-page.tsx index 8615136..96c29d8 100644 --- a/src/components/workspace/graph-page.tsx +++ b/src/components/workspace/graph-page.tsx @@ -6,11 +6,13 @@ import { api, errorText } from "@/lib/api"; import { readDemoBoards } from "@/lib/demo-workspace"; import type { Board } from "@/lib/workspace"; import styles from "./workspace.module.css"; +import { useActiveBoard } from "./active-board"; import GraphCanvas from "@/components/canvas/graph-canvas"; export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) { const demo = useSearchParams().get("demo") === "1"; const router = useRouter(); + const { setBoard: setActiveBoard } = useActiveBoard(); const [board, setBoard] = useState(null); const [error, setError] = useState(""); @@ -45,6 +47,13 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin return () => { active = false; controller.abort(); }; }, [workspaceId, boardId, demo, router]); + useEffect(() => { + if (board?.id === boardId && board.workspace_id === workspaceId) { + setActiveBoard({ id: board.id, workspace_id: board.workspace_id, title: board.title }); + } + return () => setActiveBoard(null); + }, [board, boardId, workspaceId, setActiveBoard]); + return (
{board ? :
} diff --git a/src/components/workspace/workspace.module.css b/src/components/workspace/workspace.module.css index 1cbb09c..1e60f3a 100644 --- a/src/components/workspace/workspace.module.css +++ b/src/components/workspace/workspace.module.css @@ -94,7 +94,7 @@ .newWorkspace { color: #2383e2; } .logout { color: #78746d; font-size: 12px; } .separator { height: 1px; margin: 6px 7px; background: #eeece8; } -.floating { position: fixed; left: 12px; top: 12px; z-index: 40; display: flex; gap: 6px; padding: 6px; background: #fffefa; border: 1px solid #e7e2db; border-radius: 8px; box-shadow: 0 3px 14px #3c312b0d; color: #85817b; } +.floating { position: fixed; left: 12px; top: 12px; max-width: calc(100vw - 24px); z-index: 40; } .floatingError { position: absolute; left: 0; top: 42px; width: 240px; padding: 10px; background: white; border: 1px solid #e7e2db; border-radius: 8px; color: #b14b3c; font-size: 12px; } .settingsPanel { color: #37352f; border-color: #e6e2dc; border-radius: 12px; } .settingsForm { display: flex; flex-direction: column; gap: 16px; padding-top: 4px; } @@ -112,11 +112,15 @@ @media (max-width: 600px) { .shell { --sidebar-width: 240px; } } @media (prefers-reduced-motion: reduce) { .accountPopup:popover-open { animation: none; } } -.logoLink { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 28px; height: 24px; padding: 4px 6px; border-radius: 5px; } -.logoLink img { display: block; width: 16px; height: 16px; } +.logoLink { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; box-sizing: border-box; width: var(--toolbar-control-width, 28px); height: var(--toolbar-control-height, 24px); padding: 4px 6px; border-radius: 5px; } +.logoLink img { display: block; width: var(--toolbar-icon-size, 16px); height: var(--toolbar-icon-size, 16px); } .logoLink:hover { background: #e9e7e3; } .logoLink:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; } -.floatingAvatarButton { display: grid; place-items: center; width: 28px; height: 24px; border-radius: 5px; } +.floatingAvatarButton { display: grid; place-items: center; flex: 0 0 auto; box-sizing: border-box; width: var(--toolbar-control-width, 28px); height: var(--toolbar-control-height, 24px); border-radius: 5px; } .floatingAvatarButton:hover { background: #e9e7e3; } .accountPopup[data-floating="true"] { inset: 56px auto auto 12px; max-height: calc(100dvh - 70px); transform-origin: top left; } + +.floatingAvatarButton .avatar { flex-basis: var(--toolbar-icon-size, 16px); width: var(--toolbar-icon-size, 16px); height: var(--toolbar-icon-size, 16px); font-size: 9px; } +.floatingTitle { flex: 1 1 auto; min-width: 0; max-width: 240px; margin: 0 4px; color: #37352f; font-size: 12px; line-height: 24px; font-weight: 500; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +@media (max-width: 700px) { .floatingTitle { max-width: 180px; } }