diff --git a/README.md b/README.md
index 69a63d5..3925cdc 100644
--- a/README.md
+++ b/README.md
@@ -98,3 +98,7 @@ API 测试使用本机临时后端和独立前端进程,检查会话 Cookie、
## 悬浮栏尺寸
左上角显示当前图谱标题,复用页面已读取的资料,不额外请求后端;长标题省略并可悬停查看。左上、右上、底部与帮助按钮共用 `toolbar.module.css`:图标/Logo/浮框头像 20px,图标按钮含 padding 的总尺寸为 32×32px,主文字 14px,容器内边距 4px,间隙 4px,圆角 8px。悬浮栏距屏幕边缘统一为 12px;右上角窄屏时下移避免与标题重叠。
+
+## 图谱改名
+
+左上角标题第一次点击选中,选中后再次点击进入编辑;回车或失焦保存,Esc 取消,F2 也可直接编辑。真实图谱改名先读取最新资料和版本,再携带 If-Match 更新名称,保留描述与内容;版本冲突时提示重试。演示模式保存到本地。保存后标题、侧栏列表及导出文件名同步更新。
diff --git a/VALIDATION.md b/VALIDATION.md
index cefaed1..f59978d 100644
--- a/VALIDATION.md
+++ b/VALIDATION.md
@@ -93,3 +93,12 @@
- 控件间距从 2px 增加到 4px,统一应用于左上、右上、底部及帮助悬浮栏;保持图标按钮含 padding 总尺寸 32×32px。
- 差异检查通过;仅调整 CSS 间距,未重复构建或访问浏览器。
+
+## 左上角标题改名(2026-11-06)
+
+- 标题交互为 idle → selected → editing,第一次点击选中,第二次点击编辑;支持 Enter/失焦保存、Esc 取消、F2 编辑与输入法组合输入保护。
+- 改名前 GET 最新图谱,PATCH 携带带引号的 If-Match 版本、原 description/content;成功后更新共享标题、侧栏列表及导出使用的名称。演示模式写入本地列表,跨图谱切换不会覆盖新图谱的标题。
+- 名称去除首尾空格,拒绝空名及超过 300 UTF-8 字节的名称;版本冲突不自动覆盖或重试,失败保留输入。
+- 类型检查和 16 项画布/工作空间测试通过,新增原内容保留、名称校验和版本冲突不重试的检查。
+- 按既定约定不访问浏览器,未进行实际点击与输入法交互验收。
+- 隔离 Webpack 生产构建和完整 API 冒烟检查通过;新增改名 PATCH/If-Match、204 响应、重新读取名称和内容保留检查通过。
diff --git a/src/app/dashboard/dashboard.tsx b/src/app/dashboard/dashboard.tsx
index 7a58041..2da3cfa 100644
--- a/src/app/dashboard/dashboard.tsx
+++ b/src/app/dashboard/dashboard.tsx
@@ -7,9 +7,10 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronsUpDown, FilePlus2, Home, Network, PanelLeftClose, PanelLeftOpen, Plus, Search, Settings } from "lucide-react";
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 { boardHref, createBoard, renameBoard, 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 { EditableTitle } from "@/components/workspace/editable-title";
import { UserAvatar } from "@/components/workspace/user-avatar";
import { AccountPanel } from "@/components/workspace/account-panel";
import { readDemoBoards } from "@/lib/demo-workspace";
@@ -17,7 +18,7 @@ import styles from "@/components/workspace/workspace.module.css";
export default function DashboardSidebar() {
const router = useRouter();
- const { board: activeBoard } = useActiveBoard();
+ const { board: activeBoard, setBoard: setActiveBoard } = useActiveBoard();
const reduceMotion = useReducedMotion();
const params = useParams<{ workspace?: string; board?: string }>();
const searchParams = useSearchParams();
@@ -139,6 +140,19 @@ export default function DashboardSidebar() {
const filtered = boards.filter((b) => b.title.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
const canCreate = status === "ready" && (!workspace || Boolean(currentWorkspace && currentWorkspace.role !== "viewer"));
+ async function renameTitle(title: string) {
+ const id = params.board;
+ if (!id || !workspace) return;
+ try {
+ if (demo) {
+ const list = readDemoBoards(workspace);
+ localStorage.setItem("endoleap_demo_boards_" + workspace, JSON.stringify(list.map((board) => board.id === id ? { ...board, title } : board)));
+ } else await renameBoard(api, workspace, id, title);
+ setBoards((list) => list.map((board) => board.id === id ? { ...board, title } : board));
+ setActiveBoard((board) => board?.id === id && board.workspace_id === workspace ? { ...board, title } : board);
+ } catch (error) { throw new Error(errorText(error)); }
+ }
+
const createButton = ;
const transition = { duration: reduceMotion ? 0 : 0.24, ease: [0.22, 1, 0.36, 1] as const };
@@ -159,7 +173,9 @@ export default function DashboardSidebar() {
srcSet="/brand/png/logo-light/endoleap-32.png 1x, /brand/png/logo-light/endoleap-64.png 2x, /brand/png/logo-light/endoleap-128.png 4x"
width={20} height={20} alt="" />
-
{currentTitle}
+
diff --git a/src/components/workspace/editable-title.module.css b/src/components/workspace/editable-title.module.css
new file mode 100644
index 0000000..2e21abc
--- /dev/null
+++ b/src/components/workspace/editable-title.module.css
@@ -0,0 +1,9 @@
+.shell { position: relative; display: flex; align-items: center; }
+.title, .input { box-sizing: border-box; min-width: 0; max-width: 100%; height: 32px; padding: 0 6px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: inherit; font: inherit; line-height: 30px; }
+.title { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; text-align: left; }
+.title:hover:not(:disabled) { background: #f0ece6; }
+.title[data-selected="true"] { background: #ede5dc; border-color: #d3c0b2; }
+.title:disabled { cursor: default; }
+.input { width: 220px; border-color: #a47764; background: white; outline: none; }
+.input[readonly] { opacity: 0.65; }
+.error { position: absolute; top: calc(100% + 10px); left: 0; width: 260px; max-width: calc(100vw - 40px); padding: 8px 10px; border: 1px solid #e5c7b8; border-radius: 7px; background: #fff9f3; color: #a25439; font-size: 12px; line-height: 1.6; white-space: normal; box-shadow: 0 4px 12px #32251a0a; }
diff --git a/src/components/workspace/editable-title.tsx b/src/components/workspace/editable-title.tsx
new file mode 100644
index 0000000..973e196
--- /dev/null
+++ b/src/components/workspace/editable-title.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import styles from "./editable-title.module.css";
+
+export function EditableTitle({ title, editable, onSave, className = "" }: {
+ title: string;
+ editable: boolean;
+ onSave: (title: string) => Promise;
+ className?: string;
+}) {
+ const [mode, setMode] = useState<"idle" | "selected" | "editing" | "saving">("idle");
+ const [draft, setDraft] = useState(title);
+ const [error, setError] = useState("");
+ const shell = useRef(null);
+ const input = useRef(null);
+ const button = useRef(null);
+ const pending = useRef(false);
+ const cancelled = useRef(false);
+
+ useEffect(() => {
+ if (mode !== "selected") return;
+ const deselect = (event: PointerEvent) => {
+ if (event.target instanceof Node && !shell.current?.contains(event.target)) setMode("idle");
+ };
+ document.addEventListener("pointerdown", deselect);
+ return () => document.removeEventListener("pointerdown", deselect);
+ }, [mode]);
+
+ useEffect(() => {
+ if (mode === "editing") { input.current?.focus(); input.current?.select(); }
+ }, [mode]);
+
+ function edit() {
+ if (!editable) return;
+ cancelled.current = false;
+ setDraft(title);
+ setError("");
+ setMode("editing");
+ }
+ function cancel() {
+ cancelled.current = true;
+ setError("");
+ setMode("selected");
+ requestAnimationFrame(() => button.current?.focus());
+ }
+ async function save() {
+ if (pending.current || cancelled.current) return;
+ const name = draft.trim();
+ if (!name || new TextEncoder().encode(name).length > 300) {
+ setError("名称不能为空,且不能超过 300 字节(约 100 个汉字)。");
+ input.current?.focus();
+ return;
+ }
+ if (name === title) { cancelled.current = true; setMode("idle"); return; }
+ pending.current = true;
+ setMode("saving");
+ setError("");
+ try {
+ await onSave(name);
+ cancelled.current = true;
+ setMode("idle");
+ } catch (error) {
+ setError(error instanceof Error ? error.message : "改名失败,请重试。");
+ setMode("editing");
+ } finally { pending.current = false; }
+ }
+ return (
+
+ {mode === "editing" || mode === "saving" ? (
+ setDraft(event.target.value)} onBlur={() => void save()}
+ onKeyDown={(event) => {
+ event.stopPropagation();
+ if (event.nativeEvent.isComposing || mode === "saving") return;
+ if (event.key === "Enter") { event.preventDefault(); void save(); }
+ if (event.key === "Escape") { event.preventDefault(); cancel(); }
+ }} />
+ ) : (
+
+ )}
+ {error && {error}}
+
+ );
+}
diff --git a/src/components/workspace/graph-page.tsx b/src/components/workspace/graph-page.tsx
index 96c29d8..4689833 100644
--- a/src/components/workspace/graph-page.tsx
+++ b/src/components/workspace/graph-page.tsx
@@ -12,7 +12,7 @@ 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: activeBoard, setBoard: setActiveBoard } = useActiveBoard();
const [board, setBoard] = useState(null);
const [error, setError] = useState("");
@@ -54,9 +54,12 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
return () => setActiveBoard(null);
}, [board, boardId, workspaceId, setActiveBoard]);
+ const displayBoard = board && activeBoard?.id === board.id && activeBoard.workspace_id === workspaceId
+ ? { ...board, title: activeBoard.title } : board;
+
return (
-
- {board ? : }
+
+ {displayBoard ? : }
{error ? {error}
: !board && 正在打开图谱…
}
diff --git a/src/components/workspace/workspace.module.css b/src/components/workspace/workspace.module.css
index b4c0990..bd34d31 100644
--- a/src/components/workspace/workspace.module.css
+++ b/src/components/workspace/workspace.module.css
@@ -122,5 +122,5 @@
.accountPopup[data-floating="true"] { inset: 62px auto auto 12px; max-height: calc(100dvh - 76px); transform-origin: top left; }
.floatingAvatarButton .avatar { flex-basis: var(--toolbar-icon-size, 20px); width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); font-size: 14px; }
-.floatingTitle { flex: 1 1 auto; min-width: 0; max-width: 240px; margin: 0 4px; color: #37352f; font-size: 14px; line-height: var(--toolbar-control-height, 32px); font-weight: 500; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
+.floatingTitle { flex: 1 1 auto; min-width: 0; max-width: 240px; margin: 0 4px; color: #37352f; font-size: 14px; line-height: var(--toolbar-control-height, 32px); font-weight: 500; overflow: visible; }
@media (max-width: 700px) { .floatingTitle { max-width: 180px; } }
diff --git a/src/lib/api.ts b/src/lib/api.ts
index bbcc349..4650316 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -45,6 +45,8 @@ export const errorText = (error: unknown) => {
workspace_not_loaded: "空间列表尚未加载成功,请刷新页面后重试。",
workspace_required: "请先创建或选择一个工作空间。",
invalid_workspace: "工作空间名称不符合要求,请修改后重试。",
+ invalid_title: "名称不能为空,且不能超过 300 字节(约 100 个汉字)。",
+ version_conflict: "图谱已在别处修改,请再次保存以读取最新版本。",
invalid_profile: "请检查显示名称,以及用户名是否为 3–40 位小写字母、数字、下划线或连字符。",
forbidden: "你没有在此工作空间创建或编辑图谱的权限。",
invalid_material: "画布名称或内容不符合要求,请修改后重试。",
diff --git a/src/lib/workspace.ts b/src/lib/workspace.ts
index ab5f31c..be4835a 100644
--- a/src/lib/workspace.ts
+++ b/src/lib/workspace.ts
@@ -8,6 +8,7 @@ export type Board = {
title: string;
version: number;
content?: { schema_version: number };
+ description?: string;
};
export const DEFAULT_BOARD_TITLE = "未命名图谱";
@@ -56,3 +57,18 @@ export async function createBoard(
});
return { ...result, workspace_id: result.workspace_id || id, title: name };
}
+
+
+export async function renameBoard(request: Request, workspaceId: string, boardId: string, title: string): Promise {
+ const name = title.trim();
+ if (!name || new TextEncoder().encode(name).length > 300) throw new Error("invalid_title");
+ const path = `workspaces/${encodeURIComponent(workspaceId)}/materials/${encodeURIComponent(boardId)}`;
+ const current = await request(path);
+ if (current.kind !== "board" || current.id !== boardId || current.workspace_id !== workspaceId) throw new Error("invalid_reference");
+ await request(path, {
+ method: "PATCH",
+ headers: { "If-Match": `"${current.version}"` },
+ body: JSON.stringify({ title: name, description: current.description || "", content: current.content || { schema_version: 1 } }),
+ });
+ return { ...current, title: name, version: current.version + 1 };
+}
diff --git a/tests/api-smoke.mjs b/tests/api-smoke.mjs
index 7012963..da96960 100644
--- a/tests/api-smoke.mjs
+++ b/tests/api-smoke.mjs
@@ -27,7 +27,16 @@ const upstream = createServer(async (req, res) => {
res.end(JSON.stringify({ id: graph.id, workspace_id: graph.workspace_id, kind: graph.kind, version: graph.version }));
} else if (req.url === "/projects/endoleap/workspaces/space/materials/foundry-generated-id") {
assert.equal(req.headers.authorization, "Bearer test-only-session");
- res.end(JSON.stringify(graphs.get("foundry-generated-id")));
+ if (req.method === "PATCH") {
+ assert.equal(req.headers["if-match"], '"1"');
+ let raw = "";
+ for await (const chunk of req) raw += chunk;
+ const input = JSON.parse(raw);
+ const current = graphs.get("foundry-generated-id");
+ graphs.set(current.id, { ...current, ...input, version: current.version + 1 });
+ res.statusCode = 204;
+ res.end();
+ } else res.end(JSON.stringify(graphs.get("foundry-generated-id")));
} else if (req.url.includes("/workspaces/space/materials")) {
res.end(JSON.stringify({ url: req.url, method: req.method }));
} else if (req.url.endsWith("/auth/login")) {
@@ -157,6 +166,15 @@ try {
assert.equal(stored.title, "未命名图谱");
assert.equal(stored.workspace_id, "space");
assert.equal(stored.content.schema_version, 1);
+ const rename = await fetch(host + `/api/foundry/workspaces/${graph.workspace_id}/materials/${graph.id}`, {
+ method: "PATCH", headers: { "Content-Type": "application/json", Cookie: session, Origin: host, "If-Match": '"1"' },
+ body: JSON.stringify({ title: "改名后的图谱", description: "", content: stored.content }),
+ });
+ assert.equal(rename.status, 204);
+ const renamed = await (await fetch(host + `/api/foundry/workspaces/${graph.workspace_id}/materials/${graph.id}`, { headers: { Cookie: session } })).json();
+ assert.equal(renamed.title, "改名后的图谱");
+ assert.equal(renamed.version, 2);
+ assert.deepEqual(renamed.content, stored.content);
for (const origin of ["https://example.com", "null"])
assert.equal(
diff --git a/tests/workspace.test.mjs b/tests/workspace.test.mjs
index f74053c..1079820 100644
--- a/tests/workspace.test.mjs
+++ b/tests/workspace.test.mjs
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { boardHref, createBoard, DEFAULT_BOARD_TITLE, loadWorkspaceHome } from "../src/lib/workspace.ts";
+import { boardHref, createBoard, renameBoard, DEFAULT_BOARD_TITLE, loadWorkspaceHome } from "../src/lib/workspace.ts";
test("profile and workspace reads both start before either completes", async () => {
const calls = [];
@@ -101,3 +101,36 @@ test("graph URLs preserve workspace identity and safely encode route segments",
assert.equal(boardHref("space/a", "id?b"), "/dashboard/space%2Fa/id%3Fb");
assert.equal(boardHref("demo", "board", true), "/dashboard/demo/board?demo=1");
});
+
+
+test("renaming reads the latest version and preserves description and content", async () => {
+ const calls = [];
+ const content = { schema_version: 1, preserved: { key: "value" } };
+ const renamed = await renameBoard(async (path, options) => {
+ calls.push(path);
+ if (!options) return { id: "board", workspace_id: "space", kind: "board", title: "Old", version: 7, description: "Keep this", content };
+ assert.equal(options.method, "PATCH");
+ assert.equal(options.headers["If-Match"], '"7"');
+ assert.deepEqual(JSON.parse(options.body), { title: "新名称", description: "Keep this", content });
+ return null;
+ }, "space", "board", " 新名称 ");
+ assert.deepEqual(calls, ["workspaces/space/materials/board", "workspaces/space/materials/board"]);
+ assert.equal(renamed.title, "新名称");
+ assert.equal(renamed.version, 8);
+});
+
+test("rename rejects empty or oversized names without any API calls", async () => {
+ for (const name of [" ", "字".repeat(101)]) {
+ await assert.rejects(renameBoard(() => assert.fail("unexpected API call"), "space", "board", name), /invalid_title/);
+ }
+});
+
+test("rename surfaces a version conflict without retrying or overwriting", async () => {
+ let patches = 0;
+ await assert.rejects(renameBoard(async (_path, options) => {
+ if (!options) return { id: "board", workspace_id: "space", kind: "board", title: "Old", version: 1 };
+ patches++;
+ throw new Error("version_conflict");
+ }, "space", "board", "New"), /version_conflict/);
+ assert.equal(patches, 1);
+});