完善创作节点循环与等待态交互,并大幅打磨 Web 壳与会话运行时。

- 节点循环:配方开局直坐 DAG 第一步;验收后先问下一步意向,再确认开干并可钉编排参数;「按意见修改」只重跑当前节点。
- 询问分流:能力 opening 走说话面引导,可跳过追问按题干去重;追问与产物同轮挂载,避免拆成两段历史。
- 运行时:补强 revision 重跑、创作流程合并/进度指针、工具循环与 worker 执行;新增运行日志与 web:watch。
- 前端:统一等待态文案与底栏主按钮,完善询问卡/产物验收/呈现壳样式与交互。
- 同步世界模拟器模块提示、编排文档与相关测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-17 01:28:12 +08:00
co-authored by Cursor
parent 20938b04d7
commit ec474cb946
56 changed files with 7459 additions and 1371 deletions
+967 -197
View File
File diff suppressed because it is too large Load Diff
+776 -115
View File
File diff suppressed because it is too large Load Diff
+61
View File
@@ -93,3 +93,64 @@ export function formatWorkerDisplayTitle(workerId, action = null) {
if (action === "running") return `${base} · 执行中`;
return base;
}
/**
* `review_artifact` 底栏 / 工作面文案。流程编排核对工作流计划,不是「验收通过」。
* 须与 src/server/display-labels.ts `reviewComposerCopy` 保持一致。
*/
export function reviewComposerCopy(workerId, opts = {}) {
const hasQuestions = Boolean(opts.hasQuestions);
const id = String(workerId ?? "").trim();
if (id === "design-flow") {
return {
chip: "编排",
kicker: "核对编排",
taskLabel: "编排",
taskTitle: "核对工作流计划",
hint: hasQuestions
? "同意就按这个排;要改就写意见,在现有计划上改。下方追问可选答。"
: "同意就按这个排;要改就写意见,在现有计划上改。",
submitLabel: "按意见改编排",
acceptLabel: "确认编排",
placeholder: "改编排意见…",
emptyEnterHint: "空 Enter=确认编排",
tone: "plan",
kind: "flow",
};
}
if (id === "opening-generator") {
return {
chip: "开场",
kicker: "待选定",
taskLabel: "开场",
taskTitle: "选定开场白",
hint: hasQuestions
? "同意就选定此开场;要改就写意见。下方追问可选答。"
: "同意就选定此开场;要改就写意见。",
submitLabel: "按意见修改",
acceptLabel: "选定此开场",
placeholder: "修改意见…",
emptyEnterHint: "空 Enter=选定此开场",
tone: "plan",
kind: "opening",
};
}
return {
chip: "验收",
kicker: "待验收",
taskLabel: "验收",
taskTitle: "验收产物",
hint: hasQuestions
? "同意就接受;要改就写意见,在现有产物上改。下方追问可选答。"
: "同意就接受;要改就写意见,在现有产物上改。",
submitLabel: "按意见修改",
acceptLabel: "接受",
placeholder: "修改意见…",
emptyEnterHint: "空 Enter=接受",
tone: "accept",
kind: "accept",
};
}
+34 -8
View File
@@ -35,7 +35,7 @@
<div class="rail-panels">
<div class="rail-tab-panel active" id="rail-panel-books" role="tabpanel" data-rail-panel="books">
<nav class="path-bar" id="book-path-bar" aria-label="作品路径"></nav>
<nav class="path-bar" id="book-path-bar" aria-label="面包屑" hidden></nav>
<div class="book-list" id="book-list"></div>
<div id="book-box-overlay" class="book-box-overlay" hidden aria-hidden="true"></div>
</div>
@@ -60,14 +60,19 @@
</div>
</div>
<div class="main-head-right">
<a class="link" href="/modules.html">技能</a>
<a class="link" href="/shells.html">呈现壳</a>
<a class="link" href="/stats.html">Token</a>
<a class="link" href="/settings.html">设置</a>
<button type="button" class="btn-sm" id="btn-save-instance" hidden>保存定稿</button>
<button type="button" class="btn-sm" id="btn-save-play" hidden>保存游玩</button>
<button type="button" class="btn-sm" id="btn-export" disabled>导出</button>
<span class="status" id="status-pill"><span class="status-dot" id="status-dot"></span><span id="status-text">—</span></span>
<details class="more-menu">
<summary class="btn-sm more-menu-sum" title="更多页面">更多</summary>
<div class="more-menu-panel" role="menu">
<a class="more-menu-item" href="/modules.html" role="menuitem">技能</a>
<a class="more-menu-item" href="/shells.html" role="menuitem">呈现壳</a>
<a class="more-menu-item" href="/stats.html" role="menuitem">Token</a>
<a class="more-menu-item" href="/settings.html" role="menuitem">设置</a>
</div>
</details>
</div>
</header>
@@ -77,8 +82,28 @@
<div id="message-feed" class="message-feed">
<p class="empty">点击左侧 + 新建作品</p>
</div>
<div id="workspace-stage" class="workspace-stage" hidden aria-label="验收工作面"></div>
<div id="questions-card-host" class="questions-card-host" hidden></div>
</section>
<aside id="coord-rail" class="coord-rail" hidden aria-label="此前对话">
<button
type="button"
class="coord-rail-toggle"
id="btn-coord-toggle"
aria-expanded="false"
title="展开 / 收起此前对话"
>
<span class="coord-rail-toggle-label">对话</span>
<span id="coord-drawer-count" class="coord-drawer-count"></span>
</button>
<div class="coord-rail-panel">
<header class="coord-rail-head">
<span class="coord-rail-title">此前对话</span>
<button type="button" class="btn-sm coord-rail-collapse" id="btn-coord-collapse" title="收起" aria-label="收起此前对话">›</button>
</header>
<div id="coord-drawer-body" class="coord-drawer-body"></div>
</div>
</aside>
</div>
<footer class="composer" id="composer"></footer>
@@ -119,12 +144,13 @@
<button type="button" class="context-menu-item" id="msg-menu-context" data-msg-menu-action="view-context" hidden>查看上下文</button>
<button type="button" class="context-menu-item" id="msg-menu-edit" data-msg-menu-action="edit">编辑</button>
<button type="button" class="context-menu-item" id="msg-menu-copy" data-msg-menu-action="copy">复制</button>
<button type="button" class="context-menu-item context-menu-danger" id="msg-menu-delete" data-msg-menu-action="delete">删除</button>
<button type="button" class="context-menu-item context-menu-separator-before" id="msg-menu-rollback" data-msg-menu-action="rollback" hidden>回退到这里</button>
<button type="button" class="context-menu-item context-menu-danger" id="msg-menu-delete" data-msg-menu-action="delete">从这里重开</button>
</menu>
<dialog id="dialog-context-trace" class="dialog dialog-wide">
<dialog id="dialog-context-trace" class="dialog dialog-wide" aria-labelledby="context-trace-title">
<form method="dialog" class="context-trace-form">
<h2>请求上下文</h2>
<h2 id="context-trace-title">上下文调用日志</h2>
<p class="dialog-desc" id="context-trace-meta"></p>
<pre id="context-trace-body" class="context-trace-body"></pre>
<div class="dialog-actions">
+120 -17
View File
@@ -2,10 +2,15 @@
* 结构化询问卡:点字母选中,点文案编辑;左右分页。
* 询问是对同一次发送的可选增强:有选中则问+答拼接;未选中则不带问。
* 点发送即答复 → 收起本轮卡(卡上无独立发送钮)。
* 产物验收挂载题:底栏有字优先按「改产物」发送;无字时才提交所选答案。
* 产物验收挂载题:选项 + 底栏意见一并随「按意见修改」写回产物;空内容点该钮不等于接受。
* 分页 / 跳过为悬浮控件,不占独立 header/footer 行。
*
* 能力「默认问题」(id=module-opening) 不是追问:对齐美学纲领开局,走说话面 + openingGuide。
*/
/** 与 phase-runtime maybeAskModuleOpening 写入的 id 对齐 */
export const MODULE_OPENING_QUESTION_ID = "module-opening";
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
@@ -14,6 +19,22 @@ function esc(s) {
.replace(/"/g, "&quot;");
}
/** 当前等待是否为能力默认问题(应呈现为开场引导,而非答题卡) */
export function isModuleOpeningWaiting(view) {
const wr = view?.waitingReason;
if (wr?.kind !== "worker_questions" || !Array.isArray(wr.questions)) return false;
const qs = normalizeQuestions(wr.questions);
return qs.length === 1 && qs[0].id === MODULE_OPENING_QUESTION_ID;
}
/** 取出默认问题正文(供 openingGuide / 说话面展示) */
export function getModuleOpeningPrompt(view) {
if (!isModuleOpeningWaiting(view)) return null;
const qs = normalizeQuestions(view.waitingReason.questions);
const prompt = qs[0]?.prompt?.trim();
return prompt || null;
}
function normalizeQuestions(raw) {
if (!Array.isArray(raw)) return [];
return raw
@@ -64,6 +85,8 @@ export function getActiveQuestions(view) {
// 默认每页 1 题,左右切换;勿一次堆多题
const pageSize = Math.max(1, Number(wr.pageSize) || 1);
if (wr.kind === "worker_questions" && wr.questions?.length) {
// 能力默认问题:不进答题卡(与美学纲领 openingGuide 同形)
if (isModuleOpeningWaiting(view)) return null;
return {
questions: normalizeQuestions(wr.questions),
workerId: wr.workerId,
@@ -114,6 +137,18 @@ export function getActiveQuestions(view) {
return null;
}
/**
* 本轮题是否已答复收起(卡不会再画出来)。
* 此时若仍把主区排成答题面,用户只会看到一片空壳。
*/
export function isQuestionCardDismissed(view, host) {
const el = host ?? document.getElementById("questions-card-host");
const active = getActiveQuestions(view);
if (!el?._qDismissed || !active?.questions?.length) return false;
const key = JSON.stringify(active.questions.map((q) => q.id));
return el._qDismissed.sessionId === view?.id && el._qDismissed.key === key;
}
function persistDraftsFromDom(host, state) {
host.querySelectorAll("[data-draft]").forEach((el) => {
const key = el.getAttribute("data-draft");
@@ -224,7 +259,9 @@ export function clearQuestionCardState(host, view) {
host._qState = null;
host.hidden = true;
host.innerHTML = "";
host.classList.remove("is-open");
host.classList.remove("is-open", "is-expanded", "has-pager", "has-skip");
host.removeAttribute("role");
host.removeAttribute("aria-label");
}
/**
@@ -238,7 +275,9 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
if (!active?.questions?.length) {
host.hidden = true;
host.innerHTML = "";
host.classList.remove("is-open");
host.classList.remove("is-open", "is-expanded", "has-pager", "has-skip");
host.removeAttribute("role");
host.removeAttribute("aria-label");
host._qDismissed = null;
return false;
}
@@ -251,7 +290,9 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
) {
host.hidden = true;
host.innerHTML = "";
host.classList.remove("is-open");
host.classList.remove("is-open", "is-expanded", "has-pager", "has-skip");
host.removeAttribute("role");
host.removeAttribute("aria-label");
return false;
}
@@ -264,11 +305,14 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
sessionId: view.id,
key: qKey,
page: 0,
expanded: false,
// questionId -> { optionId?, text, otherText? }
answers: {},
drafts: {}, // option key -> edited label
};
if (typeof state.expanded !== "boolean") state.expanded = false;
host._qState = state;
host.classList.toggle("is-expanded", state.expanded);
const { questions, pageSize, assessment, optional, skipLabel, dismissOnAccept } =
active;
@@ -303,20 +347,24 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
? `<button type="button" class="qcard-skip" data-qskip title="Esc">${esc(skipLabel || "跳过")}</button>`
: "";
const chromeClass = [
"qcard",
const chromeMods = [
pageCount > 1 ? "has-pager" : "",
optional ? "has-skip" : "",
]
.filter(Boolean)
.join(" ");
].filter(Boolean);
host.classList.remove("has-pager", "has-skip");
for (const c of chromeMods) host.classList.add(c);
host.setAttribute("role", "dialog");
host.setAttribute("aria-label", "询问");
host.innerHTML = `
<div class="${chromeClass}" role="dialog" aria-label="询问">
<div class="qcard-float">
${pagerHtml}
${skipHtml}
</div>
<button type="button" class="qcard-top" data-qtop title="${state.expanded ? "点击收起" : "点击展开"}" aria-label="${state.expanded ? "收起询问卡" : "展开询问卡"}">
<span class="qcard-top-grip" aria-hidden="true"></span>
</button>
<div class="qcard-body">
${dismissNote}
${assessmentHtml}
@@ -364,8 +412,43 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
</section>`;
})
.join("")}
</div>
</div>`;
</div>`;
const setExpanded = (next) => {
state.expanded = next;
host.classList.toggle("is-expanded", next);
const top = host.querySelector("[data-qtop]");
if (top) {
top.title = next ? "点击收起" : "点击展开";
top.setAttribute("aria-label", next ? "收起询问卡" : "展开询问卡");
}
};
host.querySelector("[data-qtop]")?.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
setExpanded(!state.expanded);
});
host.querySelector(".qcard-body")?.addEventListener("click", (e) => {
const t = e.target;
if (!(t instanceof Element)) return;
if (
t.closest(
".qcard-letter, .qcard-other-input, [contenteditable=true], .qcard-nav, .qcard-skip, .qcard-pager",
)
) {
return;
}
// 展开后点题干 / 评估区收起;收起时点内容区展开
if (state.expanded) {
if (t.closest(".qcard-prompt, .qcard-assessment, .qcard-optional-note")) {
setExpanded(false);
}
return;
}
setExpanded(true);
});
host.querySelector("[data-qskip]")?.addEventListener("click", () => {
_handlers.onSkipQuestions?.();
@@ -401,17 +484,37 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
text = (labelEl?.textContent ?? state.drafts[draftKey] ?? "").trim();
state.answers[qid] = { optionId: oid, text };
}
renderQuestionsCard(host, view, _handlers);
// 只切换选中态,避免整卡 innerHTML 重绘把 .qcard-body 滚回顶部
section.querySelectorAll(".qcard-opt").forEach((li) => {
li.classList.toggle("is-selected", li.getAttribute("data-oid") === oid);
});
if (!state.expanded) setExpanded(true);
_handlers.onAnswersChange?.();
});
});
host.querySelectorAll("[data-other]").forEach((el) => {
el.addEventListener("keydown", (e) => {
if (e.key !== "Enter" || e.shiftKey || e.isComposing) return;
e.preventDefault();
persistDraftsFromDom(host, state);
el.blur();
_handlers.onCardEnter?.();
});
el.addEventListener("input", () => _handlers.onAnswersChange?.());
});
host.querySelectorAll("[data-draft]").forEach((el) => {
el.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
el.blur();
}
if (e.key !== "Enter" || e.shiftKey || e.isComposing) return;
e.preventDefault();
persistDraftsFromDom(host, state);
const key = el.getAttribute("data-draft");
if (key) state.drafts[key] = el.textContent ?? "";
el.blur();
_handlers.onCardEnter?.();
});
el.addEventListener("input", () => _handlers.onAnswersChange?.());
el.addEventListener("blur", () => {
const key = el.getAttribute("data-draft");
if (key) state.drafts[key] = el.textContent ?? "";
+213 -76
View File
@@ -1,62 +1,124 @@
/* 呈现壳预览页
* 注意:本页也引入 styles.css(present 样式),会给 body 设 overflow:hidden;
* 必须在此覆盖,否则侧栏后几项点不到。
*/
/* 呈现壳预览页 — 主工作面 = 壳画布;侧栏只负责换壳 */
html,
body.shells-page {
height: auto;
min-height: 100%;
overflow: auto;
background: var(--st-bg, #f4f4f5);
}
.shells-page .st-shell.shells-shell {
max-width: 1180px;
max-width: 1280px;
align-items: start;
grid-template-columns: 200px minmax(0, 1fr);
}
.shells-page .st-rail {
--st-rail-w: 220px;
width: var(--st-rail-w);
width: 200px;
position: sticky;
top: var(--st-top-h, 48px);
max-height: calc(100dvh - var(--st-top-h, 48px));
overflow-y: auto;
min-height: 0;
align-self: start;
gap: 2px;
}
.shells-tone-label {
margin-top: 1rem !important;
}
.shells-density {
display: flex;
flex-wrap: wrap;
gap: 6px 10px;
align-items: baseline;
margin: 0.5rem 0 0.35rem;
padding: 8px 10px;
border-radius: 8px;
background: color-mix(in srgb, var(--accent-soft, #e8f0fe) 70%, transparent);
font-size: 13px;
line-height: 1.45;
}
.shells-density span {
.st-rail-hint {
margin: 0 8px 10px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--muted, #666);
text-transform: uppercase;
line-height: 1.45;
color: var(--st-faint, #a1a1aa);
}
.shells-list {
display: flex;
flex-direction: column;
gap: 4px;
gap: 2px;
flex: 0 0 auto;
}
.shells-list .st-rail-item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 8px 10px;
line-height: 1.25;
}
.shells-list .shell-item-name {
font-size: 13px;
font-weight: 600;
color: inherit;
}
.shells-list .shell-item-task {
font-size: 11px;
font-weight: 400;
color: var(--st-faint, #a1a1aa);
}
.shells-list .st-rail-item.active .shell-item-task {
color: color-mix(in srgb, var(--st-accent, #2563eb) 65%, #71717a);
}
.shells-main {
min-height: calc(100dvh - var(--st-top-h, 48px));
display: flex;
flex-direction: column;
}
.shells-toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px 20px;
padding: 14px 24px 12px;
border-bottom: 1px solid var(--st-border, #e4e4e7);
background: var(--st-surface, #fff);
position: sticky;
top: var(--st-top-h, 48px);
z-index: 3;
}
.shells-toolbar-block {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.shells-toolbar-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--st-faint, #a1a1aa);
}
.shells-toolbar-meta {
margin-left: auto;
align-items: flex-end;
text-align: right;
max-width: 280px;
}
.shells-toolbar-meta .shells-meta-title {
margin: 0;
font-size: 14px;
font-weight: 650;
color: var(--st-text, #18181b);
}
.shells-toolbar-meta .shells-meta-sub {
margin: 2px 0 0;
font-size: 12px;
color: var(--st-muted, #71717a);
line-height: 1.4;
}
.shells-tones {
display: flex;
flex-wrap: wrap;
@@ -66,65 +128,102 @@ body.shells-page {
.shells-tone-chip {
font: inherit;
font-size: 12px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--border, #ddd);
background: var(--surface, #fff);
padding: 5px 10px;
border-radius: 6px;
border: 1px solid var(--st-border, #e4e4e7);
background: var(--st-surface, #fff);
cursor: pointer;
color: inherit;
color: var(--st-muted, #71717a);
}
.shells-tone-chip:hover {
background: #f4f4f5;
color: var(--st-text, #18181b);
}
.shells-tone-chip:focus-visible {
outline: 2px solid var(--st-accent, #2563eb);
outline-offset: 2px;
}
.shells-tone-chip.active {
border-color: transparent;
background: var(--accent-soft, #e8f0fe);
background: var(--st-accent-soft, #dbeafe);
color: var(--st-accent, #2563eb);
font-weight: 600;
}
.shells-body {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.shells-meta p {
margin: 0 0 0.35rem;
line-height: 1.5;
}
.shells-preview-label,
.shells-tweaks h2,
.shells-help h2 {
margin: 0 0 0.5rem;
font-size: 13px;
font-weight: 700;
color: var(--muted, #666);
/* 主工作面:尽量大的阅读画布 */
.shells-preview-wrap {
flex: 1 1 auto;
padding: 20px 24px 28px;
background: #ebebed;
}
.shells-preview {
min-height: 280px;
padding: 16px;
border-radius: 12px;
border: 1px dashed var(--border, #ccc);
background:
radial-gradient(ellipse at 20% 0%, rgba(15, 118, 110, 0.06), transparent 50%),
var(--bg, #f6f6f4);
min-height: min(70vh, 720px);
padding: 20px 16px 28px;
border-radius: 10px;
border: 1px solid var(--st-border, #e4e4e7);
background: #f4f4f5;
overflow: visible;
}
.shells-preview .present-shell {
max-width: 640px;
max-width: 42rem;
margin: 0 auto;
box-shadow: 0 10px 28px rgba(28, 25, 23, 0.07);
box-shadow: none;
}
.shells-preview[data-shell="turn_panel"] .present-shell,
.shells-preview[data-shell="split_board"] .present-shell,
.shells-preview[data-shell="chapter_reader"] .present-shell {
max-width: 780px;
max-width: 52rem;
}
/* 预览区不裁切长正文 */
.shells-preview {
overflow: visible;
.shells-preview .present-shell-badge {
display: none;
}
.shells-details {
margin: 0 24px 28px;
border: 1px solid var(--st-border, #e4e4e7);
border-radius: 8px;
background: var(--st-surface, #fff);
}
.shells-details > summary {
cursor: pointer;
padding: 12px 14px;
font-size: 13px;
font-weight: 600;
color: var(--st-muted, #71717a);
list-style: none;
}
.shells-details > summary::-webkit-details-marker {
display: none;
}
.shells-details > summary::before {
content: "▸ ";
color: var(--st-faint, #a1a1aa);
}
.shells-details[open] > summary::before {
content: "▾ ";
}
.shells-details[open] > summary {
border-bottom: 1px solid var(--st-border, #e4e4e7);
color: var(--st-text, #18181b);
}
.shells-details-body {
padding: 14px 16px 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.shells-tweak-grid {
@@ -144,30 +243,68 @@ body.shells-page {
font: inherit;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--border, #ddd);
border: 1px solid var(--st-border, #e4e4e7);
max-width: 320px;
}
.shells-help ul {
.shells-help-list {
margin: 0;
padding-left: 1.2rem;
padding-left: 1.15rem;
line-height: 1.55;
font-size: 13px;
}
.shells-help .muted {
font-size: 12px;
color: var(--muted, #888);
color: var(--st-muted, #71717a);
}
@media (max-width: 800px) {
.shells-page .st-shell.shells-shell {
grid-template-columns: 1fr;
}
.shells-page .st-rail {
position: static;
max-height: none;
width: auto;
overflow: visible;
border-right: none;
border-bottom: 1px solid var(--st-border, #e4e4e7);
}
.shells-list {
flex-direction: row;
flex-wrap: wrap;
}
.shells-list .st-rail-item {
width: auto;
max-width: 46%;
}
.st-rail-hint,
.st-rail-note {
display: none;
}
.shells-toolbar {
position: static;
}
.shells-toolbar-meta {
margin-left: 0;
text-align: left;
align-items: flex-start;
max-width: none;
width: 100%;
}
.shells-preview-wrap {
padding: 12px;
}
.shells-preview .present-shell {
max-width: 100%;
}
}
.shells-details {
margin: 0 12px 20px;
}
}
+29 -31
View File
@@ -28,44 +28,42 @@
<div class="st-shell shells-shell">
<aside class="st-rail" aria-label="壳列表">
<p class="st-rail-label">呈现壳</p>
<p class="st-rail-label">选壳</p>
<p class="st-rail-hint">按「要同时看见什么」选,不是按好看选。</p>
<nav class="shells-list" id="shell-list" role="tablist"></nav>
<p class="st-rail-label shells-tone-label">装饰语气</p>
<div class="shells-tones" id="tone-list" role="group" aria-label="tone_chrome"></div>
<p class="st-rail-label shells-tone-label">正文长度</p>
<div class="shells-tones" id="length-list" role="group" aria-label="body length"></div>
<p class="st-rail-note">
正文按 600~3000 字设计:随字数撑高、页面滚动;侧栏/选择坞钉住。创作只改内容绑定。
主任务永远是读正文(常 600~3000 字)。侧栏只换壳;长度/语气在预览顶栏。
</p>
</aside>
<main class="st-main">
<header class="st-main-head">
<div>
<h1 id="panel-title">呈现壳预览</h1>
<p class="st-sub" id="panel-subtitle">用示例数据看布局与可调项</p>
<main class="st-main shells-main">
<div class="shells-toolbar" role="toolbar" aria-label="预览条件">
<div class="shells-toolbar-block">
<span class="shells-toolbar-label" id="length-label">正文长度</span>
<div class="shells-tones" id="length-list" role="group" aria-labelledby="length-label"></div>
</div>
</header>
<div class="st-body shells-body">
<section class="shells-meta" id="shell-meta"></section>
<section class="shells-preview-wrap">
<h2 class="shells-preview-label">预览</h2>
<div id="shell-preview" class="shells-preview"></div>
</section>
<section class="shells-tweaks">
<h2>本页可调(对应创作「微调轴」)</h2>
<div class="shells-tweak-grid" id="tweak-controls"></div>
</section>
<section class="shells-help">
<h2>设计意图</h2>
<ul>
<li>正文默认装得下单轮 <strong>600~3000 字</strong>:区域随内容长高,由页面/消息区滚动,不塞进固定小框。</li>
<li>双栏壳:侧栏 sticky;选择坞:选项区 sticky 贴底——长文读完控件还在。</li>
<li>壳差在「谁占主柱」;可改的是字段/显示名/行动开关/tone,不是重画布局。</li>
</ul>
<p class="muted">说明文档:<code>docs/play-presentation-shells.md</code></p>
</section>
<div class="shells-toolbar-block">
<span class="shells-toolbar-label" id="tone-label">语气</span>
<div class="shells-tones" id="tone-list" role="group" aria-labelledby="tone-label"></div>
</div>
<div class="shells-toolbar-block shells-toolbar-meta" id="shell-meta"></div>
</div>
<section class="shells-preview-wrap" aria-label="壳预览主工作面">
<div id="shell-preview" class="shells-preview"></div>
</section>
<details class="shells-details">
<summary>微调轴与说明</summary>
<div class="shells-details-body">
<div class="shells-tweak-grid" id="tweak-controls"></div>
<ul class="shells-help-list">
<li>主工作面 = 上面的壳。读长文时正文撑高,页面滚动;侧栏 / 选择坞钉住。</li>
<li>壳差在「必须同时看见什么」:只正文 / 正文+状态 / 正文+线索 / 正文+选项…</li>
<li>创作只改字段绑定与显示名,不发明新布局。文档:<code>docs/play-presentation-shells.md</code></li>
</ul>
</div>
</details>
</main>
</div>
+41 -52
View File
@@ -1,71 +1,64 @@
/**
* 呈现壳预览页:切换壳 / 装饰语气 / 正文长度,用示例数据渲染 present-shells。
* 正文设计目标:单轮常见 600~3000 字 —— 随字数撑高,外层滚动;侧栏/选项坞钉住。
* 呈现壳预览:主工作面 = 壳画布。
* 按「要同时看见什么」选壳;正文按 600~3000 字撑高滚动。
*/
import {
PRESENT_SHELL_IDS,
renderPresentShellHtml,
} from "./present-shells.js";
/** task = 同时可见关系(选壳依据);name = 短名 */
const SHELL_INFO = {
prose: {
name: "纯散文",
blurb: "整块都是正文,无 HUD。长文直接往下滚。",
density: "正文随字数伸展;无侧栏争面积",
scenes: "轻对话、纯叙事、不要状态栏",
task: "只要正文",
blurb: "无 HUD。读完即止。",
},
chat_monitor: {
name: "对话 + 监控",
blurb: "顶栏芯片固定矮;主聊/场面再长也只往下长。",
density: "监控一行;正文撑高滚动",
scenes: "网恋 / 日常扮演 / 轻 AIRP",
name: "对话+监控",
task: "正文 + 扫状态",
blurb: "顶栏芯片矮;主聊/场面往下长。",
},
spotlight: {
name: "场面主视",
blurb: "芯片一行 + 大正文柱。适合一幕写满。",
density: "正文主导并伸展;监控不占高",
scenes: "电影感 RP、一幕一景、少机制",
task: "大正文 + 弱状态",
blurb: "无聊天气泡框;场面即画布。",
},
turn_panel: {
name: "回合面板",
blurb: "场面叙述再长,右侧交互物钉在视口内对照。",
density: "正文撑高;侧栏 sticky;行动在下",
scenes: "资源、检定、场景交互、跑团感",
task: "场面 + 物 + 行动",
blurb: "对照交互物;行动在下。",
},
split_board: {
name: "双栏看板",
blurb: "左栏长文滚动,右栏线索钉住——对照读。",
density: "左正文伸展;右侧栏 sticky",
scenes: "调查、推理、多线索并行",
task: "正文 ‖ 线索",
blurb: "左右对照;侧栏钉住。",
},
choice_dock: {
name: "选择坞",
blurb: "先读完长局面;选项坞 sticky 贴视口底,不用翻回顶。",
density: "正文撑高;底部坞钉住",
scenes: "选项驱动、AVG、分支关口",
task: "局面 + 选项",
blurb: "读完再选;坞贴视口底。",
},
chapter_reader: {
name: "章节阅读",
blurb: "章题 + 长阅读柱;窄进度侧栏 sticky。",
density: "正文伸展为主;侧栏窄且钉住",
scenes: "长文 / 爽文 / 先纲后章",
task: "章正文 + 进度",
blurb: "阅读柱为主;进度窄栏钉住。",
},
};
const TONES = [
{ id: "default", label: "默认" },
{ id: "messenger", label: "讯息感" },
{ id: "book", label: "书页感" },
{ id: "terminal", label: "终端感" },
{ id: "messenger", label: "讯息" },
{ id: "book", label: "书页" },
{ id: "terminal", label: "终端" },
];
const LENGTHS = [
{ id: "short", label: "短(~120)" },
{ id: "mid", label: "中(~800)" },
{ id: "long", label: "长(~2500)" },
{ id: "short", label: "短" },
{ id: "mid", label: "中 ·800" },
{ id: "long", label: "长 ·2500" },
];
/** 中等长度 ≈800 字 */
const BODY_MID = `雨还在下。你把伞往她那边偏了偏,她没说话,只是把购物袋往怀里收紧了一点。水从伞骨边缘连成线,砸在柏油路上,溅起细碎的白点。
路口的灯跳成绿色。你们一起迈步——这一次,谁也没有先松开。对面便利店的玻璃上映着两个人并肩的轮廓,忽明忽暗,像谁随手画的草稿。
@@ -82,7 +75,6 @@ const BODY_MID = `雨还在下。你把伞往她那边偏了偏,她没说话
门开了一条缝。暖光漏出来,照在你们脚边的水渍上。她没有立刻进去,只是抬眼看你,像在等一句还没说出口的话。你把伞收拢,水仍顺着伞尖滴到地毯边缘。谁也没有提「进来坐坐」,但空气里已经有了那句话的形状。`;
/** 长文 ≈2500 字(重复段落拼成,预览用) */
const BODY_LONG = `${BODY_MID}
天桥下的风从另一头灌过来,把塑料袋吹得啪啪响。你想起上一次走过这里,还是夏天,蝉声吵得人没法好好说话;如今只剩雨,以及偶尔驶过的车灯,把积水切成一条条亮痕。
@@ -115,9 +107,7 @@ const BODY_LONG = `${BODY_MID}
「有的不用还。」她看着河对岸的塔吊,「有的还了,反而像没发生过。」
塔吊缓缓旋转,吊臂在灰蓝天空里划出钝角。你忽然明白,呈现壳要解决的不是「字大一点」,而是这一长段话落在界面里时,读者还愿不愿意往下看——状态栏不能抢戏,选项不能在中途消失,侧栏得钉住好让人对照线索。
你们在桥上站了一会儿。风把伞套吹得鼓起,又瘪下去。她把热饮塞进你手里:「暖的。你昨晚肯定没睡好。」你没有否认。杯壁的温度从掌心爬上手腕,像把雨夜重新烘了一遍,只留下能说出口的那一层。
塔吊缓缓旋转,吊臂在灰蓝天空里划出钝角。你们在桥上站了一会儿。风把伞套吹得鼓起,又瘪下去。她把热饮塞进你手里:「暖的。你昨晚肯定没睡好。」你没有否认。杯壁的温度从掌心爬上手腕,像把雨夜重新烘了一遍,只留下能说出口的那一层。
分开前,她抬手点了点伞:「这个,先放你那儿。等下一场雨。」
@@ -231,7 +221,7 @@ function buildTweaks() {
show_suggested_actions: state.showActions,
block_labels: { ...state.blockLabels },
empty_states: {
aside: "(侧栏空:可在创作里改空态文案)",
aside: "(侧栏空)",
footer: "(文末空)",
},
};
@@ -252,27 +242,25 @@ function buildPacket() {
function render() {
const info = SHELL_INFO[state.shell] || {
name: state.shell,
task: "",
blurb: "",
scenes: "",
density: "",
};
const packet = buildPacket();
document.getElementById("panel-title").textContent = info.name;
document.getElementById("panel-subtitle").textContent = `${info.scenes} · 正文约 ${packet._chars} 字(不计空白)`;
document.getElementById("shell-meta").innerHTML = `
<p><strong>${esc(info.name)}</strong>(<code>${esc(state.shell)}</code>)</p>
<p>${esc(info.blurb)}</p>
<p class="shells-density"><span>长文策略</span>${esc(info.density)} · 当前试读 <strong>${packet._chars}</strong> 字</p>
<p class="muted">适用:${esc(info.scenes)}。请用上方「正文长度」切到长文,确认侧栏/选项是否仍可用。</p>
<p class="shells-meta-title">${esc(info.name)}</p>
<p class="shells-meta-sub">${esc(info.task)} · 试读 ${packet._chars} 字<br>${esc(info.blurb)}</p>
`;
const preview = document.getElementById("shell-preview");
preview.dataset.shell = state.shell;
preview.innerHTML = renderPresentShellHtml(packet, esc, { tweaks: buildTweaks() });
preview.innerHTML = renderPresentShellHtml(packet, esc, {
tweaks: buildTweaks(),
});
document.querySelectorAll("[data-shell]").forEach((btn) => {
btn.classList.toggle("active", btn.getAttribute("data-shell") === state.shell);
btn.setAttribute("aria-selected", btn.getAttribute("data-shell") === state.shell ? "true" : "false");
});
document.querySelectorAll("[data-tone]").forEach((btn) => {
btn.classList.toggle("active", btn.getAttribute("data-tone") === state.tone);
@@ -289,8 +277,9 @@ function mount() {
const list = document.getElementById("shell-list");
list.innerHTML = PRESENT_SHELL_IDS.map((id) => {
const info = SHELL_INFO[id];
return `<button type="button" class="st-rail-item" data-shell="${esc(id)}" role="tab">
${esc(info?.name || id)}
return `<button type="button" class="st-rail-item" data-shell="${esc(id)}" role="tab" aria-selected="false">
<span class="shell-item-name">${esc(info?.name || id)}</span>
<span class="shell-item-task">${esc(info?.task || "")}</span>
</button>`;
}).join("");
@@ -307,15 +296,15 @@ function mount() {
document.getElementById("tweak-controls").innerHTML = `
<label class="field">
<input type="checkbox" id="tweak-show-actions" />
显示「建议行动」(对应微调轴 show_suggested_actions)
显示建议行动(show_suggested_actions)
</label>
<label class="field">
正文区显示名(block_labels.body)
<input type="text" id="tweak-label-body" placeholder="例如:短信记录 / 场面 / 本章" />
正文显示名(block_labels.body)
<input type="text" id="tweak-label-body" placeholder="短信记录 / 场面 / 本章" />
</label>
<label class="field">
监控区显示名(block_labels.monitor)
<input type="text" id="tweak-label-monitor" placeholder="例如:状态 / 今日" />
监控显示名(block_labels.monitor)
<input type="text" id="tweak-label-monitor" placeholder="状态 / 今日" />
</label>
`;
+1872 -147
View File
File diff suppressed because it is too large Load Diff