@pioneer_zmc/dsh-workbench 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/README.zh.md +100 -0
- package/client.js +122 -0
- package/cordis.patch.yml +8 -0
- package/dist/check-building.js +1669 -0
- package/dist/check-room.js +980 -0
- package/dist/index.js +2873 -0
- package/package.json +60 -0
- package/skills/specdev-building/SKILL.md +85 -0
- package/skills/specdev-building/assets/layouts/court.json +53 -0
- package/skills/specdev-building/assets/layouts/row.json +48 -0
- package/skills/specdev-building/assets/layouts/single.json +27 -0
- package/skills/specdev-building/assets/layouts/tower-9.json +71 -0
- package/skills/specdev-building/assets/layouts/tower.json +59 -0
- package/skills/specdev-building/references/blueprint-contract.md +126 -0
- package/skills/specdev-building/references/diagnostics.md +101 -0
- package/skills/specdev-building/references/layouts.md +112 -0
- package/skills/specdev-business/SKILL.md +91 -0
- package/skills/specdev-business/assets/plan-template.md +66 -0
- package/skills/specdev-business/assets/spec-template.md +111 -0
- package/skills/specdev-business/assets/tasks-template.md +36 -0
- package/skills/specdev-business/references/change.md +117 -0
- package/skills/specdev-business/references/evidence-review.md +125 -0
- package/skills/specdev-business/references/implementation.md +67 -0
- package/skills/specdev-business/references/project-contract.md +211 -0
- package/skills/specdev-business/references/source-map.md +110 -0
- package/skills/specdev-business/references/specification.md +93 -0
- package/skills/specdev-business/references/workflow.md +126 -0
- package/skills/specdev-room/SKILL.md +77 -0
- package/skills/specdev-room/assets/intro-room.json +14 -0
- package/skills/specdev-room/references/diagnostics.md +21 -0
- package/skills/specdev-room/references/room-contract.md +59 -0
- package/vendor/archify-renderer/VERSION.md +52 -0
- package/vendor/archify-renderer/archify/LICENSE +22 -0
- package/vendor/archify-renderer/archify/THIRD_PARTY_NOTICES.md +56 -0
- package/vendor/archify-renderer/archify/assets/template.html +14787 -0
- package/vendor/archify-renderer/archify/bin/archify.mjs +1990 -0
- package/vendor/archify-renderer/archify/renderers/shared/brand-marks.mjs +563 -0
- package/vendor/archify-renderer/archify/renderers/shared/cli.mjs +218 -0
- package/vendor/archify-renderer/archify/renderers/shared/desktop-readability.mjs +26 -0
- package/vendor/archify-renderer/archify/renderers/shared/diagnostics.mjs +127 -0
- package/vendor/archify-renderer/archify/renderers/shared/engineering-profiles.mjs +157 -0
- package/vendor/archify-renderer/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
- package/vendor/archify-renderer/archify/renderers/shared/generated-validators.mjs +13 -0
- package/vendor/archify-renderer/archify/renderers/shared/geometry.mjs +1423 -0
- package/vendor/archify-renderer/archify/renderers/shared/i18n.mjs +594 -0
- package/vendor/archify-renderer/archify/renderers/shared/legend.mjs +217 -0
- package/vendor/archify-renderer/archify/renderers/shared/output-path.mjs +321 -0
- package/vendor/archify-renderer/archify/renderers/shared/repository-evidence.mjs +235 -0
- package/vendor/archify-renderer/archify/renderers/shared/text-fit.mjs +49 -0
- package/vendor/archify-renderer/archify/renderers/shared/utils.mjs +232 -0
- package/vendor/archify-renderer/archify/renderers/shared/validator.mjs +86 -0
- package/vendor/archify-renderer/archify/renderers/workflow/render-workflow.mjs +35 -0
- package/vendor/archify-renderer/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
- package/vendor/archify-renderer/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
- package/vendor/archify-renderer/archify/schemas/common.schema.json +115 -0
- package/vendor/archify-renderer/archify/schemas/workflow.schema.json +428 -0
- package/vendor/archify-renderer/archify/scripts/check-render-output.mjs +835 -0
- package/vendor/archify-renderer/shims/process.mjs +18 -0
- package/vendor/archify-renderer/stubs/child_process.mjs +2 -0
- package/vendor/archify-renderer/stubs/crypto.mjs +3 -0
- package/vendor/archify-renderer/stubs/dns-promises.mjs +2 -0
- package/vendor/archify-renderer/stubs/fs.mjs +3 -0
- package/vendor/archify-renderer/stubs/http.mjs +3 -0
- package/vendor/archify-renderer/stubs/https.mjs +3 -0
- package/vendor/archify-renderer/stubs/net.mjs +3 -0
- package/vendor/archify-renderer/stubs/path.mjs +3 -0
- package/vendor/archify-renderer/stubs/url.mjs +3 -0
- package/web/assets/building-kit.js +1157 -0
- package/web/assets/building-look.js +185 -0
- package/web/assets/building-plan.js +495 -0
- package/web/assets/building-scene.js +529 -0
- package/web/assets/building-walk.js +248 -0
- package/web/assets/building.js +859 -0
- package/web/assets/business.js +124 -0
- package/web/assets/chart-link.js +165 -0
- package/web/assets/common.js +302 -0
- package/web/assets/details.js +197 -0
- package/web/assets/evidence.js +30 -0
- package/web/assets/furniture/SOURCES.md +24 -0
- package/web/assets/furniture/assets/ceramic-display-stand-01/asset.json +25 -0
- package/web/assets/furniture/assets/ceramic-display-stand-01/model.js +116 -0
- package/web/assets/furniture/assets/ceramic-display-stand-01/preview.webp +0 -0
- package/web/assets/furniture/assets/ceramic-floor-lamp-01/asset.json +61 -0
- package/web/assets/furniture/assets/ceramic-floor-lamp-01/model.js +56 -0
- package/web/assets/furniture/assets/ceramic-floor-lamp-01/preview.webp +0 -0
- package/web/assets/furniture/assets/ceramic-low-cabinet-01/asset.json +81 -0
- package/web/assets/furniture/assets/ceramic-low-cabinet-01/model.js +67 -0
- package/web/assets/furniture/assets/ceramic-low-cabinet-01/preview.webp +0 -0
- package/web/assets/furniture/assets/ceramic-vase-01/asset.json +21 -0
- package/web/assets/furniture/assets/ceramic-vase-01/model.js +119 -0
- package/web/assets/furniture/assets/ceramic-vase-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-bed-01/asset.json +61 -0
- package/web/assets/furniture/assets/fairy-bed-01/model.js +63 -0
- package/web/assets/furniture/assets/fairy-bed-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-book-stack-01/asset.json +21 -0
- package/web/assets/furniture/assets/fairy-book-stack-01/model.js +71 -0
- package/web/assets/furniture/assets/fairy-book-stack-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-bookshelf-01/asset.json +32 -0
- package/web/assets/furniture/assets/fairy-bookshelf-01/model.js +168 -0
- package/web/assets/furniture/assets/fairy-bookshelf-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-desk-01/asset.json +25 -0
- package/web/assets/furniture/assets/fairy-desk-01/model.js +132 -0
- package/web/assets/furniture/assets/fairy-desk-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-notice-board-01/asset.json +23 -0
- package/web/assets/furniture/assets/fairy-notice-board-01/model.js +125 -0
- package/web/assets/furniture/assets/fairy-notice-board-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-potted-plant-01/asset.json +21 -0
- package/web/assets/furniture/assets/fairy-potted-plant-01/model.js +128 -0
- package/web/assets/furniture/assets/fairy-potted-plant-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-reading-chair-01/asset.json +61 -0
- package/web/assets/furniture/assets/fairy-reading-chair-01/model.js +66 -0
- package/web/assets/furniture/assets/fairy-reading-chair-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-table-lamp-01/asset.json +21 -0
- package/web/assets/furniture/assets/fairy-table-lamp-01/model.js +182 -0
- package/web/assets/furniture/assets/fairy-table-lamp-01/preview.webp +0 -0
- package/web/assets/furniture/catalog.js +137 -0
- package/web/assets/furniture/index.json +110 -0
- package/web/assets/furniture/registry.js +72 -0
- package/web/assets/furniture-page.css +68 -0
- package/web/assets/furniture-page.js +302 -0
- package/web/assets/home.js +65 -0
- package/web/assets/node-detail.js +148 -0
- package/web/assets/panel-link.js +47 -0
- package/web/assets/read.js +746 -0
- package/web/assets/room-scene.js +386 -0
- package/web/assets/room.css +287 -0
- package/web/assets/room.js +437 -0
- package/web/assets/rooms/bindings.js +449 -0
- package/web/assets/rooms/ceramic-reflection.js +193 -0
- package/web/assets/rooms/ceramic-room.js +302 -0
- package/web/assets/rooms/door.js +35 -0
- package/web/assets/rooms/fairy-room.js +492 -0
- package/web/assets/rooms/furniture-assembly.js +113 -0
- package/web/assets/rooms/navigation.js +262 -0
- package/web/assets/rooms/placeholder-catalog.json +71 -0
- package/web/assets/rooms/placeholder-furniture.js +76 -0
- package/web/assets/rooms/placement.js +588 -0
- package/web/assets/rooms/reader.js +441 -0
- package/web/assets/rooms/reading-gate.js +157 -0
- package/web/assets/rooms/room-lighting.js +50 -0
- package/web/assets/rooms/templates.json +50 -0
- package/web/assets/rooms/walk-frame.js +64 -0
- package/web/assets/rooms/walk-session.js +138 -0
- package/web/assets/save-result.js +89 -0
- package/web/assets/screenshots/business.png +0 -0
- package/web/assets/screenshots/node-detail.png +0 -0
- package/web/assets/screenshots/project-home.png +0 -0
- package/web/assets/screenshots/workflow.png +0 -0
- package/web/assets/showcase.js +232 -0
- package/web/assets/style.css +836 -0
- package/web/assets/template.js +7 -0
- package/web/assets/theme.js +29 -0
- package/web/assets/three/OrbitControls.js +1972 -0
- package/web/assets/three/VERSION.md +12 -0
- package/web/assets/three/three.core.js +60586 -0
- package/web/assets/three/three.module.js +19719 -0
- package/web/assets/workspaces.css +21 -0
- package/web/assets/workspaces.js +81 -0
- package/web/building.html +131 -0
- package/web/business.html +43 -0
- package/web/furniture.html +47 -0
- package/web/index.html +57 -0
- package/web/read.html +107 -0
- package/web/room.html +146 -0
- package/web/showcase.html +50 -0
- package/web/workspaces.html +32 -0
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
// 图阅读页:版本条 + 原生图现场渲染(步骤 4/A·补神:原生装配恢复工具栏/主题/缩放/悬停说明;
|
|
2
|
+
// B·渲染失败说人话:三类失败给中文原因与换版本建议,版本条不再被报错顶掉;
|
|
3
|
+
// C·说明文档分块:details.md 按 `## 节点 id` 分节贴上页面,与所选版本同源;
|
|
4
|
+
// D·源码证据:evidence.json 逐条展示——有效引用贴带行号的代码片段,坏引用按条说原因,
|
|
5
|
+
// 片段永远取自引用写定的固定提交;双请求错配修复:第二次请求(解析证据)带着第一次
|
|
6
|
+
// 响应里的清单原文去问(POST 正文),服务端只按提交来的正文解析、不重读磁盘——
|
|
7
|
+
// 两次请求之间文件被保存,图与证据也不会来自两个版本;
|
|
8
|
+
// 评审二修:比较失败单独成态不拖垮清单与阅读、"当前"可从版本栏一键切回、证据请求
|
|
9
|
+
// 不再阻塞版本条、快照登记的保存说明与时间上屏、坏节点不二次崩页、装配前剥离
|
|
10
|
+
// 模板外链字体满足零外网;
|
|
11
|
+
// 超限死路收尾:读不开(超上限)的文件降级为该版本自己的说明——失败卡分"缺失/读不开",
|
|
12
|
+
// 说明与证据面板如实报"读不开",页面与版本条始终活着,历史照样能切)。
|
|
13
|
+
// E·选中同步:图里被原生选中的步骤,在外层状态条上显示"已选:<图上名字>";
|
|
14
|
+
// chart-link.js 观察原生选中标记,并适配节点双击;不改 vendor;
|
|
15
|
+
// 接不上有尽头——load 后仍进不去、或一直等不到 load,都明确说一句,不无限等(复审修复)。
|
|
16
|
+
// F·节点详情阅读:状态条上「查看详情」弹出可关闭的详情层,读该节点在当前所选版本里的那一节
|
|
17
|
+
// 说明(与说明面板同一个文件、同一个版本,不发新请求);没写/没文档/读不开/未分节各如实说;
|
|
18
|
+
// 说明正文里代码示例的 ## 不冒充分节,同一编号写多节时提示"显示的是第一份"(复审修复)。
|
|
19
|
+
// G·保存版本:正看"当前(工作区)"时版本条上有「保存版本」,弹层里填名称/阶段/说明即可把
|
|
20
|
+
// 已提交的图内容登记成一版。打开弹层先问一次"现在能不能存",并把服务端给的内容摘要与
|
|
21
|
+
// 页面正看的那一份对一下——页面旧了、或还有没提交的改动,就地说明并禁用保存;
|
|
22
|
+
// 提交时把确认过的提交号与摘要一起送回,服务端再核一遍(双保险)。连点只存一次;
|
|
23
|
+
// 保存成功后原地刷新版本条与状态行,图与说明、证据面板都不动。
|
|
24
|
+
// 复查修复(审查 P1/P2):两条状态规则搬到 save-result.js(能直接跑断言)——
|
|
25
|
+
// ① 页面正展示内容的摘要只由"加载并展示内容"写定,刷新版本条不覆盖它;
|
|
26
|
+
// ② 保存请求的成功只认"状态成功 + 正文完整",并区分"版本已写上、只是没核完"。
|
|
27
|
+
import { $, el, fetchJson, pageTitle, postJson, renderEmptyWorkspaceParam, renderGuide, renderRepoLine, renderRepoState, setStatus, showError, workspaceParamEmpty, wsUrl } from './common.js'
|
|
28
|
+
import { buildDetails, parseBody, splitInline } from './details.js'
|
|
29
|
+
import { describeSource, displayError, evidenceSummary, formatCodeBlock } from './evidence.js'
|
|
30
|
+
import { stripExternalFonts } from './template.js'
|
|
31
|
+
import { connectChartSelection } from './chart-link.js'
|
|
32
|
+
import { loadDetailEvidence, renderNodeDetail } from './node-detail.js'
|
|
33
|
+
import { forwardEscapeToHost } from './panel-link.js'
|
|
34
|
+
// 保存弹层的两条状态规则(自查放行与否、请求结果怎么算);摘要在那个模块里保管,见文件头注释
|
|
35
|
+
import { rememberShown, shownFingerprint, saveGate, saveOutcome } from './save-result.js'
|
|
36
|
+
// process 垫片必须先于编译器求值(Archify 模块顶层读 process.env;浏览器里没有 process)。
|
|
37
|
+
import '/specdev-workbench/vendor/shims/process.mjs'
|
|
38
|
+
|
|
39
|
+
const COMPILER_URL = '/specdev-workbench/vendor/archify/renderers/workflow/workflow-compiler.mjs'
|
|
40
|
+
const UTILS_URL = '/specdev-workbench/vendor/archify/renderers/shared/utils.mjs'
|
|
41
|
+
const TEMPLATE_URL = '/specdev-workbench/vendor/archify/assets/template.html'
|
|
42
|
+
|
|
43
|
+
// 图里当前选中的那个步骤(恰好一个才算选中):状态条与详情弹层共用同一份,未选中为 null。
|
|
44
|
+
// 它只是"记着当前是谁",节点清单与说明正文都取本次已加载的版本,点开详情不再取文件。
|
|
45
|
+
let currentSelection = null
|
|
46
|
+
|
|
47
|
+
// 本页状态(保存成功后要原地重画版本条与状态行,不整页刷新、不动图):
|
|
48
|
+
// business/chart/v 是地址里的三项,data 是这次加载的页面数据,rendered/failure 是本次渲染凭据。
|
|
49
|
+
let pageState = null
|
|
50
|
+
// 保存弹层打开时那次检查的结果(确认过的提交号与服务端摘要),提交时原样送回服务端再核一遍。
|
|
51
|
+
let saveCheck = null
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 当场编译 + 按 Archify 原生装配(applyTemplate)拼出完整页面,装进 iframe。
|
|
55
|
+
* 为什么用 iframe("独立小房间"):模板的交互脚本是整页的主人——用 document 级查找
|
|
56
|
+
* 找工具栏、标题等元素,塞进本页的局部容器会找不到而失灵;给它独立文档才原样运转。
|
|
57
|
+
* 小房间不带任何 URL 参数:模板的 embed=1 模式会隐藏工具栏等交互,不触发它。
|
|
58
|
+
* 喂参与原生命令行 render-workflow.mjs 一致(仅 title 回退到图名)。
|
|
59
|
+
* 返回 { kb, ms, frame } 供状态栏展示"现场渲染"凭据、供外层接上小房间里的图;失败抛带人话原因的 Error。
|
|
60
|
+
*/
|
|
61
|
+
async function renderChart(workflowText, host, chartName) {
|
|
62
|
+
let workflow
|
|
63
|
+
try {
|
|
64
|
+
workflow = JSON.parse(workflowText)
|
|
65
|
+
} catch (error) {
|
|
66
|
+
// kind 供 failureInfo 分"文件坏了"这类;技术原文留在 message 里折进卡片的技术细节
|
|
67
|
+
throw Object.assign(new Error(`workflow.json 不是合法 JSON:${error.message}`), { kind: 'broken-json' })
|
|
68
|
+
}
|
|
69
|
+
const [{ compileWorkflow }, { applyTemplate, renderCards }, template] = await Promise.all([
|
|
70
|
+
import(COMPILER_URL),
|
|
71
|
+
import(UTILS_URL),
|
|
72
|
+
// 剥掉模板自带的 Google Fonts 外链(零外网,验收 #10);正文回退系统等宽字体
|
|
73
|
+
fetch(TEMPLATE_URL).then((response) => response.text()).then(stripExternalFonts),
|
|
74
|
+
])
|
|
75
|
+
|
|
76
|
+
const t0 = performance.now()
|
|
77
|
+
const result = compileWorkflow({ workflow, qualityProfile: workflow.meta?.quality_profile })
|
|
78
|
+
const ms = Math.max(1, Math.round(performance.now() - t0))
|
|
79
|
+
if (!result.ok || !result.svg) {
|
|
80
|
+
const detail = result.error ? String(result.error) : `${(result.diagnostics || []).length} 条编译诊断未通过`
|
|
81
|
+
throw Object.assign(new Error(`渲染未通过:${detail}`), { kind: 'compile-failed' })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const meta = workflow.meta || {}
|
|
85
|
+
const html = applyTemplate(template, {
|
|
86
|
+
title: meta.title || chartName,
|
|
87
|
+
subtitle: meta.subtitle,
|
|
88
|
+
svg: result.svg,
|
|
89
|
+
cards: renderCards(workflow.cards),
|
|
90
|
+
locale: meta.locale,
|
|
91
|
+
visualPreset: meta.visual_preset || 'classic',
|
|
92
|
+
guidedViews: meta.views || [],
|
|
93
|
+
sourceEvidence: null, // 源码证据展示属后续步骤;这里不给假数据
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const frame = document.createElement('iframe')
|
|
97
|
+
frame.className = 'chart-frame'
|
|
98
|
+
frame.title = `${meta.title || chartName} 交互图`
|
|
99
|
+
frame.src = URL.createObjectURL(new Blob([html], { type: 'text/html' }))
|
|
100
|
+
// 图是同一来源的 blob:点进图里之后焦点落在那份文档,键盘一样到不了外层,
|
|
101
|
+
// 所以那份文档也转发一次"关掉资料面板"的 ESC(图每次重渲染换一份文档,跟着 load 重新接)。
|
|
102
|
+
frame.addEventListener('load', () => {
|
|
103
|
+
if (frame.contentDocument) forwardEscapeToHost(window, frame.contentDocument)
|
|
104
|
+
})
|
|
105
|
+
host.replaceChildren(frame)
|
|
106
|
+
return { kb: (result.svg.length / 1024).toFixed(1), ms, frame }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 「已选」状态条(E·选中同步):图里恰好一个步骤被原生选中时,把图上的名字写在这一条上,
|
|
111
|
+
* 并露出「查看详情」入口;未选中、多选、取消选中都隐藏(沿用旧增强版"多个按未选中"的规则)。
|
|
112
|
+
* 名字用图里那个节点自己的标签,外层不另起译名,和图对得上。
|
|
113
|
+
*/
|
|
114
|
+
function renderSelectionBar(selection) {
|
|
115
|
+
const bar = $('selectionBar')
|
|
116
|
+
const button = $('detailOpen')
|
|
117
|
+
if (!selection) {
|
|
118
|
+
bar.hidden = true
|
|
119
|
+
$('selectionText').textContent = ''
|
|
120
|
+
button.hidden = true
|
|
121
|
+
bar.removeAttribute('data-kind')
|
|
122
|
+
currentSelection = null
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
$('selectionText').textContent = `已选:${selection.label}`
|
|
126
|
+
button.hidden = false
|
|
127
|
+
bar.dataset.kind = 'selected'
|
|
128
|
+
bar.hidden = false
|
|
129
|
+
currentSelection = selection
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 增强阅读接不上(进不去图所在的小房间、或图里找不到主图):在这里如实说一句就走开,
|
|
134
|
+
* 只报一次;图本身与文档、证据面板都不受影响。这时候没有可读的节点,入口一并收起。
|
|
135
|
+
*/
|
|
136
|
+
function renderLinkFailure(reason) {
|
|
137
|
+
const bar = $('selectionBar')
|
|
138
|
+
$('selectionText').textContent = `增强阅读连接不上:${reason}。图与文档不受影响。`
|
|
139
|
+
$('detailOpen').hidden = true
|
|
140
|
+
bar.dataset.kind = 'warn'
|
|
141
|
+
bar.hidden = false
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 把渲染失败归成看图人能懂的四类(B:渲染失败说人话):
|
|
146
|
+
* missing 文件没了 / unreadable 文件在但读不开(如超过大小上限)/
|
|
147
|
+
* broken-json 文件格式坏了 / compile-failed 图自相矛盾。
|
|
148
|
+
* 技术原文保留在 detail 里,折进卡片的"技术细节",给修图的人看,不占正文。
|
|
149
|
+
*/
|
|
150
|
+
function failureInfo(kind, version, source) {
|
|
151
|
+
const where = version.kind === 'snapshot'
|
|
152
|
+
? `历史快照「${version.label}」(${version.tag})`
|
|
153
|
+
: '当前(工作区)'
|
|
154
|
+
if (kind === 'missing') {
|
|
155
|
+
return {
|
|
156
|
+
where,
|
|
157
|
+
reason: version.kind === 'snapshot'
|
|
158
|
+
? '这个快照存档的时候没有存图文件(workflow.json 缺失):存档不完整,不是您操作的问题。'
|
|
159
|
+
: '当前工作区里没有图文件(workflow.json 缺失):图还没画,或者文件没有放在约定目录里。',
|
|
160
|
+
hint: version.kind === 'snapshot'
|
|
161
|
+
? '可以点上面版本条里的其他版本看;这份存档本身补不了图,需要重新存一份快照。'
|
|
162
|
+
: '图已经画过的话,检查文件是否在约定目录里;还没画的话,画完保存后这里就能读。',
|
|
163
|
+
detail: String(source?.message || source || ''),
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (kind === 'unreadable') {
|
|
167
|
+
return {
|
|
168
|
+
where,
|
|
169
|
+
reason: '这个版本的图文件在,但读不开(例如文件太大,超过了读取上限):内容取不出来,不是您操作的问题。',
|
|
170
|
+
hint: '可以点上面版本条里的其他版本看;这份文件需要先处理(比如拆小)才能在这里显示。',
|
|
171
|
+
detail: String(source?.message || source || ''),
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (kind === 'broken-json') {
|
|
175
|
+
return {
|
|
176
|
+
where,
|
|
177
|
+
reason: '这个版本存的图文件格式坏了:存档的时候内容就写错了,机器读不开。不是您操作的问题。',
|
|
178
|
+
hint: '可以点上面版本条里的其他版本看;这份存档需要重新生成。',
|
|
179
|
+
detail: String(source?.message || ''),
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
where,
|
|
184
|
+
reason: '图文件读得开,但内容对不上:比如箭头指向了不存在的步骤,或缺少必要的信息,画不成图。',
|
|
185
|
+
hint: '可以点上面版本条里的其他版本看;这份图的内容需要修正后重新保存。',
|
|
186
|
+
detail: String(source?.message || ''),
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 失败说明卡:标题 + 坏的是哪份 + 原因 + 建议 + 可折叠技术原文。装进画布区,版本条照常保留。 */
|
|
191
|
+
function renderFailureCard(host, info, chartName) {
|
|
192
|
+
const box = el('div', 'fail-card')
|
|
193
|
+
const h = el('h2')
|
|
194
|
+
h.textContent = '这份图暂时看不了'
|
|
195
|
+
const which = el('p', 'fail-which')
|
|
196
|
+
which.textContent = `图:${chartName} · 版本:${info.where}`
|
|
197
|
+
const reason = el('p', 'fail-reason')
|
|
198
|
+
reason.textContent = `原因:${info.reason}`
|
|
199
|
+
const hint = el('p', 'fail-hint')
|
|
200
|
+
hint.textContent = info.hint
|
|
201
|
+
box.append(h, which, reason, hint)
|
|
202
|
+
if (info.detail) {
|
|
203
|
+
const details = el('details', 'fail-detail')
|
|
204
|
+
const summary = el('summary')
|
|
205
|
+
summary.textContent = '技术细节(给修图的人看)'
|
|
206
|
+
const pre = el('pre')
|
|
207
|
+
pre.textContent = info.detail
|
|
208
|
+
details.append(summary, pre)
|
|
209
|
+
box.appendChild(details)
|
|
210
|
+
}
|
|
211
|
+
host.replaceChildren(box)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 从 workflow.json 取节点清单,供说明文档块配上图上的名字。图读不开时返回空(判断不了归属)。 */
|
|
215
|
+
function chartNodes(workflowText) {
|
|
216
|
+
try {
|
|
217
|
+
const workflow = JSON.parse(workflowText)
|
|
218
|
+
return Array.isArray(workflow.nodes) ? workflow.nodes : []
|
|
219
|
+
} catch {
|
|
220
|
+
return []
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 一段文字按行内标记拼成 DOM(加粗/代码;不碰 innerHTML)。 */
|
|
225
|
+
function inlineNodes(text) {
|
|
226
|
+
const holder = document.createDocumentFragment()
|
|
227
|
+
for (const part of splitInline(text)) {
|
|
228
|
+
if (part.strong) {
|
|
229
|
+
const strong = document.createElement('strong')
|
|
230
|
+
strong.textContent = part.text
|
|
231
|
+
holder.appendChild(strong)
|
|
232
|
+
} else if (part.code) {
|
|
233
|
+
const code = el('code')
|
|
234
|
+
code.textContent = part.text
|
|
235
|
+
holder.appendChild(code)
|
|
236
|
+
} else {
|
|
237
|
+
holder.appendChild(document.createTextNode(part.text))
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return holder
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** 一个步骤一块:图上名字(+ 副标签、节点编号)+ 这一节的正文。 */
|
|
244
|
+
function detailBlock(block) {
|
|
245
|
+
const box = el('div', 'detail-block')
|
|
246
|
+
const heading = el('h3')
|
|
247
|
+
heading.textContent = block.label
|
|
248
|
+
if (block.note) {
|
|
249
|
+
const note = el('span', 'detail-note')
|
|
250
|
+
note.textContent = block.note
|
|
251
|
+
heading.appendChild(note)
|
|
252
|
+
}
|
|
253
|
+
if (block.sublabel) {
|
|
254
|
+
const sub = el('span', 'detail-sub')
|
|
255
|
+
sub.textContent = block.sublabel
|
|
256
|
+
heading.appendChild(sub)
|
|
257
|
+
}
|
|
258
|
+
if (block.label !== block.id) {
|
|
259
|
+
const id = el('code', 'detail-id')
|
|
260
|
+
id.textContent = block.id
|
|
261
|
+
heading.appendChild(id)
|
|
262
|
+
}
|
|
263
|
+
box.appendChild(heading)
|
|
264
|
+
for (const item of parseBody(block.body)) {
|
|
265
|
+
const line = item.kind === 'bullet' ? el('li') : el('p')
|
|
266
|
+
line.appendChild(inlineNodes(item.text))
|
|
267
|
+
if (item.kind === 'bullet') {
|
|
268
|
+
// 连续的列表项归进同一个 <ul>
|
|
269
|
+
const last = box.lastElementChild
|
|
270
|
+
const list = last && last.tagName === 'UL' ? last : box.appendChild(document.createElement('ul'))
|
|
271
|
+
list.appendChild(line)
|
|
272
|
+
} else {
|
|
273
|
+
box.appendChild(line)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return box
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* 说明文档面板(C):一级标题当总说明,`## 节点 id` 各成一块。
|
|
281
|
+
* 没写说明文档、没按约定分节、图上没写说明的步骤、图外多余的条目,都如实说明,都不算错误。
|
|
282
|
+
* doc 是 main 里算好的一份(与详情弹层共用),这里只负责渲染,不再重复解析。
|
|
283
|
+
*/
|
|
284
|
+
function renderDetailsPanel(version, doc) {
|
|
285
|
+
const host = $('detailsPanel')
|
|
286
|
+
host.textContent = ''
|
|
287
|
+
const heading = el('h2')
|
|
288
|
+
heading.textContent = '说明文档'
|
|
289
|
+
host.appendChild(heading)
|
|
290
|
+
|
|
291
|
+
// 文件在但读不开(如超过大小上限):如实说明,不误报"没写",也不算错误以外的事故
|
|
292
|
+
if (version.detailsError) {
|
|
293
|
+
const warn = el('p', 'details-warn')
|
|
294
|
+
warn.textContent = `说明文档读不开:${version.detailsError}`
|
|
295
|
+
host.appendChild(warn)
|
|
296
|
+
host.hidden = false
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (doc.empty) {
|
|
301
|
+
const note = el('p', 'muted')
|
|
302
|
+
note.textContent = version.kind === 'snapshot'
|
|
303
|
+
? '这个版本没有说明文档(details.md)。'
|
|
304
|
+
: '这张图还没有说明文档(details.md)。'
|
|
305
|
+
host.appendChild(note)
|
|
306
|
+
host.hidden = false
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (doc.title) {
|
|
311
|
+
const lead = el('p', 'details-lead')
|
|
312
|
+
lead.textContent = doc.title
|
|
313
|
+
host.appendChild(lead)
|
|
314
|
+
}
|
|
315
|
+
if (doc.malformed) {
|
|
316
|
+
const warn = el('p', 'details-warn')
|
|
317
|
+
warn.textContent = '说明文档没有按约定分节(每节应以「## 节点编号」开头),所以只能看到上面的总说明。'
|
|
318
|
+
host.appendChild(warn)
|
|
319
|
+
}
|
|
320
|
+
for (const block of doc.blocks) host.appendChild(detailBlock(block))
|
|
321
|
+
if (doc.missing.length) {
|
|
322
|
+
const note = el('p', 'muted')
|
|
323
|
+
note.textContent = `图中还有 ${doc.missing.length} 个步骤没写说明:${doc.missing.map((node) => node.label).join('、')}`
|
|
324
|
+
host.appendChild(note)
|
|
325
|
+
}
|
|
326
|
+
if (doc.strays.length) {
|
|
327
|
+
const note = el('p', 'muted')
|
|
328
|
+
note.textContent = `以下 ${doc.strays.length} 条在图里找不到对应步骤(保留显示,不算错误):`
|
|
329
|
+
host.appendChild(note)
|
|
330
|
+
for (const block of doc.strays) host.appendChild(detailBlock(block))
|
|
331
|
+
}
|
|
332
|
+
host.hidden = false
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* 源码证据面板(D + 双请求错配修复)。清单用第一次响应(/api/chart)里那份 evidence.json
|
|
337
|
+
* 的原文:第二次请求带着它去问(POST 正文),服务端只按提交来的正文解析与切片,
|
|
338
|
+
* 不重读磁盘——两次请求之间文件被保存,证据也不会跟图错开版本。
|
|
339
|
+
* 第一次拿到的清单是 null(没有证据文件)或带读不开标注时,就地如实说明,根本不发请求;
|
|
340
|
+
* 证据为空是如实说明,不算错误;接口失败也只影响本面板,不拖垮整页。
|
|
341
|
+
*/
|
|
342
|
+
async function renderEvidencePanel(version, onReady) {
|
|
343
|
+
const state = await loadDetailEvidence(version, text => postJson(wsUrl('/specdev-workbench/api/evidence'), text))
|
|
344
|
+
const host = $('evidencePanel')
|
|
345
|
+
host.replaceChildren()
|
|
346
|
+
const heading = el('h2')
|
|
347
|
+
heading.textContent = '全部源码证据'
|
|
348
|
+
host.append(heading)
|
|
349
|
+
if (state.message || !state.refs.length) {
|
|
350
|
+
const note = el('p', 'ev-warn')
|
|
351
|
+
note.textContent = state.message || '证据清单为空。'
|
|
352
|
+
host.append(note)
|
|
353
|
+
}
|
|
354
|
+
const summary = evidenceSummary(state.refs)
|
|
355
|
+
if (summary) {
|
|
356
|
+
const note = el('p', 'muted')
|
|
357
|
+
note.textContent = summary.total + ' 条证据:' + summary.ok + ' 条有效、' + summary.bad + ' 条有问题。'
|
|
358
|
+
host.append(note)
|
|
359
|
+
}
|
|
360
|
+
for (const ref of state.refs) host.append(evidenceBlock(ref))
|
|
361
|
+
host.hidden = false
|
|
362
|
+
onReady(state)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** 一条证据一块:名称 + (有效)出处与代码片段 /(有问题)人话原因。 */
|
|
366
|
+
function evidenceBlock(ref) {
|
|
367
|
+
const box = el('div', ref.ok ? 'ev-block' : 'ev-block ev-block-bad')
|
|
368
|
+
const heading = el('h3')
|
|
369
|
+
heading.textContent = ref.label
|
|
370
|
+
if (ref.label !== ref.id) {
|
|
371
|
+
const id = el('code', 'detail-id')
|
|
372
|
+
id.textContent = ref.id
|
|
373
|
+
heading.appendChild(id)
|
|
374
|
+
}
|
|
375
|
+
box.appendChild(heading)
|
|
376
|
+
|
|
377
|
+
if (!ref.ok) {
|
|
378
|
+
const bad = el('p', 'ev-bad')
|
|
379
|
+
bad.textContent = `这条引用用不了:${displayError(ref.error)}`
|
|
380
|
+
box.appendChild(bad)
|
|
381
|
+
return box
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const src = el('p', 'ev-src')
|
|
385
|
+
src.textContent = describeSource(ref)
|
|
386
|
+
src.title = `完整提交号:${ref.commit}`
|
|
387
|
+
box.appendChild(src)
|
|
388
|
+
const pre = el('pre', 'ev-code')
|
|
389
|
+
pre.textContent = formatCodeBlock(ref.text, ref.fromLine)
|
|
390
|
+
box.appendChild(pre)
|
|
391
|
+
return box
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function parseLocation() {
|
|
395
|
+
const parts = location.pathname.split('/').filter(Boolean) // ['specdev-workbench','read','<biz>','<chart>']
|
|
396
|
+
return { business: parts[2] || '', chart: parts[3] || '', v: new URLSearchParams(location.search).get('v') || 'current' }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function main() {
|
|
400
|
+
const { business, chart, v } = parseLocation()
|
|
401
|
+
if (!business || !chart) return showError('路径应为 /specdev-workbench/read/<业务id>/<图id>')
|
|
402
|
+
if (workspaceParamEmpty) return renderEmptyWorkspaceParam()
|
|
403
|
+
// "业务"面包屑在取数开始前就绑好业务段与工作区标识:
|
|
404
|
+
// 图不存在、工作区失效或请求失败时它也不再是裸地址,错误页上点它仍回到本工作区的业务页。
|
|
405
|
+
$('bizLink').href = wsUrl(`/specdev-workbench/business/${business}`)
|
|
406
|
+
let data
|
|
407
|
+
try {
|
|
408
|
+
data = await fetchJson(wsUrl(`/specdev-workbench/api/chart?business=${encodeURIComponent(business)}&chart=${encodeURIComponent(chart)}&v=${encodeURIComponent(v)}`))
|
|
409
|
+
} catch (error) {
|
|
410
|
+
if (renderRepoState(error)) return
|
|
411
|
+
return showError(error.message)
|
|
412
|
+
}
|
|
413
|
+
if (data.code === 'repo-not-configured') return renderGuide(data)
|
|
414
|
+
|
|
415
|
+
document.title = pageTitle(data.chart.name, data.repo)
|
|
416
|
+
$('bizLink').textContent = data.business.name
|
|
417
|
+
$('bizLink').href = wsUrl(`/specdev-workbench/business/${business}`)
|
|
418
|
+
$('chartName').textContent = data.chart.name
|
|
419
|
+
$('title').textContent = data.chart.name
|
|
420
|
+
if (data.chart.summary) $('summary').textContent = data.chart.summary
|
|
421
|
+
renderRepoLine(data.repo)
|
|
422
|
+
let rendered = null
|
|
423
|
+
let failure = null
|
|
424
|
+
if (data.version.workflowError) {
|
|
425
|
+
failure = failureInfo(
|
|
426
|
+
data.version.workflowErrorKind === 'unreadable' ? 'unreadable' : 'missing',
|
|
427
|
+
data.version,
|
|
428
|
+
data.version.workflowError,
|
|
429
|
+
)
|
|
430
|
+
} else {
|
|
431
|
+
try {
|
|
432
|
+
rendered = await renderChart(data.version.files.workflow, $('stage'), data.chart.name)
|
|
433
|
+
} catch (error) {
|
|
434
|
+
// 失败不当成整页事故:版本条照常出来,画布区换成说明卡,看的人能自己换版本
|
|
435
|
+
failure = failureInfo(error.kind === 'broken-json' ? 'broken-json' : 'compile-failed', data.version, error)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (failure) renderFailureCard($('stage'), failure, data.chart.name)
|
|
439
|
+
// 本页状态:保存成功后重画版本条与状态行要读它(图与说明面板不重画,因此不看这里)
|
|
440
|
+
pageState = { business, chart, v, data, rendered, failure }
|
|
441
|
+
// 记下"页面上展示的这一份内容是哪个摘要"——只在真正加载并展示内容时登记一次。
|
|
442
|
+
// 保存成功后原地刷新版本条不走这里:屏幕上还是这一份图与说明,过期检查就得按它算(审查 P1)。
|
|
443
|
+
rememberShown(data)
|
|
444
|
+
// 说明文档只解析一次:说明面板与详情弹层用同一份结果,两处说法必然一致
|
|
445
|
+
const details = buildDetails(data.version.files.details, chartNodes(data.version.files.workflow))
|
|
446
|
+
let evidence = { loading: true, refs: [] }
|
|
447
|
+
let openedSelection = null
|
|
448
|
+
let returnFocus = null
|
|
449
|
+
const openDetail = (selection, target) => {
|
|
450
|
+
if (!selection) return
|
|
451
|
+
openedSelection = selection
|
|
452
|
+
returnFocus = target || $('detailOpen')
|
|
453
|
+
renderNodeDetail($('detailContent'), data.version, details, selection, evidence)
|
|
454
|
+
$('detailSource').textContent = '关闭后继续看图 · Esc 关闭'
|
|
455
|
+
if (!$('detailDialog').open) $('detailDialog').showModal()
|
|
456
|
+
$('detailContent').scrollTop = 0
|
|
457
|
+
}
|
|
458
|
+
if (rendered) {
|
|
459
|
+
connectChartSelection(rendered.frame, {
|
|
460
|
+
onSelection: renderSelectionBar,
|
|
461
|
+
onUnavailable: renderLinkFailure,
|
|
462
|
+
onOpenDetail: openDetail,
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
$('detailOpen').addEventListener('click', () => openDetail(currentSelection))
|
|
466
|
+
$('detailDialog').addEventListener('close', () => {
|
|
467
|
+
openedSelection = null
|
|
468
|
+
returnFocus?.focus({ preventScroll: true })
|
|
469
|
+
})
|
|
470
|
+
$('materialsOpen').disabled = false
|
|
471
|
+
$('materialsOpen').addEventListener('click', () => $('materialsDialog').showModal())
|
|
472
|
+
$('materialsClose').addEventListener('click', () => $('materialsDialog').close())
|
|
473
|
+
$('detailClose').addEventListener('click', () => $('detailDialog').close())
|
|
474
|
+
// 点框外空白关闭:点到的就是 dialog 自己(内容在里面的正文区),且坐标落在框外。
|
|
475
|
+
// ESC 由原生 dialog 自己关(浏览器默认行为),不另写一套。
|
|
476
|
+
$('detailDialog').addEventListener('click', (event) => {
|
|
477
|
+
const dialog = event.currentTarget
|
|
478
|
+
if (event.target !== dialog) return
|
|
479
|
+
const rect = dialog.getBoundingClientRect()
|
|
480
|
+
if (event.clientX < rect.left || event.clientX > rect.right
|
|
481
|
+
|| event.clientY < rect.top || event.clientY > rect.bottom) dialog.close()
|
|
482
|
+
})
|
|
483
|
+
// 「保存版本」(G):关闭三方式与详情层一致;保存按钮点下去就禁用(连点只发一次请求)
|
|
484
|
+
$('saveSubmit').addEventListener('click', submitSave)
|
|
485
|
+
$('saveClose').addEventListener('click', () => $('saveDialog').close())
|
|
486
|
+
$('saveCancel').addEventListener('click', () => $('saveDialog').close())
|
|
487
|
+
$('saveDialog').addEventListener('click', (event) => {
|
|
488
|
+
const dialog = event.currentTarget
|
|
489
|
+
if (event.target !== dialog) return
|
|
490
|
+
const rect = dialog.getBoundingClientRect()
|
|
491
|
+
if (event.clientX < rect.left || event.clientX > rect.right
|
|
492
|
+
|| event.clientY < rect.top || event.clientY > rect.bottom) dialog.close()
|
|
493
|
+
})
|
|
494
|
+
// 本页被装进资料面板(iframe)时,焦点在本文档里,父页收不到按键:没有自己的弹层
|
|
495
|
+
// 要关的 ESC 由阅读页转给宿主页去关面板;顶层标签页里打开本页时它什么也不做。
|
|
496
|
+
// 注意安装位置见文件末尾:要装在 main() 的早退分支之前(见那里的说明)。
|
|
497
|
+
renderDetailsPanel(data.version, details)
|
|
498
|
+
// 证据解析不阻塞版本条:先发出去(正文带的就是本页 data 里那份清单原文),版本条立起来、
|
|
499
|
+
// 状态栏报完再等它——证据慢只慢证据面板
|
|
500
|
+
const evidenceTask = renderEvidencePanel(data.version, state => {
|
|
501
|
+
evidence = state
|
|
502
|
+
// 晚到的证据只更新仍打开的节点,不重新打开已关闭的详情。
|
|
503
|
+
if ($('detailDialog').open && openedSelection) {
|
|
504
|
+
const scroll = $('detailContent').scrollTop
|
|
505
|
+
renderNodeDetail($('detailContent'), data.version, details, openedSelection, evidence)
|
|
506
|
+
$('detailContent').scrollTop = scroll
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
// 版本条与状态栏的绘制抽成函数:保存成功后原地重画它们(不整页刷新、不动图)
|
|
510
|
+
renderVersions(data)
|
|
511
|
+
renderStatus(data)
|
|
512
|
+
// 版本条与状态栏已就绪,这里才等证据面板收尾(失败只写进面板自身,不影响页面其余部分)
|
|
513
|
+
await evidenceTask
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** 当前 vs 最新快照的比较结果徽标文案(服务端算好传下来,这里只翻成中文后缀)。 */
|
|
517
|
+
function statusSuffix(data) {
|
|
518
|
+
return data.currentStatus === 'identical' ? '· 一致' : data.currentStatus === 'changed' ? '· 已改动'
|
|
519
|
+
: data.currentStatus === 'compare-failed' ? '· 无法比较' : '· 无快照'
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* 版本条 + 正在看历史时的登记信息行(§3.2 的保存时间与保存说明)。
|
|
524
|
+
* 保存成功后由 refreshAfterSave 再调一次原地重画:新版本会出现在条上,图与说明面板照旧。
|
|
525
|
+
*/
|
|
526
|
+
function renderVersions(data) {
|
|
527
|
+
const { business, chart, v } = pageState
|
|
528
|
+
const versions = $('versions')
|
|
529
|
+
versions.textContent = ''
|
|
530
|
+
if (v === 'current') {
|
|
531
|
+
const current = el('span', 'current-item')
|
|
532
|
+
current.textContent = `当前(工作区)${statusSuffix(data)}`
|
|
533
|
+
current.setAttribute('aria-current', 'true')
|
|
534
|
+
versions.appendChild(current)
|
|
535
|
+
// 「保存版本」只在正看"当前"时出现:历史版本是存下来的那一份,不能再存一次
|
|
536
|
+
const save = el('button', 'save-open')
|
|
537
|
+
save.textContent = '保存版本'
|
|
538
|
+
save.title = '把当前已提交的图内容登记成一个有名称的版本'
|
|
539
|
+
save.addEventListener('click', openSaveDialog)
|
|
540
|
+
versions.appendChild(save)
|
|
541
|
+
} else {
|
|
542
|
+
// 正在看历史时,"当前"也是按钮——同一条版本栏就能切回来,不用绕回业务页
|
|
543
|
+
const current = el('button')
|
|
544
|
+
current.textContent = `当前(工作区)${statusSuffix(data)}`
|
|
545
|
+
current.title = data.compareError || '回到当前工作区内容'
|
|
546
|
+
current.addEventListener('click', () => {
|
|
547
|
+
location.assign(wsUrl(`/specdev-workbench/read/${business}/${chart}?v=current`))
|
|
548
|
+
})
|
|
549
|
+
versions.appendChild(current)
|
|
550
|
+
}
|
|
551
|
+
for (const snapshot of data.snapshots.snapshots) {
|
|
552
|
+
const button = el('button')
|
|
553
|
+
button.textContent = `${snapshot.meta.name}(${snapshot.meta.stage === 'implemented' ? '实现版' : '设计版'})`
|
|
554
|
+
button.title = [snapshot.tag, snapshot.meta.note].filter(Boolean).join(' · ')
|
|
555
|
+
if (snapshot.tag === v) button.setAttribute('aria-current', 'true')
|
|
556
|
+
button.addEventListener('click', () => {
|
|
557
|
+
location.assign(wsUrl(`/specdev-workbench/read/${business}/${chart}?v=${encodeURIComponent(snapshot.tag)}`))
|
|
558
|
+
})
|
|
559
|
+
versions.appendChild(button)
|
|
560
|
+
}
|
|
561
|
+
// 正在看历史快照:登记的保存时间与保存说明上屏(§3.2);并说明图名与摘要的来处——
|
|
562
|
+
// 图名/摘要(chart.json)不进快照,历史页上的名字仍取自当前说明文件
|
|
563
|
+
const meta = $('snapshotMeta')
|
|
564
|
+
if (data.version.kind === 'snapshot') {
|
|
565
|
+
const when = data.version.savedAt ? `保存于 ${data.version.savedAt.slice(0, 16).replace('T', ' ')}` : ''
|
|
566
|
+
const parts = [when, data.version.note].filter(Boolean)
|
|
567
|
+
const origin = '图名与摘要取自当前说明文件(chart.json),不随版本回退'
|
|
568
|
+
meta.textContent = parts.length ? `${parts.join(' · ')} · ${origin}` : origin
|
|
569
|
+
meta.hidden = false
|
|
570
|
+
} else {
|
|
571
|
+
meta.hidden = true
|
|
572
|
+
}
|
|
573
|
+
if (data.snapshots.snapshots.length === 0 && data.currentStatus === 'no-snapshot') {
|
|
574
|
+
const note = el('span', 'muted')
|
|
575
|
+
note.textContent = '还没有保存过快照'
|
|
576
|
+
versions.appendChild(note)
|
|
577
|
+
}
|
|
578
|
+
if (data.snapshots.invalidCount > 0) {
|
|
579
|
+
const note = el('span', 'muted')
|
|
580
|
+
note.textContent = `有 ${data.snapshots.invalidCount} 个不符合约定的标签被忽略(如 ${data.snapshots.invalidSamples.slice(0, 2).join('、')})`
|
|
581
|
+
versions.appendChild(note)
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** 状态行:在看哪一版 + 这次现场渲染的凭据(或这个版本看不了)。prefix 供保存成功后加一句结果。 */
|
|
586
|
+
function renderStatus(data, prefix = '') {
|
|
587
|
+
const { v, rendered, failure } = pageState
|
|
588
|
+
setStatus(
|
|
589
|
+
prefix + (v === 'current'
|
|
590
|
+
? '正在看:当前(工作区)'
|
|
591
|
+
: `正在看:${data.version.label}(提交 ${String(data.version.commit).slice(0, 8)})`)
|
|
592
|
+
+ (rendered ? ` · 已现场渲染 ${rendered.kb} KB / ${rendered.ms} ms` : failure ? ' · 这个版本看不了' : ''),
|
|
593
|
+
failure ? 'bad' : 'ok',
|
|
594
|
+
)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* 打开保存弹层:先问一次"现在能不能存"(GET /api/snapshots),再把服务端给的内容摘要与
|
|
599
|
+
* 页面正看的这一份对一下。放行与否、各说什么话都由 save-result.js 的 saveGate 判(那边能跑断言);
|
|
600
|
+
* 这里只负责取数、把结论摆上来、以及在通过时放开保存按钮。
|
|
601
|
+
*/
|
|
602
|
+
async function openSaveDialog() {
|
|
603
|
+
const { business, chart } = pageState
|
|
604
|
+
const box = $('saveCheck')
|
|
605
|
+
const button = $('saveSubmit')
|
|
606
|
+
saveCheck = null
|
|
607
|
+
button.disabled = true
|
|
608
|
+
button.textContent = '保存'
|
|
609
|
+
$('saveError').hidden = true
|
|
610
|
+
$('saveName').value = ''
|
|
611
|
+
$('saveNote').value = ''
|
|
612
|
+
const designStage = document.querySelector('input[name="saveStage"][value="design"]')
|
|
613
|
+
if (designStage) designStage.checked = true
|
|
614
|
+
box.textContent = '正在检查这一版能不能存…'
|
|
615
|
+
box.removeAttribute('data-kind')
|
|
616
|
+
$('saveDialog').showModal()
|
|
617
|
+
|
|
618
|
+
let check
|
|
619
|
+
try {
|
|
620
|
+
check = await fetchJson(wsUrl(`/specdev-workbench/api/snapshots?business=${encodeURIComponent(business)}&chart=${encodeURIComponent(chart)}`))
|
|
621
|
+
} catch (error) {
|
|
622
|
+
check = { failed: error.message } // 请求没回来:交给 saveGate 归为"没能检查"
|
|
623
|
+
}
|
|
624
|
+
const gate = saveGate(shownFingerprint(), check)
|
|
625
|
+
if (gate.state === 'ok') {
|
|
626
|
+
saveCheck = check
|
|
627
|
+
box.dataset.kind = 'ok'
|
|
628
|
+
box.textContent = gate.text
|
|
629
|
+
button.disabled = false
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
if (gate.state === 'uncommitted') {
|
|
633
|
+
box.dataset.kind = 'bad'
|
|
634
|
+
box.textContent = gate.text
|
|
635
|
+
const list = el('ul', 'save-problems')
|
|
636
|
+
for (const problem of gate.problems) {
|
|
637
|
+
const item = el('li')
|
|
638
|
+
item.textContent = problem
|
|
639
|
+
list.appendChild(item)
|
|
640
|
+
}
|
|
641
|
+
box.appendChild(list)
|
|
642
|
+
return
|
|
643
|
+
}
|
|
644
|
+
box.dataset.kind = gate.state === 'unknown' || gate.state === 'stale' ? 'warn' : 'bad'
|
|
645
|
+
box.textContent = gate.text
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* 提交保存:弹层里的按钮点下去就禁用(连点只发一次请求)。结果怎么算由 save-result.js 的
|
|
650
|
+
* saveOutcome 判(那边能跑断言),这里只按它给的 kind 做三件事:成功或已存过就关掉弹层并
|
|
651
|
+
* 原地刷新版本条;"版本已写上、只是没核完"提示后顺手刷一次版本条供核对;其余按原因如实说。
|
|
652
|
+
*/
|
|
653
|
+
async function submitSave() {
|
|
654
|
+
if (!saveCheck) return
|
|
655
|
+
const { business, chart } = pageState
|
|
656
|
+
const button = $('saveSubmit')
|
|
657
|
+
const error = $('saveError')
|
|
658
|
+
const name = $('saveName').value.trim()
|
|
659
|
+
error.hidden = true
|
|
660
|
+
if (!name) {
|
|
661
|
+
error.hidden = false
|
|
662
|
+
error.textContent = '请先给这一版起个名字(例如「首版设计」),再点保存。'
|
|
663
|
+
return
|
|
664
|
+
}
|
|
665
|
+
const stage = document.querySelector('input[name="saveStage"]:checked')?.value === 'implemented' ? 'implemented' : 'design'
|
|
666
|
+
const note = $('saveNote').value.trim()
|
|
667
|
+
button.disabled = true
|
|
668
|
+
button.textContent = '正在保存…'
|
|
669
|
+
|
|
670
|
+
let responded = false
|
|
671
|
+
let ok = false
|
|
672
|
+
let status = 0
|
|
673
|
+
let body = null
|
|
674
|
+
try {
|
|
675
|
+
const response = await fetch(wsUrl('/specdev-workbench/api/snapshots'), {
|
|
676
|
+
method: 'POST',
|
|
677
|
+
headers: { 'Content-Type': 'application/json' },
|
|
678
|
+
body: JSON.stringify({
|
|
679
|
+
business,
|
|
680
|
+
chart,
|
|
681
|
+
name,
|
|
682
|
+
stage,
|
|
683
|
+
...(note ? { note } : {}),
|
|
684
|
+
head: saveCheck.head,
|
|
685
|
+
// 页面上真正展示的那一份(不是版本条刷新后的新数据):展示的内容没重画,摘要就不许换
|
|
686
|
+
fingerprint: shownFingerprint(),
|
|
687
|
+
}),
|
|
688
|
+
})
|
|
689
|
+
responded = true
|
|
690
|
+
ok = response.ok
|
|
691
|
+
status = response.status
|
|
692
|
+
try {
|
|
693
|
+
body = await response.json()
|
|
694
|
+
} catch {
|
|
695
|
+
body = null // 正文读不出来:交给 saveOutcome 当"结果未知",不当成功
|
|
696
|
+
}
|
|
697
|
+
} catch {
|
|
698
|
+
responded = false // 请求没到服务端或没拿到回应:存没存上未知
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const outcome = saveOutcome({ responded, ok, status, body })
|
|
702
|
+
if (outcome.kind === 'created' || outcome.kind === 'already') {
|
|
703
|
+
$('saveDialog').close()
|
|
704
|
+
await refreshAfterSave(outcome.label, outcome.kind === 'already')
|
|
705
|
+
return
|
|
706
|
+
}
|
|
707
|
+
button.disabled = false
|
|
708
|
+
button.textContent = '保存'
|
|
709
|
+
error.hidden = false
|
|
710
|
+
error.textContent = outcome.text
|
|
711
|
+
if (outcome.kind === 'created-unverified') {
|
|
712
|
+
// 版本可能已经写上去了:立刻刷一次版本条,让它出现在列表里供核对(图与说明仍不动)
|
|
713
|
+
await refreshBar()
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* 就地刷新版本条与状态行(不动图、说明、证据)。返回是否刷成功。
|
|
719
|
+
* 注意这里只换"列表数据",不碰 save-result.js 里保管的展示摘要——屏幕上还是原来那一份
|
|
720
|
+
* 内容,过期检查就必须仍按原来那一份算,否则会出现"看着旧图、存下新图"(审查 P1)。
|
|
721
|
+
*/
|
|
722
|
+
async function refreshBar(prefix = '') {
|
|
723
|
+
const { business, chart, v } = pageState
|
|
724
|
+
let data
|
|
725
|
+
try {
|
|
726
|
+
data = await fetchJson(wsUrl(`/specdev-workbench/api/chart?business=${encodeURIComponent(business)}&chart=${encodeURIComponent(chart)}&v=${encodeURIComponent(v)}`))
|
|
727
|
+
} catch {
|
|
728
|
+
return false
|
|
729
|
+
}
|
|
730
|
+
pageState.data = data
|
|
731
|
+
renderVersions(data)
|
|
732
|
+
renderStatus(data, prefix)
|
|
733
|
+
return true
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** 保存成功(或已存过)之后原地刷新:说一句结果,再把版本条与状态行重画一遍。 */
|
|
737
|
+
async function refreshAfterSave(label, alreadySaved) {
|
|
738
|
+
const note = alreadySaved ? `这一版已经保存过:「${label}」` : `已保存「${label}」`
|
|
739
|
+
if (!(await refreshBar(`${note}。`))) setStatus(`${note},但版本条没能刷新:已保存,请刷新查看。`, 'warn')
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// 本页嵌在资料面板里时,把"关掉面板"的 ESC 转给宿主页(顶层标签页里什么也不做)。
|
|
743
|
+
// 装在 main() 之前而不是 main 里面:路径不对、工作区标识为空、取图失败这些分支都会提前返回,
|
|
744
|
+
// 但用户看到的仍是这个阅读页——读不到图的时候,ESC 照样该能关掉外面的资料面板。
|
|
745
|
+
forwardEscapeToHost(window, document)
|
|
746
|
+
main()
|