From 61bfff4874104bc9f3a2710e28ecd4e3127a37c8 Mon Sep 17 00:00:00 2001 From: moranzhi Date: Mon, 14 Sep 2026 01:14:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8C=89=E5=88=9B=E4=BD=9C=E6=AD=A5=E9=AA=A4?= =?UTF-8?q?=E5=88=97=E5=87=BA=E5=BF=AB=E7=85=A7=E4=BA=A7=E7=89=A9=E5=B9=B6?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=9B=B4=E6=8E=A5=E6=94=B9=E5=86=99=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 方便在存档侧栏核对与手改黑板内容。 Co-authored-by: Cursor --- src/book/snapshot-products.ts | 92 +++++++++++++++++++++++++++++++++ src/server/book-handlers.ts | 46 +++++++++++++++-- tests/snapshot-products.test.ts | 55 ++++++++++++++++++++ 3 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 src/book/snapshot-products.ts create mode 100644 tests/snapshot-products.test.ts diff --git a/src/book/snapshot-products.ts b/src/book/snapshot-products.ts new file mode 100644 index 0000000..1d895f4 --- /dev/null +++ b/src/book/snapshot-products.ts @@ -0,0 +1,92 @@ +/** + * 快照内「创作产物」列表:按「设计.创作流程」步骤序展开,其余设计/展示 tag 殿后。 + */ + +import type { BlackboardItem } from "../types/blackboard.js"; +import { + CREATION_FLOW_TAG, + parseCreationFlow, +} from "../skills/creation-flow.js"; + +export type SnapshotProductRow = { + /** 给人看的步骤名或短标签 */ + label: string; + tag: string; + content: string; +}; + +function latestByTag(items: BlackboardItem[]): Map { + const map = new Map(); + for (const item of items) { + const tag = item.tag?.trim(); + if (!tag) continue; + map.set(tag, typeof item.content === "string" ? item.content : ""); + } + return map; +} + +function isProductTag(tag: string): boolean { + if (tag === CREATION_FLOW_TAG) return false; + if (tag.startsWith("设计.") || tag.startsWith("用户.")) return true; + return tag === "输出.开场白" || tag === "输出.用户展示"; +} + +/** 从快照黑板抽出可手改产物;有创作流程时按步骤序。 */ +export function listSnapshotProducts( + items: BlackboardItem[], +): SnapshotProductRow[] { + const byTag = latestByTag(items); + const out: SnapshotProductRow[] = []; + const used = new Set(); + + const flow = parseCreationFlow(byTag.get(CREATION_FLOW_TAG) ?? null); + if (flow) { + for (const step of flow.steps) { + if (step.role === "prototype") continue; + const tag = `设计.${step.name}`; + const content = byTag.get(tag); + if (content == null || !content.trim()) continue; + out.push({ label: step.name, tag, content }); + used.add(tag); + } + } + + const rest: SnapshotProductRow[] = []; + for (const [tag, content] of byTag) { + if (used.has(tag) || !isProductTag(tag) || !content.trim()) continue; + const label = tag.startsWith("设计.") ? tag.slice("设计.".length) : tag; + rest.push({ label, tag, content }); + } + rest.sort((a, b) => a.tag.localeCompare(b.tag, "zh-CN")); + out.push(...rest); + return out; +} + +/** 覆盖快照黑板某一 tag 的最新条目内容(同 tag 多条时改最后一条;没有则追加)。 */ +export function patchBlackboardTagContent( + items: BlackboardItem[], + tag: string, + content: string, +): BlackboardItem[] { + const trimmedTag = tag.trim(); + if (!trimmedTag) throw new Error("tag 不能为空"); + const next = items.map((item) => ({ ...item })); + let last = -1; + for (let i = 0; i < next.length; i++) { + if (next[i].tag === trimmedTag) last = i; + } + const now = new Date().toISOString(); + if (last >= 0) { + next[last] = { ...next[last], content, updatedAt: now }; + return next; + } + next.push({ + id: `manual-${trimmedTag}-${Date.now()}`, + tag: trimmedTag, + content, + source: "user", + createdAt: now, + updatedAt: now, + }); + return next; +} diff --git a/src/server/book-handlers.ts b/src/server/book-handlers.ts index 92c6a2e..0ca8786 100644 --- a/src/server/book-handlers.ts +++ b/src/server/book-handlers.ts @@ -531,9 +531,49 @@ export async function handleBooksApi( return true; } - if ((req.method === "PATCH" || req.method === "PUT") && !isLoad) { - const body = JSON.parse(await readBody(req)) as { label?: string }; + if (req.method === "GET" && !isLoad) { try { + const data = sessionManager.getGameSnapshotProducts(bookId, saveId); + json(res, 200, { + save: { + ...data.save, + kindLabel: + data.save.kind === "instance" ? "创作定稿" : "游玩进度", + }, + products: data.products, + }); + } catch (err) { + json(res, 404, { + error: err instanceof Error ? err.message : "读取存档产物失败", + }); + } + return true; + } + + if ((req.method === "PATCH" || req.method === "PUT") && !isLoad) { + const body = JSON.parse(await readBody(req)) as { + label?: string; + tag?: string; + content?: string; + }; + try { + if (typeof body.tag === "string") { + const data = sessionManager.patchGameSnapshotProduct( + bookId, + saveId, + body.tag, + typeof body.content === "string" ? body.content : "", + ); + json(res, 200, { + save: { + ...data.save, + kindLabel: + data.save.kind === "instance" ? "创作定稿" : "游玩进度", + }, + products: data.products, + }); + return true; + } const save = sessionManager.renameGameSnapshot( bookId, saveId, @@ -547,7 +587,7 @@ export async function handleBooksApi( }); } catch (err) { json(res, 400, { - error: err instanceof Error ? err.message : "重命名失败", + error: err instanceof Error ? err.message : "更新失败", }); } return true; diff --git a/tests/snapshot-products.test.ts b/tests/snapshot-products.test.ts new file mode 100644 index 0000000..184bbfd --- /dev/null +++ b/tests/snapshot-products.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + listSnapshotProducts, + patchBlackboardTagContent, +} from "../src/book/snapshot-products.js"; +import type { BlackboardItem } from "../src/types/blackboard.js"; + +function item(tag: string, content: string): BlackboardItem { + const now = "2026-09-13T00:00:00.000Z"; + return { + id: tag, + tag, + content, + source: "test", + createdAt: now, + updatedAt: now, + }; +} + +describe("listSnapshotProducts", () => { + it("orders by creation flow steps then leftovers", () => { + const flow = JSON.stringify({ + version: 1, + steps: [ + { id: "a", name: "美学纲领与交互范式", depends_on: [] }, + { id: "b", name: "舞台骨架", depends_on: ["a"] }, + ], + }); + const rows = listSnapshotProducts([ + item("设计.舞台骨架", "骨架正文"), + item("设计.美学纲领与交互范式", "美学正文"), + item("设计.创作流程", flow), + item("输出.用户展示", "短正文"), + item("设计.监控栏", "栏"), + ]); + expect(rows.map((r) => r.tag)).toEqual([ + "设计.美学纲领与交互范式", + "设计.舞台骨架", + "设计.监控栏", + "输出.用户展示", + ]); + expect(rows[0].label).toBe("美学纲领与交互范式"); + }); + + it("patches latest tag content", () => { + const items = [ + item("输出.用户展示", "旧"), + item("输出.用户展示", "中间"), + ]; + const next = patchBlackboardTagContent(items, "输出.用户展示", "新加长正文"); + expect(next).toHaveLength(2); + expect(next[1].content).toBe("新加长正文"); + expect(next[0].content).toBe("旧"); + }); +});