完善创作节点循环与等待态交互,并大幅打磨 Web 壳与会话运行时。
- 节点循环:配方开局直坐 DAG 第一步;验收后先问下一步意向,再确认开干并可钉编排参数;「按意见修改」只重跑当前节点。 - 询问分流:能力 opening 走说话面引导,可跳过追问按题干去重;追问与产物同轮挂载,避免拆成两段历史。 - 运行时:补强 revision 重跑、创作流程合并/进度指针、工具循环与 worker 执行;新增运行日志与 web:watch。 - 前端:统一等待态文案与底栏主按钮,完善询问卡/产物验收/呈现壳样式与交互。 - 同步世界模拟器模块提示、编排文档与相关测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1164
web/agent-ui.js
1164
web/agent-ui.js
File diff suppressed because it is too large
Load Diff
891
web/app.js
891
web/app.js
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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, "&")
|
||||
@@ -14,6 +19,22 @@ function esc(s) {
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** 当前等待是否为能力默认问题(应呈现为开场引导,而非答题卡) */
|
||||
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 ?? "";
|
||||
|
||||
289
web/shells.css
289
web/shells.css
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
|
||||
2021
web/styles.css
2021
web/styles.css
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user