按创作步骤列出快照产物并支持直接改写。
方便在存档侧栏核对与手改黑板内容。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<string, string> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -531,9 +531,49 @@ export async function handleBooksApi(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((req.method === "PATCH" || req.method === "PUT") && !isLoad) {
|
if (req.method === "GET" && !isLoad) {
|
||||||
const body = JSON.parse(await readBody(req)) as { label?: string };
|
|
||||||
try {
|
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(
|
const save = sessionManager.renameGameSnapshot(
|
||||||
bookId,
|
bookId,
|
||||||
saveId,
|
saveId,
|
||||||
@@ -547,7 +587,7 @@ export async function handleBooksApi(
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
json(res, 400, {
|
json(res, 400, {
|
||||||
error: err instanceof Error ? err.message : "重命名失败",
|
error: err instanceof Error ? err.message : "更新失败",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -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("旧");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user