cell-storage-process-console-prototype.html 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  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>中科兴皖 · 业务办理(并行流程原型 v2)</title>
  7. <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
  8. <style>
  9. :root {
  10. --primary: #10b981; --primary-dark: #059669; --primary-bg: #ecfdf5;
  11. --text: #1e293b; --text-muted: #64748b; --border: #d9e8e2;
  12. --bg-page: #fefefe; --surface: #fff; --table-head: #f8fafc;
  13. --row-hover: #f1f5f9; --shadow: 0 1px 3px rgba(0,0,0,0.05);
  14. --radius: 10px; --warn: #d97706; --warn-bg: #fffbeb;
  15. --info: #3b82f6; --info-bg: #eff6ff;
  16. }
  17. * { box-sizing: border-box; margin: 0; padding: 0; }
  18. body { font-family: Inter, sans-serif; font-size: 14px; line-height: 1.5; color: var(--text); background: var(--bg-page); }
  19. .layout { display: flex; min-height: 100vh; }
  20. .sidebar { width: 220px; background: var(--surface); border-right: 1px solid var(--border); padding: 24px 16px; flex-shrink: 0; }
  21. .logo { font-size: 16px; font-weight: 700; padding: 0 12px 20px; border-bottom: 1px solid var(--border); margin-bottom: 12px; }
  22. .logo span { color: var(--primary); }
  23. .nav-item { display: block; padding: 8px 12px; color: var(--text-muted); text-decoration: none; border-radius: 8px; font-size: 13px; }
  24. .nav-item.active { background: var(--primary-bg); color: var(--primary-dark); font-weight: 600; }
  25. .main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
  26. .topbar { height: 56px; padding: 0 24px; display: flex; align-items: center; justify-content: space-between; background: var(--surface); border-bottom: 1px solid var(--border); }
  27. .topbar h1 { font-size: 15px; font-weight: 600; }
  28. .topbar-meta { font-size: 12px; color: var(--text-muted); }
  29. .page { flex: 1; display: flex; min-height: 0; }
  30. .case-list { width: 300px; border-right: 1px solid var(--border); background: var(--surface); display: flex; flex-direction: column; }
  31. .case-list-head { padding: 14px 16px; border-bottom: 1px solid var(--border); }
  32. .case-list-head h2 { font-size: 13px; font-weight: 600; margin-bottom: 8px; }
  33. .search-input { width: 100%; height: 34px; border: 1px solid var(--border); border-radius: 8px; padding: 0 10px; font-size: 12px; }
  34. .case-items { flex: 1; overflow-y: auto; }
  35. .case-card { padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; }
  36. .case-card:hover { background: var(--row-hover); }
  37. .case-card.active { background: var(--primary-bg); border-left: 3px solid var(--primary); padding-left: 13px; }
  38. .case-protocol { font-weight: 600; font-size: 13px; }
  39. .case-customer { font-size: 12px; color: var(--text-muted); margin: 4px 0 8px; }
  40. .active-tags { display: flex; flex-wrap: wrap; gap: 4px; }
  41. .tag { font-size: 11px; padding: 1px 6px; border-radius: 5px; font-weight: 500; }
  42. .tag.success { background: var(--primary-bg); color: var(--primary-dark); }
  43. .tag.warning { background: var(--warn-bg); color: var(--warn); }
  44. .tag.info { background: var(--info-bg); color: var(--info); }
  45. .tag.muted { background: var(--table-head); color: var(--text-muted); }
  46. .console { flex: 1; overflow-y: auto; padding: 12px 16px 16px; }
  47. .proto-tip {
  48. padding: 10px 14px; background: linear-gradient(90deg, var(--primary-bg), #fff);
  49. border: 1px solid var(--border); border-radius: var(--radius); font-size: 12px; color: var(--text-muted); margin-bottom: 12px;
  50. }
  51. .proto-tip strong { color: var(--primary-dark); }
  52. .legend { display: flex; flex-wrap: wrap; gap: 12px 20px; font-size: 11px; color: var(--text-muted); margin-bottom: 12px; }
  53. .legend span { display: flex; align-items: center; gap: 5px; }
  54. .legend i { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
  55. .legend .solid { background: var(--primary); }
  56. .legend .dash { border: 2px dashed var(--text-muted); background: transparent; height: 0; width: 16px; }
  57. .legend .fork { background: var(--warn); }
  58. .summary-bar {
  59. display: flex; flex-wrap: wrap; gap: 16px 28px; padding: 12px 16px;
  60. background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 12px;
  61. }
  62. .summary-item dt { font-size: 11px; color: var(--text-muted); }
  63. .summary-item dd { font-size: 14px; font-weight: 600; }
  64. /* ── Flow graph (DAG, not linear) ── */
  65. .flow-panel {
  66. background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  67. padding: 16px; margin-bottom: 12px; overflow-x: auto;
  68. }
  69. .flow-panel h3 { font-size: 12px; font-weight: 600; color: var(--text-muted); margin-bottom: 14px; }
  70. .flow-main { display: flex; align-items: center; gap: 0; min-width: max-content; padding-bottom: 8px; }
  71. .flow-arrow { color: var(--text-muted); font-size: 18px; padding: 0 4px; flex-shrink: 0; user-select: none; }
  72. .flow-node {
  73. width: 88px; text-align: center; cursor: pointer; flex-shrink: 0;
  74. padding: 4px; border-radius: 8px; transition: background 0.12s;
  75. }
  76. .flow-node:hover { background: var(--row-hover); }
  77. .flow-node.selected { background: var(--primary-bg); outline: 2px solid var(--primary); outline-offset: 1px; }
  78. .flow-node.independent { opacity: 0.95; }
  79. .node-box {
  80. width: 72px; height: 52px; margin: 0 auto 4px; border-radius: 8px; border: 2px solid var(--border);
  81. background: var(--surface); display: flex; align-items: center; justify-content: center;
  82. font-size: 11px; font-weight: 600; line-height: 1.2; padding: 4px; position: relative;
  83. }
  84. .flow-node.done .node-box { border-color: var(--primary); background: var(--primary-bg); color: var(--primary-dark); }
  85. .flow-node.active .node-box { border-color: var(--primary); background: var(--primary); color: #fff; box-shadow: 0 0 0 3px rgba(16,185,129,0.25); }
  86. .flow-node.ready .node-box { border-color: var(--warn); background: var(--warn-bg); color: var(--warn); }
  87. .flow-node.locked .node-box { opacity: 0.45; cursor: not-allowed; }
  88. .flow-node.independent .node-box { border-style: dashed; }
  89. .node-dept { font-size: 9px; color: var(--text-muted); }
  90. .node-status { font-size: 10px; margin-top: 2px; }
  91. .flow-node.done .node-status { color: var(--primary-dark); }
  92. .flow-node.active .node-status, .flow-node.ready .node-status { color: var(--warn); font-weight: 600; }
  93. /* Fork: reception → qc + production */
  94. .flow-fork { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; }
  95. .fork-bar { width: 2px; height: 12px; background: var(--border); }
  96. .fork-row { display: flex; gap: 24px; align-items: center; position: relative; }
  97. .fork-row::before {
  98. content: ''; position: absolute; top: 26px; left: 44px; right: 44px; height: 2px; background: var(--border);
  99. }
  100. .fork-label {
  101. font-size: 10px; color: var(--warn); font-weight: 600; background: var(--warn-bg);
  102. padding: 2px 8px; border-radius: 4px; margin-bottom: 4px;
  103. }
  104. .join-wrap { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; }
  105. .join-label { font-size: 10px; color: var(--text-muted); margin: 4px 0; }
  106. /* Independent track: payment */
  107. .flow-side {
  108. margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--border);
  109. display: flex; align-items: center; gap: 8px; min-width: max-content;
  110. }
  111. .side-label { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
  112. .workspace {
  113. background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  114. min-height: 320px; display: flex; flex-direction: column;
  115. }
  116. .workspace-head {
  117. padding: 12px 16px; border-bottom: 1px solid var(--border);
  118. display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px;
  119. }
  120. .workspace-head h3 { font-size: 15px; font-weight: 600; }
  121. .node-badge { font-size: 11px; background: var(--primary-bg); color: var(--primary-dark); padding: 2px 8px; border-radius: 6px; font-weight: 500; }
  122. .dep-hint { font-size: 12px; color: var(--text-muted); padding: 8px 16px; background: var(--table-head); border-bottom: 1px solid var(--border); }
  123. .dep-hint strong { color: var(--primary-dark); }
  124. .workspace-body { padding: 16px; flex: 1; }
  125. .workspace-actions { display: flex; gap: 8px; flex-wrap: wrap; }
  126. .btn {
  127. height: 32px; padding: 0 12px; border-radius: 8px; font-size: 13px; font-weight: 500;
  128. border: 1px solid var(--border); background: var(--surface); cursor: pointer;
  129. }
  130. .btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
  131. .btn-sm { height: 28px; padding: 0 10px; font-size: 12px; }
  132. .readonly-panel { background: var(--table-head); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; margin-bottom: 12px; }
  133. .readonly-panel h4 { font-size: 12px; color: var(--text-muted); margin-bottom: 8px; }
  134. .kv-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
  135. .kv-row dt { font-size: 11px; color: var(--text-muted); }
  136. .kv-row dd { font-size: 13px; font-weight: 500; }
  137. .parallel-note {
  138. margin-top: 12px; padding: 10px 12px; background: var(--info-bg); border: 1px solid rgba(59,130,246,0.2);
  139. border-radius: 8px; font-size: 12px; color: #1d4ed8;
  140. }
  141. .locked-msg { text-align: center; padding: 40px 20px; color: var(--text-muted); }
  142. .toast {
  143. position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(80px);
  144. background: var(--text); color: #fff; padding: 12px 20px; border-radius: var(--radius);
  145. font-size: 13px; opacity: 0; transition: all 0.3s; z-index: 99; max-width: 92vw; text-align: center;
  146. }
  147. .toast.show { transform: translateX(-50%) translateY(0); opacity: 1; }
  148. @media (max-width: 960px) {
  149. .page { flex-direction: column; }
  150. .case-list { width: 100%; max-height: 200px; }
  151. .kv-row { grid-template-columns: 1fr 1fr; }
  152. }
  153. </style>
  154. </head>
  155. <body>
  156. <div class="layout">
  157. <aside class="sidebar">
  158. <div class="logo">中科兴皖<span> · 管理端</span></div>
  159. <a href="#" class="nav-item active">细胞存储 · 业务办理</a>
  160. </aside>
  161. <div class="main">
  162. <header class="topbar">
  163. <h1>细胞存储 · 业务办理</h1>
  164. <span class="topbar-meta">v2 · 并行/独立依赖,非简单串行</span>
  165. </header>
  166. <div class="page">
  167. <aside class="case-list">
  168. <div class="case-list-head">
  169. <h2>进行中业务</h2>
  170. <input class="search-input" id="caseSearch" placeholder="协议号 / 客户 / 样本" />
  171. </div>
  172. <div class="case-items" id="caseList"></div>
  173. </aside>
  174. <section class="console">
  175. <div class="proto-tip">
  176. <strong>v2 修正:</strong>流程不是 8 步串行。缴费<strong>不阻塞</strong>接收/质检/生产;接收后<strong>质检与生产并行</strong>;放行需<strong>两者都完成</strong>(汇合门禁)。点击节点查看下方工作区。
  177. </div>
  178. <div class="legend">
  179. <span><i class="solid"></i> 主链 / 已完成</span>
  180. <span><i class="fork"></i> 并行进行中</span>
  181. <span><i class="dash"></i> 独立支路(不阻塞实验链)</span>
  182. <span>汇合 ⬦ = 需全部前置完成才解锁</span>
  183. </div>
  184. <div class="summary-bar" id="summaryBar"></div>
  185. <div class="flow-panel">
  186. <h3>里程碑依赖图(每个节点独立状态,只按 dependsOn 解锁)</h3>
  187. <div id="flowGraph"></div>
  188. </div>
  189. <div class="workspace">
  190. <div class="workspace-head">
  191. <h3><span id="nodeTitle">—</span> <span class="node-badge" id="nodeBadge">—</span></h3>
  192. <div class="workspace-actions" id="nodeActions"></div>
  193. </div>
  194. <div class="dep-hint" id="depHint"></div>
  195. <div class="workspace-body" id="nodeBody"></div>
  196. </div>
  197. </section>
  198. </div>
  199. </div>
  200. </div>
  201. <div class="toast" id="toast"></div>
  202. <script>
  203. /**
  204. * 节点定义:dependsOn = 硬依赖(全部完成才解锁)
  205. * independent = true → 不参与阻塞其他节点(如缴费)
  206. * parallelGroup = 仅 UI 分组,逻辑上各自独立状态
  207. */
  208. const NODES = {
  209. protocol: { name: '协议单', dept: '市场', dependsOn: [], embed: 'ProtocolAuditTab' },
  210. collection: { name: '样本采集', dept: '采集岗', dependsOn: ['protocol'], embed: 'SampleCollectionInfoTab' },
  211. reception: { name: '样本接收', dept: '质检接收', dependsOn: ['collection'], embed: 'sample-reception' },
  212. qc: { name: '质检工单', dept: '质检部', dependsOn: ['reception'], parallelGroup: 'lab', embed: 'qc-work-order' },
  213. production: { name: '生产工单', dept: '生产部', dependsOn: ['reception'], parallelGroup: 'lab', embed: 'production-work-order' },
  214. release: { name: '入库放行', dept: 'QA', dependsOn: ['qc', 'production'], joinGate: true, embed: 'storage-release-review' },
  215. storage: { name: '样本入库', dept: '库管', dependsOn: ['release'], embed: 'StorageEntryInfoTab' },
  216. payment: { name: '缴费', dept: '市场+财务', dependsOn: [], independent: true, embed: 'PaymentInfoTab' }
  217. };
  218. const STATUS_LABEL = {
  219. locked: '未解锁', ready: '可办理', active: '进行中', done: '已完成', skipped: '—'
  220. };
  221. const CASES = [
  222. {
  223. id: 'c1', protocol: 'XY-2025-0066', customer: '张明华', sample: 'ZK202506120001', type: '脐带血',
  224. // 已接收:质检、生产并行进行中;缴费仍待办但不阻塞
  225. nodes: {
  226. protocol: 'done', collection: 'done', reception: 'done',
  227. qc: 'active', production: 'ready',
  228. release: 'locked', storage: 'locked', payment: 'ready'
  229. }
  230. },
  231. {
  232. id: 'c2', protocol: 'XY-2025-0077', customer: '李芳', sample: '—', type: '胎盘',
  233. nodes: {
  234. protocol: 'done', collection: 'ready', reception: 'locked',
  235. qc: 'locked', production: 'locked', release: 'locked', storage: 'locked',
  236. payment: 'active' // 财务在催缴费,但不影响后续实验链解锁逻辑
  237. }
  238. },
  239. {
  240. id: 'c3', protocol: 'XY-2025-0088', customer: '王强', sample: 'XY202506100003', type: '脐带血',
  241. nodes: {
  242. protocol: 'done', collection: 'done', reception: 'done',
  243. qc: 'done', production: 'active', // 质检已完成,生产还在做
  244. release: 'locked', storage: 'locked', payment: 'done'
  245. }
  246. },
  247. {
  248. id: 'c4', protocol: 'XY-2025-0055', customer: '赵敏', sample: 'ZK202506090005', type: '脐带血',
  249. nodes: {
  250. protocol: 'done', collection: 'done', reception: 'done',
  251. qc: 'done', production: 'done', release: 'ready', storage: 'locked',
  252. payment: 'done'
  253. }
  254. }
  255. ];
  256. let activeCaseId = 'c1';
  257. let selectedNode = 'qc';
  258. function getCase(id) { return CASES.find(c => c.id === id) || CASES[0]; }
  259. /** 计算节点是否解锁(不含 independent 对别人的影响) */
  260. function isUnlocked(caseData, nodeId) {
  261. const node = NODES[nodeId];
  262. return (node.dependsOn || []).every(dep => caseData.nodes[dep] === 'done');
  263. }
  264. function getNodeUiStatus(caseData, nodeId) {
  265. const raw = caseData.nodes[nodeId];
  266. if (raw === 'done') return 'done';
  267. if (!isUnlocked(caseData, nodeId)) return 'locked';
  268. if (raw === 'active') return 'active';
  269. return 'ready'; // ready = 可办理
  270. }
  271. function getActiveTags(caseData) {
  272. return Object.entries(caseData.nodes)
  273. .filter(([, st]) => st === 'active' || st === 'ready')
  274. .filter(([id]) => getNodeUiStatus(caseData, id) !== 'locked')
  275. .map(([id]) => NODES[id].name);
  276. }
  277. function renderCaseList() {
  278. const q = (document.getElementById('caseSearch').value || '').toLowerCase();
  279. document.getElementById('caseList').innerHTML = CASES.filter(c =>
  280. !q || [c.protocol, c.customer, c.sample].join(' ').toLowerCase().includes(q)
  281. ).map(c => {
  282. const tags = getActiveTags(c);
  283. return `
  284. <div class="case-card ${c.id === activeCaseId ? 'active' : ''}" onclick="selectCase('${c.id}')">
  285. <div class="case-protocol">${c.protocol}</div>
  286. <div class="case-customer">${c.customer} · ${c.type}</div>
  287. <div class="active-tags">
  288. ${tags.length ? tags.map(t => `<span class="tag warning">${t}</span>`).join('') : '<span class="tag muted">暂无待办节点</span>'}
  289. ${c.nodes.payment !== 'done' ? '<span class="tag info">缴费未完成</span>' : ''}
  290. </div>
  291. </div>`;
  292. }).join('');
  293. }
  294. function renderSummary(c) {
  295. const tags = getActiveTags(c);
  296. document.getElementById('summaryBar').innerHTML = `
  297. <dl class="summary-item"><dt>协议号</dt><dd>${c.protocol}</dd></dl>
  298. <dl class="summary-item"><dt>客户</dt><dd>${c.customer}</dd></dl>
  299. <dl class="summary-item"><dt>样本</dt><dd>${c.sample}</dd></dl>
  300. <dl class="summary-item"><dt>并行进行中</dt><dd style="color:var(--warn)">${tags.join('、') || '—'}</dd></dl>
  301. `;
  302. }
  303. function nodeHtml(id, caseData) {
  304. const n = NODES[id];
  305. const ui = getNodeUiStatus(caseData, id);
  306. const sel = id === selectedNode ? ' selected' : '';
  307. const indep = n.independent ? ' independent' : '';
  308. const st = caseData.nodes[id];
  309. const statusText = ui === 'locked' ? '未解锁' : STATUS_LABEL[st] || STATUS_LABEL[ui];
  310. return `
  311. <div class="flow-node ${ui}${indep}${sel}" onclick="selectNode('${id}')" title="${n.dept}">
  312. <div class="node-box">${n.name}</div>
  313. <div class="node-dept">${n.dept}</div>
  314. <div class="node-status">${statusText}</div>
  315. </div>`;
  316. }
  317. function renderFlowGraph(c) {
  318. document.getElementById('flowGraph').innerHTML = `
  319. <div class="flow-main">
  320. ${nodeHtml('protocol', c)}
  321. <span class="flow-arrow">→</span>
  322. ${nodeHtml('collection', c)}
  323. <span class="flow-arrow">→</span>
  324. ${nodeHtml('reception', c)}
  325. <span class="flow-arrow">→</span>
  326. <div class="flow-fork">
  327. <span class="fork-label">接收后并行创建</span>
  328. <div class="fork-row">
  329. ${nodeHtml('qc', c)}
  330. ${nodeHtml('production', c)}
  331. </div>
  332. </div>
  333. <span class="flow-arrow">→</span>
  334. <div class="join-wrap">
  335. <span class="join-label">⬦ 质检+生产均完成</span>
  336. ${nodeHtml('release', c)}
  337. </div>
  338. <span class="flow-arrow">→</span>
  339. ${nodeHtml('storage', c)}
  340. </div>
  341. <div class="flow-side">
  342. <span class="side-label">独立支路(不阻塞实验主链):</span>
  343. ${nodeHtml('payment', c)}
  344. <span style="font-size:11px;color:var(--text-muted)">— 未缴费仍可接收、质检、生产;储存证/合同等另按业务规则控制</span>
  345. </div>
  346. `;
  347. }
  348. function getDepHint(id, c) {
  349. const n = NODES[id];
  350. if (n.independent) return '独立节点:与实验主链无阻塞关系,可随时办理。';
  351. if (!isUnlocked(c, id)) {
  352. const pending = (n.dependsOn || []).filter(d => c.nodes[d] !== 'done');
  353. return `未解锁:需先完成 ${pending.map(d => '「' + NODES[d].name + '」').join(n.joinGate ? ' 且 ' : '、')}`;
  354. }
  355. if (n.parallelGroup === 'lab') return '并行节点:与「' + (id === 'qc' ? '生产工单' : '质检工单') + '」同时进行,互不等对方完成。';
  356. if (n.joinGate) return '汇合门禁:质检工单与生产工单均完成后,方可审核放行。';
  357. return '已解锁,可按权限办理。';
  358. }
  359. function renderNode(c, id) {
  360. const n = NODES[id];
  361. const ui = getNodeUiStatus(c, id);
  362. document.getElementById('nodeTitle').textContent = n.name;
  363. document.getElementById('nodeBadge').textContent = n.dept + ' · ' + n.embed;
  364. document.getElementById('depHint').innerHTML = getDepHint(id, c);
  365. if (ui === 'locked') {
  366. document.getElementById('nodeActions').innerHTML = '';
  367. document.getElementById('nodeBody').innerHTML = `
  368. <div class="locked-msg">
  369. <p><strong>🔒 此节点尚未解锁</strong></p>
  370. <p style="margin-top:8px;font-size:13px">${getDepHint(id, c)}</p>
  371. </div>`;
  372. return;
  373. }
  374. const st = c.nodes[id];
  375. const isActive = st === 'active' || st === 'ready';
  376. document.getElementById('nodeActions').innerHTML = getActions(id, isActive);
  377. document.getElementById('nodeBody').innerHTML = getBody(c, id, isActive);
  378. }
  379. function getActions(id, canEdit) {
  380. const btns = {
  381. protocol: canEdit ? '<button class="btn btn-primary btn-sm" onclick="completeNode(\'protocol\')">提交审核</button>' : '<button class="btn btn-sm">查看</button>',
  382. collection: canEdit ? '<button class="btn btn-primary btn-sm" onclick="completeNode(\'collection\')">确认采集</button>' : '<button class="btn btn-sm">查看</button>',
  383. reception: canEdit ? '<button class="btn btn-primary btn-sm" onclick="completeReception()">提交接收</button>' : '<button class="btn btn-sm">查看</button>',
  384. qc: canEdit ? '<button class="btn btn-primary btn-sm" onclick="toast(\'配置检测\')">配置检测</button><button class="btn btn-sm" onclick="completeNode(\'qc\')">标记完成</button>' : '<button class="btn btn-sm">查看报告</button>',
  385. production: canEdit ? '<button class="btn btn-primary btn-sm" onclick="toast(\'创建生产任务\')">创建任务</button><button class="btn btn-sm" onclick="completeNode(\'production\')">标记完成</button>' : '<button class="btn btn-sm">查看</button>',
  386. release: canEdit ? '<button class="btn btn-primary btn-sm" onclick="completeNode(\'release\')">审核通过</button>' : '<button class="btn btn-sm">查看</button>',
  387. storage: canEdit ? '<button class="btn btn-primary btn-sm" onclick="completeNode(\'storage\')">确认入库</button>' : '<button class="btn btn-sm">查看</button>',
  388. payment: canEdit ? '<button class="btn btn-primary btn-sm" onclick="completeNode(\'payment\')">提交/确认缴费</button>' : '<button class="btn btn-sm">查看</button>'
  389. };
  390. return btns[id] || '';
  391. }
  392. function getBody(c, id, canEdit) {
  393. if (id === 'reception' && canEdit) {
  394. return `<div class="readonly-panel"><h4>接收登记(不依赖缴费状态)</h4>
  395. <p style="font-size:13px;color:var(--text-muted)">即使缴费未完成,只要采集完成即可接收。提交后<strong>同时</strong>创建质检工单 + 生产工单。</p></div>
  396. <div class="parallel-note">⚡ 提交接收 → 后端同一事务创建 ZJ 质检工单 与 SC 生产工单(与现有实现一致)</div>`;
  397. }
  398. if (id === 'qc' || id === 'production') {
  399. const other = id === 'qc' ? 'production' : 'qc';
  400. const otherSt = c.nodes[other];
  401. return `<div class="readonly-panel"><h4>${NODES[id].name}</h4>
  402. <dl class="kv-row">
  403. <div><dt>本节点</dt><dd>${STATUS_LABEL[c.nodes[id]]}</dd></div>
  404. <div><dt>并行节点「${NODES[other].name}」</dt><dd>${STATUS_LABEL[otherSt]}(不阻塞本节点)</dd></div>
  405. </dl></div>
  406. <div class="parallel-note">两路可不同人、不同时间推进;放行环节才要求两者都 done。</div>`;
  407. }
  408. if (id === 'release') {
  409. return `<div class="readonly-panel"><h4>汇合门禁</h4>
  410. <dl class="kv-row">
  411. <div><dt>质检工单</dt><dd>${c.nodes.qc === 'done' ? '✓ 已完成' : '✗ 未完成'}</dd></div>
  412. <div><dt>生产工单</dt><dd>${c.nodes.production === 'done' ? '✓ 已完成' : '✗ 未完成'}</dd></div>
  413. <div><dt>缴费</dt><dd>${c.nodes.payment === 'done' ? '已缴费' : '未缴费(不阻塞放行)'}</dd></div>
  414. </dl></div>`;
  415. }
  416. if (id === 'payment') {
  417. return `<div class="readonly-panel"><h4>独立支路 · 缴费</h4>
  418. <p style="font-size:13px">与接收/质检/生产<strong>无 dependsOn 关系</strong>。列表上显示「缴费未完成」提醒,但不锁实验主链。</p>
  419. <p style="font-size:12px;color:var(--text-muted);margin-top:8px">若储存证开具等业务需要已缴费,在<strong>储存证节点</strong>单独加 softDependsOn: ['payment'] 即可。</p></div>`;
  420. }
  421. return `<p style="color:var(--text-muted);font-size:13px">嵌入 ${NODES[id].embed} · ${canEdit ? '可编辑' : '只读'}</p>`;
  422. }
  423. function selectCase(id) { activeCaseId = id; const c = getCase(id); selectedNode = getActiveTags(c)[0] ? Object.keys(NODES).find(k => NODES[k].name === getActiveTags(c)[0]) : 'protocol'; renderAll(); }
  424. function selectNode(id) {
  425. const c = getCase(activeCaseId);
  426. if (getNodeUiStatus(c, id) === 'locked') { toast(getDepHint(id, c)); return; }
  427. selectedNode = id; renderAll();
  428. }
  429. function completeReception() {
  430. const c = getCase(activeCaseId);
  431. c.nodes.reception = 'done';
  432. c.nodes.qc = 'active';
  433. c.nodes.production = 'ready';
  434. if (c.sample === '—') c.sample = 'ZK' + Date.now().toString().slice(-6);
  435. toast('接收完成 → 质检工单、生产工单已并行创建');
  436. selectedNode = 'qc';
  437. renderAll();
  438. }
  439. function completeNode(id) {
  440. const c = getCase(activeCaseId);
  441. c.nodes[id] = 'done';
  442. // 解锁:把 dependsOn 含 id 且全部 deps done 的 locked 节点改为 ready
  443. Object.keys(NODES).forEach(nid => {
  444. if (c.nodes[nid] === 'locked' || c.nodes[nid] === 'ready' || c.nodes[nid] === 'active') {
  445. if (isUnlocked(c, nid) && c.nodes[nid] !== 'done' && c.nodes[nid] !== 'active') {
  446. c.nodes[nid] = 'ready';
  447. }
  448. }
  449. });
  450. if (id === 'qc' || id === 'production') {
  451. if (c.nodes.qc === 'done' && c.nodes.production === 'done') {
  452. c.nodes.release = 'ready';
  453. toast('质检+生产均完成 → 入库放行已解锁');
  454. } else {
  455. toast('「' + NODES[id].name + '」已完成,并行另一路仍可继续');
  456. }
  457. } else {
  458. toast('「' + NODES[id].name + '」已完成');
  459. }
  460. renderAll();
  461. }
  462. function renderAll() {
  463. const c = getCase(activeCaseId);
  464. renderCaseList(); renderSummary(c); renderFlowGraph(c); renderNode(c, selectedNode);
  465. }
  466. function toast(msg) {
  467. const t = document.getElementById('toast'); t.textContent = msg; t.classList.add('show');
  468. setTimeout(() => t.classList.remove('show'), 2800);
  469. }
  470. document.getElementById('caseSearch').addEventListener('input', renderCaseList);
  471. renderAll();
  472. </script>
  473. </body>
  474. </html>