adspecs 0.1.23 → 0.1.25

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.
@@ -0,0 +1,664 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>PRD 工作台 — 华峰铝业 MOM 项目</title>
7
+ <!--
8
+ PRD 工作台(P06-01 试点):md 为唯一存储格式,本工具只做渲染/视图过滤/编辑/打印。
9
+ - 客户确认视图:章节级过滤 + 封面/确认声明/签字页注入 + 待确认项高亮 → 浏览器打印为 PDF
10
+ - PRD 全文视图:完整渲染(含 mermaid 状态机/流程图)
11
+ - 源码编辑视图:直接编辑 md,Chrome/Edge 可直接保存回原文件(其他浏览器下载替换)
12
+ 依赖 CDN:vue3 / marked / mermaid(需联网)
13
+ -->
14
+ <script src="https://unpkg.com/vue@3.4.27/dist/vue.global.prod.js"></script>
15
+ <script src="https://unpkg.com/marked@12.0.2/marked.min.js"></script>
16
+ <script src="https://unpkg.com/mermaid@10.9.1/dist/mermaid.min.js"></script>
17
+ <style>
18
+ * { box-sizing: border-box; }
19
+ body { margin: 0; font-family: "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif; background: #eef1f5; color: #303133; }
20
+ /* ── 工作台壳 ── */
21
+ .topbar { height: 50px; background: #1f2d3d; color: #fff; display: flex; align-items: center; padding: 0 16px; gap: 12px; position: sticky; top: 0; z-index: 100; }
22
+ .topbar .logo { font-weight: 700; }
23
+ .topbar .file { font-size: 12px; color: #a3b1c2; max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
24
+ .tabs { display: flex; gap: 4px; margin-left: 12px; }
25
+ .tab { padding: 6px 14px; border-radius: 4px; font-size: 13px; cursor: pointer; color: #c9d4e0; }
26
+ .tab.on { background: #409eff; color: #fff; }
27
+ .btn { border: 1px solid #5a6b7d; background: transparent; color: #dfe6ee; padding: 5px 12px; border-radius: 4px; font-size: 13px; cursor: pointer; }
28
+ .btn:hover { background: rgba(255,255,255,.1); }
29
+ .btn.primary { background: #409eff; border-color: #409eff; color: #fff; }
30
+ .btn.warn { background: #e6a23c; border-color: #e6a23c; color: #fff; }
31
+ .dirty-dot { width: 8px; height: 8px; border-radius: 50%; background: #e6a23c; display: inline-block; margin-left: 4px; }
32
+ .wrap { display: flex; min-height: calc(100vh - 50px); }
33
+ .sidebar { width: 250px; background: #fff; border-right: 1px solid #e4e7ed; padding: 14px; flex-shrink: 0; font-size: 13px; }
34
+ .sidebar h4 { margin: 14px 0 8px; font-size: 12px; color: #909399; font-weight: 600; }
35
+ .chap { display: flex; align-items: center; gap: 6px; padding: 3px 0; cursor: pointer; }
36
+ .chap input { cursor: pointer; }
37
+ .chap .n { color: #909399; width: 22px; text-align: right; }
38
+ .sidebar .field { margin-bottom: 8px; }
39
+ .sidebar .field label { display: block; font-size: 11px; color: #909399; margin-bottom: 2px; }
40
+ .sidebar .field input { width: 100%; border: 1px solid #dcdfe6; border-radius: 3px; padding: 4px 6px; font-size: 12px; }
41
+ .link-btn { color: #409eff; cursor: pointer; font-size: 12px; }
42
+ .main { flex: 1; overflow: auto; padding: 20px; }
43
+ /* ── 文档纸面 ── */
44
+ .paper { background: #fff; max-width: 900px; margin: 0 auto; padding: 40px 56px; box-shadow: 0 1px 6px rgba(0,0,0,.1); border-radius: 4px; }
45
+ .doc h1 { font-size: 22px; border-bottom: 2px solid #2f5b95; padding-bottom: 8px; }
46
+ .doc h2 { font-size: 18px; color: #1f2d3d; border-left: 4px solid #2f5b95; padding-left: 10px; margin-top: 34px; }
47
+ .doc h3 { font-size: 15px; color: #2c3e50; margin-top: 24px; }
48
+ .doc h4 { font-size: 13.5px; color: #2c3e50; }
49
+ .doc table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 12.5px; }
50
+ .doc th, .doc td { border: 1px solid #d0d7de; padding: 6px 9px; text-align: left; vertical-align: top; word-break: break-word; }
51
+ .doc th { background: #f0f4f8; font-weight: 600; }
52
+ /* 批注 */
53
+ .has-ann { background: #fffbe6 !important; box-shadow: inset 3px 0 0 #e6a23c; }
54
+ .ann-badge { display: inline-block; background: #e6a23c; color: #fff; font-size: 11px; border-radius: 8px; padding: 0 6px; margin-left: 6px; cursor: pointer; vertical-align: 1px; white-space: nowrap; }
55
+ .ann-panel { position: fixed; top: 50px; right: 0; bottom: 0; width: 360px; background: #fff; border-left: 1px solid #dcdfe6; box-shadow: -4px 0 12px rgba(0,0,0,.08); z-index: 200; display: flex; flex-direction: column; }
56
+ .ann-panel .hd { padding: 12px 14px; border-bottom: 1px solid #ebeef5; font-weight: 600; display: flex; align-items: center; gap: 6px; }
57
+ .ann-list { flex: 1; overflow: auto; padding: 10px 14px; }
58
+ .ann-item { border: 1px solid #ebeef5; border-radius: 6px; padding: 10px 12px; margin-bottom: 10px; font-size: 13px; }
59
+ .ann-item.done { opacity: .55; }
60
+ .ann-item .quote { color: #909399; font-size: 12px; border-left: 3px solid #e6a23c; padding-left: 8px; margin-bottom: 6px; cursor: pointer; }
61
+ .ann-item .meta { color: #909399; font-size: 11px; margin-top: 6px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
62
+ .ann-item .ops { margin-left: auto; display: flex; gap: 8px; }
63
+ .ann-item .ops span { cursor: pointer; color: #409eff; }
64
+ .ann-dialog { position: fixed; z-index: 300; inset: 0; background: rgba(0,0,0,.3); display: flex; align-items: center; justify-content: center; }
65
+ .ann-dialog .box { background: #fff; border-radius: 8px; width: 540px; padding: 20px 24px; }
66
+ .ann-dialog textarea { width: 100%; height: 90px; border: 1px solid #dcdfe6; border-radius: 4px; padding: 8px; font-family: inherit; font-size: 13px; }
67
+ .ann-flash { animation: annflash 1.6s; }
68
+ @keyframes annflash { 0%, 60% { background: #ffe58f; } 100% { background: transparent; } }
69
+ .abtn { border: 1px solid #dcdfe6; background: #fff; border-radius: 4px; padding: 4px 10px; font-size: 12px; cursor: pointer; }
70
+ .abtn.primary { background: #409eff; border-color: #409eff; color: #fff; }
71
+ /* 列宽拖拽手柄 */
72
+ .col-resizer { position: absolute; right: -4px; top: 0; bottom: 0; width: 8px; cursor: col-resize; z-index: 3; }
73
+ .col-resizer:hover, body.col-dragging .col-resizer { background: rgba(64,158,255,.35); }
74
+ body.col-dragging { cursor: col-resize; user-select: none; }
75
+ .doc tr:nth-child(even) td { background: #fafbfc; }
76
+ .doc blockquote { margin: 10px 0; padding: 8px 14px; background: #f5f7fa; border-left: 4px solid #c0c8d0; color: #5a6470; font-size: 13px; }
77
+ .doc code { background: #f4f4f5; border-radius: 3px; padding: 1px 5px; font-size: 12px; color: #c0392b; }
78
+ .doc pre { background: #f6f8fa; padding: 12px; border-radius: 4px; overflow: auto; font-size: 12px; }
79
+ .doc pre code { background: none; color: #303133; }
80
+ .doc .mermaid { text-align: center; margin: 14px 0; }
81
+ .doc mark { background: #ffe58f; padding: 0 3px; border-radius: 2px; }
82
+ .doc p, .doc li { font-size: 13.5px; line-height: 1.8; }
83
+ /* ── 封面 / 签字页 ── */
84
+ .cover { text-align: center; padding: 60px 0 30px; page-break-after: always; }
85
+ .cover .comp { font-size: 20px; letter-spacing: 6px; color: #2f5b95; font-weight: 700; }
86
+ .cover .t1 { font-size: 30px; font-weight: 700; margin: 90px 0 6px; }
87
+ .cover .t2 { font-size: 16px; color: #606266; margin-bottom: 100px; }
88
+ .cover table { margin: 0 auto; border-collapse: collapse; font-size: 13px; min-width: 380px; }
89
+ .cover td { border: 1px solid #b8c2cc; padding: 8px 18px; }
90
+ .cover td:first-child { background: #f0f4f8; width: 120px; }
91
+ .cover .conf { margin-top: 60px; color: #c0392b; font-size: 13px; letter-spacing: 2px; }
92
+ .signpage { page-break-before: always; padding-top: 20px; }
93
+ .signpage h2 { border-left: 4px solid #2f5b95; padding-left: 10px; font-size: 18px; }
94
+ .signpage .decl { background: #f5f7fa; padding: 14px 18px; border-radius: 4px; font-size: 13.5px; line-height: 1.9; margin: 14px 0 26px; }
95
+ .signpage table { border-collapse: collapse; width: 100%; font-size: 13px; }
96
+ .signpage th, .signpage td { border: 1px solid #b8c2cc; padding: 14px 12px; }
97
+ .signpage th { background: #f0f4f8; }
98
+ /* ── 编辑器 ── */
99
+ .editor-grid { display: flex; gap: 0; height: calc(100vh - 90px); max-width: 1700px; margin: 0 auto; background: #fff; box-shadow: 0 1px 6px rgba(0,0,0,.1); border-radius: 4px; overflow: hidden; }
100
+ .editor-grid textarea { width: 50%; border: none; outline: none; resize: none; padding: 18px; font-family: "SF Mono", Consolas, Menlo, monospace; font-size: 12.5px; line-height: 1.7; color: #2c3e50; border-right: 1px solid #e4e7ed; }
101
+ .editor-grid .preview { width: 50%; overflow: auto; padding: 18px 26px; }
102
+ /* ── 空状态 ── */
103
+ .empty { max-width: 620px; margin: 80px auto; background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 1px 6px rgba(0,0,0,.08); }
104
+ .empty h2 { margin-top: 0; }
105
+ .empty .drop { border: 2px dashed #b8c2cc; border-radius: 8px; padding: 34px; margin: 20px 0; color: #909399; }
106
+ .empty .drop.over { border-color: #409eff; background: #ecf5ff; }
107
+ .bigbtn { background: #409eff; color: #fff; border: none; border-radius: 5px; padding: 10px 26px; font-size: 15px; cursor: pointer; }
108
+ .hint { font-size: 12px; color: #909399; }
109
+ /* ── 打印 ── */
110
+ @media print {
111
+ body { background: #fff; }
112
+ .topbar, .sidebar, .no-print, .col-resizer, .ann-panel, .ann-dialog, .ann-badge { display: none !important; }
113
+ .has-ann { background: none !important; box-shadow: none !important; }
114
+ .main { padding: 0; overflow: visible; }
115
+ .paper { box-shadow: none; max-width: none; padding: 0; border-radius: 0; }
116
+ .doc h2 { page-break-before: always; }
117
+ .doc h2:first-of-type { page-break-before: avoid; }
118
+ .doc table, .doc pre, .doc .mermaid { page-break-inside: avoid; }
119
+ @page { margin: 18mm 16mm; }
120
+ }
121
+ </style>
122
+ </head>
123
+ <body>
124
+ <div id="app">
125
+ <!-- 顶栏 -->
126
+ <div class="topbar no-print">
127
+ <span class="logo">📘 PRD 工作台</span>
128
+ <span class="sub" :style="isFull ? '' : 'color:#ffd04b'">{{isFull ? '主修订版' : '标注版 · 只读+批注'}}</span>
129
+ <span class="file" v-if="fileName">{{fileName}} <span v-if="dirty" class="dirty-dot" title="有未保存修改"></span></span>
130
+ <div class="tabs" v-if="mdText">
131
+ <span class="tab" :class="{on: view==='customer'}" @click="view='customer'">客户确认视图</span>
132
+ <span class="tab" :class="{on: view==='full'}" @click="view='full'">PRD 全文</span>
133
+ <span class="tab" v-if="isFull" :class="{on: view==='edit'}" @click="view='edit'">源码编辑</span>
134
+ </div>
135
+ <span style="margin-left:auto"></span>
136
+ <button v-if="mdText" class="btn" @click="openFile">打开其他 PRD</button>
137
+ <button v-if="isFull && mdText && dirty" class="btn warn" @click="save">💾 保存{{canWriteBack ? '回原文件' : '(下载)'}}</button>
138
+ <button v-if="mdText && view!=='edit'" class="btn warn" @click="annPanel=!annPanel">💬 批注 ({{annotations.length}})</button>
139
+ <button v-if="mdText && view!=='edit'" class="btn primary" @click="doPrint">🖨 打印 / 存为 PDF</button>
140
+ </div>
141
+
142
+ <!-- 空状态:打开文件 -->
143
+ <div v-if="!mdText" class="empty">
144
+ <h2>打开一份 PRD(Markdown)</h2>
145
+ <p class="hint">md 是唯一存储格式,本工作台负责:渲染全文(含 mermaid 图)、客户确认视图(章节过滤+签字页)、批注{{isFull ? '汇总、源码编辑与保存' : '(导出给主修订人汇总)'}}、打印为 PDF。</p>
146
+ <div class="drop" :class="{over: dragOver}" @dragover.prevent="dragOver=true" @dragleave="dragOver=false" @drop.prevent="onDrop">
147
+ 将 PRD .md 文件拖到这里
148
+ </div>
149
+ <button class="bigbtn" @click="openFile">选择文件打开</button>
150
+ <p class="hint" style="margin-top:16px">推荐 Chrome / Edge 打开——支持编辑后直接保存回原文件;其他浏览器保存时将下载新文件手动替换。<br/>试点文件:docs/20-prd/P06-01_工单管理_PRD_v0.3.md</p>
151
+ </div>
152
+
153
+ <div v-else class="wrap">
154
+ <!-- 侧栏 -->
155
+ <div class="sidebar no-print">
156
+ <template v-if="view==='customer'">
157
+ <h4>客户确认视图 · 章节选择 <span class="link-btn" @click="resetChapters">恢复默认</span></h4>
158
+ <div class="chap" v-for="c in chapters" :key="c.key" @click="toggleChap(c.key)">
159
+ <input type="checkbox" :checked="selected.has(c.key)" @click.stop="toggleChap(c.key)"/>
160
+ <span class="n">{{c.num===null?'—':c.num}}</span><span>{{c.title}}</span>
161
+ </div>
162
+ <h4>签署信息(进入封面)</h4>
163
+ <div class="field"><label>文档编号</label><input v-model="meta.docNo"/></div>
164
+ <div class="field"><label>版本</label><input v-model="meta.version"/></div>
165
+ <div class="field"><label>日期</label><input v-model="meta.date"/></div>
166
+ <div class="field"><label>密级</label><input v-model="meta.conf"/></div>
167
+ <h4>提示</h4>
168
+ <p class="hint">打印时请在浏览器打印设置中:关闭"页眉和页脚"、开启"背景图形"、选择"另存为 PDF"。</p>
169
+ <p class="hint">正文中 <mark style="background:#ffe58f">TBD-xx / Q-xx</mark> 为待客户确认事项,已自动高亮。</p>
170
+ <p class="hint">📐 表格列宽:拖动<b>表头右缘</b>调整,<b>双击</b>恢复默认;同表头的表格(如各单据字段表)共享列宽,调一处全部生效,结果本机记忆并按调整后宽度打印。</p>
171
+ <h4>批注(不修改原文)</h4>
172
+ <div class="field"><label>批注人姓名</label><input v-model="annAuthor" placeholder="如:王建国"/></div>
173
+ <p class="hint" v-if="isFull">💬 <b>双击</b>正文任意段落/表格行即可批注,仅存本机不改 md。导入各评审人的批注 JSON 汇总意见,逐条处置后在源码编辑视图集中修订。打印 PDF 不含批注。</p>
174
+ <p class="hint" v-else>💬 <b>双击</b>正文任意段落/表格行即可批注,仅存本机不改 md。评审后点"导出我的批注",把生成的 JSON 文件发给主修订人。打印 PDF 不含批注。</p>
175
+ <span class="link-btn" @click="exportAnns">导出我的批注</span><template v-if="isFull"> · <span class="link-btn" @click="importAnns">导入批注</span></template> · <span class="link-btn" @click="annPanel=!annPanel">批注面板({{annotations.length}})</span>
176
+ </template>
177
+ <template v-else-if="view==='full'">
178
+ <h4>PRD 全文</h4>
179
+ <p class="hint">完整渲染,含全部章节与 mermaid 状态机/流程图。此视图供内部使用,也可整册打印。</p>
180
+ <h4>批注(不修改原文)</h4>
181
+ <div class="field"><label>批注人姓名</label><input v-model="annAuthor" placeholder="如:王建国"/></div>
182
+ <p class="hint">💬 双击正文任意段落/表格行即可批注;导出{{isFull ? '/导入' : ''}}见批注面板。</p>
183
+ <span class="link-btn" @click="annPanel=!annPanel">批注面板({{annotations.length}})</span>
184
+ </template>
185
+ <template v-else>
186
+ <h4>源码编辑</h4>
187
+ <p class="hint">左侧编辑 md 源码,右侧实时预览。<b>所有修订都发生在这里</b>——保存后客户确认视图与全文视图即为最新版。</p>
188
+ <p class="hint">{{canWriteBack ? '✅ 当前文件支持直接保存回磁盘(Ctrl+S)' : '⚠ 当前打开方式不支持回写,保存将下载新文件,请手动替换原文件'}}</p>
189
+ <h4>修订纪律</h4>
190
+ <p class="hint">1. 评审意见当场改源码;<br/>2. 改动涉及口径的,§15 变更记录加一行;<br/>3. 保存后重新打印客户确认 PDF 升版。</p>
191
+ </template>
192
+ </div>
193
+
194
+ <!-- 主区 -->
195
+ <div class="main">
196
+ <!-- 客户确认视图 -->
197
+ <div v-if="view==='customer'" class="paper">
198
+ <div class="cover">
199
+ <div class="comp">华峰铝业 · MOM 项目</div>
200
+ <div class="t1">{{docTitleCN}}</div>
201
+ <div class="t2">业务蓝图(客户确认版)</div>
202
+ <table>
203
+ <tr><td>文档编号</td><td>{{meta.docNo}}</td></tr>
204
+ <tr><td>文档版本</td><td>{{meta.version}}</td></tr>
205
+ <tr><td>出具日期</td><td>{{meta.date}}</td></tr>
206
+ <tr><td>编制方</td><td>乙方项目组</td></tr>
207
+ <tr><td>确认方</td><td>华峰铝业</td></tr>
208
+ </table>
209
+ <div class="conf">【{{meta.conf}}】</div>
210
+ </div>
211
+ <div class="doc" v-html="customerHtml" ref="customerDoc"></div>
212
+ <div class="signpage">
213
+ <h2>业务蓝图确认签署</h2>
214
+ <div class="decl">
215
+ 贵我双方确认:本业务蓝图所述之业务范围、用户故事与验收场景、业务流程与业务规则、单据状态与数据口径、功能清单、权限要求及验收标准,符合贵方业务要求,作为后续系统设计、开发与验收的业务依据。文中"风险与待确认问题"所列事项按评审会议结论执行。签署后如需调整业务口径,双方按项目变更流程处理。
216
+ </div>
217
+ <table>
218
+ <tr><th style="width:22%">签署方</th><th style="width:22%">角色</th><th>姓名(签字)</th><th style="width:18%">日期</th></tr>
219
+ <tr><td>甲方(华峰铝业)</td><td>业务负责人</td><td></td><td></td></tr>
220
+ <tr><td>甲方(华峰铝业)</td><td>项目经理</td><td></td><td></td></tr>
221
+ <tr><td>乙方</td><td>产品经理</td><td></td><td></td></tr>
222
+ <tr><td>乙方</td><td>项目经理</td><td></td><td></td></tr>
223
+ </table>
224
+ </div>
225
+ </div>
226
+
227
+ <!-- 全文视图 -->
228
+ <div v-else-if="view==='full'" class="paper">
229
+ <div class="doc" v-html="fullHtml" ref="fullDoc"></div>
230
+ </div>
231
+
232
+ <!-- 编辑视图 -->
233
+ <div v-else class="editor-grid">
234
+ <textarea v-model="mdText" spellcheck="false" @input="dirty=true"></textarea>
235
+ <div class="preview doc" v-html="editPreviewHtml" ref="editDoc"></div>
236
+ </div>
237
+ </div>
238
+ </div>
239
+
240
+ <!-- 批注面板(主修订人汇总视图) -->
241
+ <div v-if="annPanel && mdText" class="ann-panel no-print">
242
+ <div class="hd">💬 批注({{annotations.length}})
243
+ <span style="margin-left:auto"></span>
244
+ <button class="abtn" @click="exportAnns">导出</button>
245
+ <button v-if="isFull" class="abtn" @click="importAnns">导入</button>
246
+ <button class="abtn" @click="annPanel=false">✕</button>
247
+ </div>
248
+ <div class="ann-list">
249
+ <div v-if="!annotations.length" class="hint" style="text-align:center;padding:36px 0">暂无批注<br/>双击正文任意段落/表格行添加</div>
250
+ <div v-for="a in sortedAnns" :key="a.id" class="ann-item" :class="{done: a.status==='done'}">
251
+ <div class="quote" @click="locateAnn(a)" title="点击定位原文">「{{a.quote}}」<span v-if="a.located===false" style="color:#f56c6c">(未定位:章节未勾选或原文已修改)</span></div>
252
+ <div style="white-space:pre-wrap">{{a.text}}</div>
253
+ <div class="meta">
254
+ <b>{{a.author}}</b><span>{{a.time}}</span>
255
+ <span v-if="a.status==='done'" style="color:#67c23a">✓ 已处理</span>
256
+ <span v-if="a.status==='hold'" style="color:#e6a23c">⏸ 搁置</span>
257
+ <span class="ops">
258
+ <span v-if="isFull" @click="toggleDone(a)">{{a.status==='done' ? '重开' : '已处理'}}</span>
259
+ <span v-if="isFull && a.status!=='hold'" @click="a.status='hold'; saveAnns()">搁置</span>
260
+ <span v-if="a.author===annAuthor" @click="editAnn(a)">改</span>
261
+ <span v-if="isFull || a.author===annAuthor" style="color:#f56c6c" @click="delAnn(a)">删</span>
262
+ </span>
263
+ </div>
264
+ </div>
265
+ </div>
266
+ </div>
267
+
268
+ <!-- 批注输入弹窗 -->
269
+ <div v-if="annDialog.visible" class="ann-dialog no-print" @click.self="annDialog.visible=false">
270
+ <div class="box">
271
+ <h3 style="margin-top:0">💬 {{annDialog.editingId ? '修改批注' : '添加批注'}}(不修改原文)</h3>
272
+ <div class="quote" style="color:#909399;font-size:12px;border-left:3px solid #e6a23c;padding-left:8px;margin-bottom:10px">「{{annDialog.quote}}」</div>
273
+ <textarea v-model="annText" placeholder="记录你的意见(如:此处口径与现场不符,建议改为……)"></textarea>
274
+ <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px;align-items:center">
275
+ <span class="hint" style="margin-right:auto">批注人:{{annAuthor || '(请先在侧栏填写姓名)'}}</span>
276
+ <button class="abtn" @click="annDialog.visible=false">取消</button>
277
+ <button class="abtn primary" @click="saveAnnFromDialog">保存批注</button>
278
+ </div>
279
+ </div>
280
+ </div>
281
+ </div>
282
+
283
+ <script>
284
+ const { createApp, ref, reactive, computed, watch, nextTick, onMounted } = Vue;
285
+
286
+ /* 版本开关:'full'=主修订版(含源码编辑+批注汇总修订);'review'=评审版(只读+批注,发给业务评审人)
287
+ 评审版生成方式:复制本文件,把下行改为 'review'(或用 docs/70-scripts/build_review_workbench.py 一键生成) */
288
+ const EDITION = 'full';
289
+
290
+ mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'loose' });
291
+ marked.setOptions({ gfm: true, breaks: false });
292
+
293
+ const DEFAULT_CUSTOMER_CHAPTERS = [1, 2, 3, 4, 6, 7, 9, 14];
294
+
295
+ createApp({
296
+ setup() {
297
+ const isFull = EDITION === 'full';
298
+ const mdText = ref('');
299
+ const fileName = ref('');
300
+ const fileHandle = ref(null);
301
+ const dirty = ref(false);
302
+ const view = ref('customer');
303
+ const dragOver = ref(false);
304
+ const selected = reactive(new Set());
305
+ const meta = reactive({ docNo: '', version: '', date: new Date().toISOString().slice(0,10), conf: '内部资料' });
306
+ const canWriteBack = computed(() => !!fileHandle.value);
307
+
308
+ /* ── 章节切分(代码块内的 ## 不算标题) ── */
309
+ /* 中文数字 → 数值(一~九十九) */
310
+ const cn2num = s => {
311
+ const m = { 一:1, 二:2, 三:3, 四:4, 五:5, 六:6, 七:7, 八:8, 九:9 };
312
+ if (s === '十') return 10;
313
+ if (s.length === 1) return m[s] || null;
314
+ if (s[0] === '十') return 10 + (m[s[1]] || 0);
315
+ if (s[1] === '十') return (m[s[0]] || 0) * 10 + (s[2] ? m[s[2]] || 0 : 0);
316
+ return null;
317
+ };
318
+ const chapters = computed(() => {
319
+ if (!mdText.value) return [];
320
+ const lines = mdText.value.split('\n');
321
+ const out = []; let cur = { key: 'head', num: null, title: '文档头(标题/版本说明)', body: [] };
322
+ let inCode = false;
323
+ for (const line of lines) {
324
+ if (/^\s*```/.test(line)) inCode = !inCode;
325
+ let next = null;
326
+ if (!inCode) {
327
+ let m;
328
+ if ((m = line.match(/^##\s+(\d+)[\.、.]?\s*(.*)$/))) {
329
+ next = { key: 'c' + m[1], num: +m[1], title: m[2].trim() }; // 阿拉伯数字:## 1. 标题
330
+ } else if ((m = line.match(/^##\s+([一二三四五六七八九十]+)[、\..]?\s*(.*)$/))) {
331
+ const n = cn2num(m[1]);
332
+ next = { key: 'c' + n, num: n, title: m[2].trim() }; // 中文数字:## 一、标题
333
+ } else if ((m = line.match(/^##\s+(\S.*)$/))) {
334
+ next = { key: 'u' + out.length, num: null, title: m[1].trim() }; // 无编号 H2:独立成章,默认不勾选
335
+ }
336
+ }
337
+ if (next) { out.push(cur); cur = { ...next, body: [line] }; }
338
+ else cur.body.push(line);
339
+ }
340
+ out.push(cur);
341
+ return out.map(c => ({ ...c, md: c.body.join('\n') }));
342
+ });
343
+
344
+ const docTitleCN = computed(() => {
345
+ const h1 = (mdText.value.match(/^#\s+(.+)$/m) || [])[1] || fileName.value;
346
+ return h1.replace(/[—-]*\s*(用户需求规格说明书)?\s*\(?PRD\)?\s*v?[\d.]*\s*$/i, '').trim() || h1;
347
+ });
348
+
349
+ /* ── 渲染 ── */
350
+ function render(md) { return marked.parse(md); }
351
+ function highlightTbd(html) {
352
+ return html.replace(/(?<![\w-])((?:TBD|Q|R)-\d{1,3})(?![\w-])/g, '<mark>$1</mark>');
353
+ }
354
+ const customerHtml = computed(() => {
355
+ const parts = chapters.value.filter(c => selected.has(c.key)).map(c => c.md);
356
+ return highlightTbd(render(parts.join('\n\n')));
357
+ });
358
+ const fullHtml = computed(() => render(mdText.value));
359
+ const editPreviewHtml = computed(() => render(mdText.value));
360
+
361
+ /* ── mermaid 渲染(渲染后处理 code.language-mermaid) ── */
362
+ const customerDoc = ref(null), fullDoc = ref(null), editDoc = ref(null);
363
+ let mermaidSeq = 0;
364
+ async function runMermaid(container) {
365
+ if (!container) return;
366
+ const blocks = container.querySelectorAll('pre code.language-mermaid');
367
+ for (const code of blocks) {
368
+ const pre = code.parentElement;
369
+ const div = document.createElement('div');
370
+ div.className = 'mermaid';
371
+ div.textContent = code.textContent;
372
+ pre.replaceWith(div);
373
+ }
374
+ const nodes = container.querySelectorAll('.mermaid:not([data-processed])');
375
+ if (nodes.length) {
376
+ try { await mermaid.run({ nodes, suppressErrors: true }); } catch (e) { /* 保留源码展示 */ }
377
+ }
378
+ }
379
+ /* ── 表格列宽拖拽(拖表头右缘调整;双击恢复;同表头共享;localStorage 记忆)── */
380
+ function colStoreKey() { return 'prdwb:colw:' + fileName.value; }
381
+ function enhanceTables(container) {
382
+ if (!container) return;
383
+ let store = {};
384
+ try { store = JSON.parse(localStorage.getItem(colStoreKey()) || '{}'); } catch (e) {}
385
+ const saveStore = () => { try { localStorage.setItem(colStoreKey(), JSON.stringify(store)); } catch (e) {} };
386
+ container.querySelectorAll('table').forEach(tb => {
387
+ if (tb.dataset.colEnhanced) return;
388
+ tb.dataset.colEnhanced = '1';
389
+ const ths = Array.from(tb.querySelectorAll('tr:first-child th'));
390
+ if (!ths.length) return;
391
+ const sig = ths.map(t => t.textContent.trim()).join('|');
392
+ const freeze = () => {
393
+ if (tb.style.tableLayout === 'fixed') return;
394
+ const ws = ths.map(t => t.getBoundingClientRect().width);
395
+ tb.style.tableLayout = 'fixed';
396
+ ths.forEach((t, j) => { t.style.width = ws[j] + 'px'; });
397
+ };
398
+ const saved = store[sig];
399
+ if (saved && saved.length === ths.length) {
400
+ tb.style.tableLayout = 'fixed';
401
+ ths.forEach((t, j) => { if (saved[j]) t.style.width = saved[j] + 'px'; });
402
+ }
403
+ ths.forEach(th => {
404
+ th.style.position = 'relative';
405
+ const h = document.createElement('div');
406
+ h.className = 'col-resizer';
407
+ h.title = '拖动调整列宽 · 双击恢复默认';
408
+ h.addEventListener('dblclick', e => {
409
+ e.stopPropagation();
410
+ delete store[sig]; saveStore();
411
+ tb.style.tableLayout = '';
412
+ ths.forEach(t => { t.style.width = ''; });
413
+ });
414
+ h.addEventListener('mousedown', e => {
415
+ e.preventDefault();
416
+ freeze();
417
+ const startX = e.clientX, startW = th.getBoundingClientRect().width;
418
+ document.body.classList.add('col-dragging');
419
+ const move = ev => { th.style.width = Math.max(40, startW + ev.clientX - startX) + 'px'; };
420
+ const up = () => {
421
+ document.removeEventListener('mousemove', move);
422
+ document.removeEventListener('mouseup', up);
423
+ document.body.classList.remove('col-dragging');
424
+ store[sig] = ths.map(t => Math.round(t.getBoundingClientRect().width));
425
+ saveStore();
426
+ };
427
+ document.addEventListener('mousemove', move);
428
+ document.addEventListener('mouseup', up);
429
+ });
430
+ th.appendChild(h);
431
+ });
432
+ });
433
+ }
434
+ /* ── 批注(只读旁注,不修改 md;本机存储 + 导出/导入 JSON 汇总)── */
435
+ const annAuthor = ref('');
436
+ try { annAuthor.value = localStorage.getItem('prdwb:annAuthor') || ''; } catch (e) {}
437
+ watch(annAuthor, v => { try { localStorage.setItem('prdwb:annAuthor', v); } catch (e) {} });
438
+ const annotations = ref([]);
439
+ const annPanel = ref(false);
440
+ const annDialog = reactive({ visible: false, quote: '', anchor: null, editingId: null });
441
+ const annText = ref('');
442
+ const annKey = () => 'prdwb:ann:' + fileName.value;
443
+ function loadAnns() { try { annotations.value = JSON.parse(localStorage.getItem(annKey()) || '[]'); } catch (e) { annotations.value = []; } }
444
+ function saveAnns() { try { localStorage.setItem(annKey(), JSON.stringify(annotations.value)); } catch (e) {} nextTick(redecorate); }
445
+ const BLOCK_SEL = 'p, li, tr, h3, h4, pre, blockquote';
446
+ const normT = el => (el.textContent || '').replace(/\s+/g, ' ').trim();
447
+ function anchorOf(container, el) {
448
+ const snip = normT(el).slice(0, 80);
449
+ const same = Array.from(container.querySelectorAll(el.tagName)).filter(x => normT(x).slice(0, 80) === snip);
450
+ return { tag: el.tagName, snippet: snip, occ: Math.max(0, same.indexOf(el)) };
451
+ }
452
+ function findBlock(container, anchor) {
453
+ if (!container || !anchor) return null;
454
+ const cands = Array.from(container.querySelectorAll(anchor.tag)).filter(x => normT(x).slice(0, 80) === anchor.snippet);
455
+ return cands[anchor.occ] || cands[0] || null;
456
+ }
457
+ const activeDoc = () => view.value === 'customer' ? customerDoc.value : (view.value === 'full' ? fullDoc.value : null);
458
+ function redecorate() {
459
+ const c = activeDoc(); if (!c) return;
460
+ c.querySelectorAll('.has-ann').forEach(el => el.classList.remove('has-ann'));
461
+ c.querySelectorAll('.ann-badge').forEach(el => el.remove());
462
+ const byBlock = new Map();
463
+ annotations.value.forEach(a => {
464
+ const el = findBlock(c, a.anchor);
465
+ a.located = !!el;
466
+ if (el) { if (!byBlock.has(el)) byBlock.set(el, []); byBlock.get(el).push(a); }
467
+ });
468
+ byBlock.forEach((list, el) => {
469
+ el.classList.add('has-ann');
470
+ const b = document.createElement('span');
471
+ b.className = 'ann-badge'; b.textContent = '💬' + list.length; b.title = '查看批注';
472
+ b.onclick = e => { e.stopPropagation(); annPanel.value = true; };
473
+ (el.tagName === 'TR' ? (el.lastElementChild || el) : el).appendChild(b);
474
+ });
475
+ }
476
+ function onDocDblclick(e) {
477
+ const c = activeDoc(); if (!c || !c.contains(e.target)) return;
478
+ const el = e.target.closest(BLOCK_SEL); if (!el || !normT(el)) return;
479
+ if (!annAuthor.value.trim()) { toast('请先在左侧栏填写批注人姓名'); return; }
480
+ annDialog.anchor = anchorOf(c, el);
481
+ annDialog.quote = normT(el).slice(0, 120);
482
+ annDialog.editingId = null; annText.value = '';
483
+ annDialog.visible = true;
484
+ }
485
+ function saveAnnFromDialog() {
486
+ if (!annText.value.trim()) { toast('批注内容不能为空'); return; }
487
+ if (annDialog.editingId) {
488
+ const a = annotations.value.find(x => x.id === annDialog.editingId);
489
+ if (a) a.text = annText.value.trim();
490
+ } else {
491
+ annotations.value.push({ id: 'an_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
492
+ anchor: annDialog.anchor, quote: annDialog.quote, text: annText.value.trim(),
493
+ author: annAuthor.value.trim(), time: new Date().toLocaleString(), status: 'open' });
494
+ }
495
+ annDialog.visible = false; saveAnns(); toast('批注已保存(不影响原文)');
496
+ }
497
+ function editAnn(a) { annDialog.editingId = a.id; annDialog.quote = a.quote; annDialog.anchor = a.anchor; annText.value = a.text; annDialog.visible = true; }
498
+ function delAnn(a) { annotations.value = annotations.value.filter(x => x.id !== a.id); saveAnns(); }
499
+ function toggleDone(a) { a.status = a.status === 'done' ? 'open' : 'done'; saveAnns(); }
500
+ function locateAnn(a) {
501
+ const c = activeDoc(); const el = c && findBlock(c, a.anchor);
502
+ if (!el) { toast('未能定位:所在章节未勾选或原文已修改'); return; }
503
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
504
+ el.classList.remove('ann-flash'); void el.offsetWidth; el.classList.add('ann-flash');
505
+ }
506
+ const sortedAnns = computed(() => {
507
+ const c = activeDoc();
508
+ return [...annotations.value].sort((a, b) => {
509
+ const ea = c && findBlock(c, a.anchor), eb = c && findBlock(c, b.anchor);
510
+ if (ea && eb) return (ea.compareDocumentPosition(eb) & 2) ? 1 : -1;
511
+ if (ea) return -1; if (eb) return 1; return 0;
512
+ });
513
+ });
514
+ function exportAnns() {
515
+ const mine = annotations.value.filter(a => !annAuthor.value.trim() || a.author === annAuthor.value.trim());
516
+ if (!mine.length) { toast('暂无可导出的批注'); return; }
517
+ const blob = new Blob([JSON.stringify({ file: fileName.value, author: annAuthor.value, exportedAt: new Date().toLocaleString(), annotations: mine }, null, 2)], { type: 'application/json' });
518
+ const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
519
+ a.download = fileName.value.replace(/\.md$/, '') + '_批注_' + (annAuthor.value.trim() || '未署名') + '.json'; a.click(); URL.revokeObjectURL(a.href);
520
+ toast('已导出 ' + mine.length + ' 条批注,请发送给主修订人');
521
+ }
522
+ function importAnns() {
523
+ const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.multiple = true;
524
+ input.onchange = async () => {
525
+ let added = 0;
526
+ for (const f of input.files) {
527
+ try {
528
+ const data = JSON.parse(await f.text());
529
+ (data.annotations || []).forEach(a => { if (a && a.id && !annotations.value.some(x => x.id === a.id)) { annotations.value.push(a); added++; } });
530
+ } catch (e) { toast('文件解析失败:' + f.name); }
531
+ }
532
+ saveAnns(); annPanel.value = true; toast('导入完成:新增 ' + added + ' 条批注');
533
+ };
534
+ input.click();
535
+ }
536
+
537
+ watch([customerHtml, view], () => nextTick(() => { if (view.value === 'customer') { runMermaid(customerDoc.value); enhanceTables(customerDoc.value); redecorate(); } }), { flush: 'post' });
538
+ watch([fullHtml, view], () => nextTick(() => { if (view.value === 'full') { runMermaid(fullDoc.value); enhanceTables(fullDoc.value); redecorate(); } }), { flush: 'post' });
539
+ let editTimer = null;
540
+ watch([editPreviewHtml, view], () => {
541
+ if (view.value !== 'edit') return;
542
+ clearTimeout(editTimer);
543
+ editTimer = setTimeout(() => nextTick(() => runMermaid(editDoc.value)), 600);
544
+ }, { flush: 'post' });
545
+
546
+ /* ── 章节选择持久化 ── */
547
+ function storeKey() { return 'prdwb:' + fileName.value; }
548
+ function resetChapters() {
549
+ selected.clear();
550
+ chapters.value.forEach(c => { if (c.num !== null && DEFAULT_CUSTOMER_CHAPTERS.includes(c.num)) selected.add(c.key); });
551
+ persist();
552
+ }
553
+ function toggleChap(k) { selected.has(k) ? selected.delete(k) : selected.add(k); persist(); }
554
+ function persist() {
555
+ try { localStorage.setItem(storeKey(), JSON.stringify({ sel: [...selected], meta: { ...meta } })); } catch (e) {}
556
+ }
557
+ watch(meta, persist);
558
+ function restoreOrDefault() {
559
+ try {
560
+ const saved = JSON.parse(localStorage.getItem(storeKey()) || 'null');
561
+ if (saved) {
562
+ selected.clear(); saved.sel.forEach(k => selected.add(k));
563
+ Object.assign(meta, saved.meta || {});
564
+ return;
565
+ }
566
+ } catch (e) {}
567
+ resetChapters();
568
+ }
569
+
570
+ /* ── 文件打开/保存 ── */
571
+ function afterLoad(name, text) {
572
+ fileName.value = name; mdText.value = text; dirty.value = false;
573
+ const mod = (name.match(/P\d{2}-\d{2}/) || [''])[0];
574
+ const ver = (name.match(/v\d+\.\d+/) || (text.match(/v\d+\.\d+/) || ['v1.0']))[0];
575
+ meta.docNo = meta.docNo || (mod ? `MOM-PRD-${mod}` : 'MOM-PRD-');
576
+ meta.version = ver;
577
+ restoreOrDefault();
578
+ // frontmatter-free;docNo 若本地有存档 restoreOrDefault 已覆盖;旧前缀自动迁移
579
+ if (!meta.docNo && mod) meta.docNo = `MOM-PRD-${mod}`;
580
+ meta.docNo = (meta.docNo || '').replace(/^HFLY-PRD-/, 'MOM-PRD-');
581
+ loadAnns();
582
+ }
583
+ async function openFile() {
584
+ if (window.showOpenFilePicker) {
585
+ try {
586
+ const [h] = await window.showOpenFilePicker({ types: [{ description: 'Markdown', accept: { 'text/markdown': ['.md'] } }] });
587
+ const f = await h.getFile();
588
+ fileHandle.value = h;
589
+ afterLoad(f.name, await f.text());
590
+ } catch (e) { /* 用户取消 */ }
591
+ } else {
592
+ const input = document.createElement('input');
593
+ input.type = 'file'; input.accept = '.md';
594
+ input.onchange = async () => {
595
+ const f = input.files[0]; if (!f) return;
596
+ fileHandle.value = null;
597
+ afterLoad(f.name, await f.text());
598
+ };
599
+ input.click();
600
+ }
601
+ }
602
+ async function onDrop(e) {
603
+ dragOver.value = false;
604
+ const item = e.dataTransfer.items && e.dataTransfer.items[0];
605
+ if (item && item.getAsFileSystemHandle) {
606
+ try {
607
+ const h = await item.getAsFileSystemHandle();
608
+ if (h.kind === 'file') { const f = await h.getFile(); fileHandle.value = h; afterLoad(f.name, await f.text()); return; }
609
+ } catch (err) {}
610
+ }
611
+ const f = e.dataTransfer.files[0];
612
+ if (f) { fileHandle.value = null; afterLoad(f.name, await f.text()); }
613
+ }
614
+ async function save() {
615
+ if (fileHandle.value) {
616
+ try {
617
+ if ((await fileHandle.value.queryPermission({ mode: 'readwrite' })) !== 'granted') {
618
+ await fileHandle.value.requestPermission({ mode: 'readwrite' });
619
+ }
620
+ const w = await fileHandle.value.createWritable();
621
+ await w.write(mdText.value); await w.close();
622
+ dirty.value = false;
623
+ toast('✅ 已保存回 ' + fileName.value);
624
+ return;
625
+ } catch (e) { toast('⚠ 回写失败,转为下载:' + e.message); }
626
+ }
627
+ const blob = new Blob([mdText.value], { type: 'text/markdown' });
628
+ const a = document.createElement('a');
629
+ a.href = URL.createObjectURL(blob); a.download = fileName.value || 'prd.md'; a.click();
630
+ URL.revokeObjectURL(a.href);
631
+ dirty.value = false;
632
+ toast('已下载新文件,请替换原 PRD 文件');
633
+ }
634
+ function toast(t) {
635
+ const d = document.createElement('div');
636
+ d.textContent = t;
637
+ d.style.cssText = 'position:fixed;top:60px;left:50%;transform:translateX(-50%);background:#303133;color:#fff;padding:8px 18px;border-radius:4px;z-index:999;font-size:13px';
638
+ document.body.appendChild(d); setTimeout(() => d.remove(), 2600);
639
+ }
640
+ function doPrint() { window.print(); }
641
+
642
+ onMounted(() => {
643
+ window.addEventListener('keydown', e => {
644
+ if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); if (isFull && mdText.value) save(); }
645
+ });
646
+ window.addEventListener('beforeunload', e => { if (dirty.value) { e.preventDefault(); e.returnValue = ''; } });
647
+ document.addEventListener('dblclick', onDocDblclick);
648
+ // 经本地 http 服务打开时,自动尝试加载试点 PRD(file:// 下会被浏览器拦截,忽略即可)
649
+ fetch('./P06-01_工单管理_PRD_v0.3.md').then(r => r.ok ? r.text() : null).then(t => {
650
+ if (t && !mdText.value) afterLoad('P06-01_工单管理_PRD_v0.3.md', t);
651
+ }).catch(() => {});
652
+ });
653
+
654
+ return { isFull, mdText, fileName, dirty, view, dragOver, selected, meta, canWriteBack,
655
+ chapters, docTitleCN, customerHtml, fullHtml, editPreviewHtml,
656
+ customerDoc, fullDoc, editDoc,
657
+ openFile, onDrop, save, doPrint, toggleChap, resetChapters,
658
+ annAuthor, annotations, annPanel, annDialog, annText, sortedAnns,
659
+ saveAnnFromDialog, editAnn, delAnn, toggleDone, locateAnn, exportAnns, importAnns };
660
+ }
661
+ }).mount('#app');
662
+ </script>
663
+ </body>
664
+ </html>