| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511 |
- <!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <title>中科兴皖 · 业务办理(并行流程原型 v2)</title>
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
- <style>
- :root {
- --primary: #10b981; --primary-dark: #059669; --primary-bg: #ecfdf5;
- --text: #1e293b; --text-muted: #64748b; --border: #d9e8e2;
- --bg-page: #fefefe; --surface: #fff; --table-head: #f8fafc;
- --row-hover: #f1f5f9; --shadow: 0 1px 3px rgba(0,0,0,0.05);
- --radius: 10px; --warn: #d97706; --warn-bg: #fffbeb;
- --info: #3b82f6; --info-bg: #eff6ff;
- }
- * { box-sizing: border-box; margin: 0; padding: 0; }
- body { font-family: Inter, sans-serif; font-size: 14px; line-height: 1.5; color: var(--text); background: var(--bg-page); }
- .layout { display: flex; min-height: 100vh; }
- .sidebar { width: 220px; background: var(--surface); border-right: 1px solid var(--border); padding: 24px 16px; flex-shrink: 0; }
- .logo { font-size: 16px; font-weight: 700; padding: 0 12px 20px; border-bottom: 1px solid var(--border); margin-bottom: 12px; }
- .logo span { color: var(--primary); }
- .nav-item { display: block; padding: 8px 12px; color: var(--text-muted); text-decoration: none; border-radius: 8px; font-size: 13px; }
- .nav-item.active { background: var(--primary-bg); color: var(--primary-dark); font-weight: 600; }
- .main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
- .topbar { height: 56px; padding: 0 24px; display: flex; align-items: center; justify-content: space-between; background: var(--surface); border-bottom: 1px solid var(--border); }
- .topbar h1 { font-size: 15px; font-weight: 600; }
- .topbar-meta { font-size: 12px; color: var(--text-muted); }
- .page { flex: 1; display: flex; min-height: 0; }
- .case-list { width: 300px; border-right: 1px solid var(--border); background: var(--surface); display: flex; flex-direction: column; }
- .case-list-head { padding: 14px 16px; border-bottom: 1px solid var(--border); }
- .case-list-head h2 { font-size: 13px; font-weight: 600; margin-bottom: 8px; }
- .search-input { width: 100%; height: 34px; border: 1px solid var(--border); border-radius: 8px; padding: 0 10px; font-size: 12px; }
- .case-items { flex: 1; overflow-y: auto; }
- .case-card { padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; }
- .case-card:hover { background: var(--row-hover); }
- .case-card.active { background: var(--primary-bg); border-left: 3px solid var(--primary); padding-left: 13px; }
- .case-protocol { font-weight: 600; font-size: 13px; }
- .case-customer { font-size: 12px; color: var(--text-muted); margin: 4px 0 8px; }
- .active-tags { display: flex; flex-wrap: wrap; gap: 4px; }
- .tag { font-size: 11px; padding: 1px 6px; border-radius: 5px; font-weight: 500; }
- .tag.success { background: var(--primary-bg); color: var(--primary-dark); }
- .tag.warning { background: var(--warn-bg); color: var(--warn); }
- .tag.info { background: var(--info-bg); color: var(--info); }
- .tag.muted { background: var(--table-head); color: var(--text-muted); }
- .console { flex: 1; overflow-y: auto; padding: 12px 16px 16px; }
- .proto-tip {
- padding: 10px 14px; background: linear-gradient(90deg, var(--primary-bg), #fff);
- border: 1px solid var(--border); border-radius: var(--radius); font-size: 12px; color: var(--text-muted); margin-bottom: 12px;
- }
- .proto-tip strong { color: var(--primary-dark); }
- .legend { display: flex; flex-wrap: wrap; gap: 12px 20px; font-size: 11px; color: var(--text-muted); margin-bottom: 12px; }
- .legend span { display: flex; align-items: center; gap: 5px; }
- .legend i { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
- .legend .solid { background: var(--primary); }
- .legend .dash { border: 2px dashed var(--text-muted); background: transparent; height: 0; width: 16px; }
- .legend .fork { background: var(--warn); }
- .summary-bar {
- display: flex; flex-wrap: wrap; gap: 16px 28px; padding: 12px 16px;
- background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 12px;
- }
- .summary-item dt { font-size: 11px; color: var(--text-muted); }
- .summary-item dd { font-size: 14px; font-weight: 600; }
- /* ── Flow graph (DAG, not linear) ── */
- .flow-panel {
- background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
- padding: 16px; margin-bottom: 12px; overflow-x: auto;
- }
- .flow-panel h3 { font-size: 12px; font-weight: 600; color: var(--text-muted); margin-bottom: 14px; }
- .flow-main { display: flex; align-items: center; gap: 0; min-width: max-content; padding-bottom: 8px; }
- .flow-arrow { color: var(--text-muted); font-size: 18px; padding: 0 4px; flex-shrink: 0; user-select: none; }
- .flow-node {
- width: 88px; text-align: center; cursor: pointer; flex-shrink: 0;
- padding: 4px; border-radius: 8px; transition: background 0.12s;
- }
- .flow-node:hover { background: var(--row-hover); }
- .flow-node.selected { background: var(--primary-bg); outline: 2px solid var(--primary); outline-offset: 1px; }
- .flow-node.independent { opacity: 0.95; }
- .node-box {
- width: 72px; height: 52px; margin: 0 auto 4px; border-radius: 8px; border: 2px solid var(--border);
- background: var(--surface); display: flex; align-items: center; justify-content: center;
- font-size: 11px; font-weight: 600; line-height: 1.2; padding: 4px; position: relative;
- }
- .flow-node.done .node-box { border-color: var(--primary); background: var(--primary-bg); color: var(--primary-dark); }
- .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); }
- .flow-node.ready .node-box { border-color: var(--warn); background: var(--warn-bg); color: var(--warn); }
- .flow-node.locked .node-box { opacity: 0.45; cursor: not-allowed; }
- .flow-node.independent .node-box { border-style: dashed; }
- .node-dept { font-size: 9px; color: var(--text-muted); }
- .node-status { font-size: 10px; margin-top: 2px; }
- .flow-node.done .node-status { color: var(--primary-dark); }
- .flow-node.active .node-status, .flow-node.ready .node-status { color: var(--warn); font-weight: 600; }
- /* Fork: reception → qc + production */
- .flow-fork { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; }
- .fork-bar { width: 2px; height: 12px; background: var(--border); }
- .fork-row { display: flex; gap: 24px; align-items: center; position: relative; }
- .fork-row::before {
- content: ''; position: absolute; top: 26px; left: 44px; right: 44px; height: 2px; background: var(--border);
- }
- .fork-label {
- font-size: 10px; color: var(--warn); font-weight: 600; background: var(--warn-bg);
- padding: 2px 8px; border-radius: 4px; margin-bottom: 4px;
- }
- .join-wrap { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; }
- .join-label { font-size: 10px; color: var(--text-muted); margin: 4px 0; }
- /* Independent track: payment */
- .flow-side {
- margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--border);
- display: flex; align-items: center; gap: 8px; min-width: max-content;
- }
- .side-label { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
- .workspace {
- background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
- min-height: 320px; display: flex; flex-direction: column;
- }
- .workspace-head {
- padding: 12px 16px; border-bottom: 1px solid var(--border);
- display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px;
- }
- .workspace-head h3 { font-size: 15px; font-weight: 600; }
- .node-badge { font-size: 11px; background: var(--primary-bg); color: var(--primary-dark); padding: 2px 8px; border-radius: 6px; font-weight: 500; }
- .dep-hint { font-size: 12px; color: var(--text-muted); padding: 8px 16px; background: var(--table-head); border-bottom: 1px solid var(--border); }
- .dep-hint strong { color: var(--primary-dark); }
- .workspace-body { padding: 16px; flex: 1; }
- .workspace-actions { display: flex; gap: 8px; flex-wrap: wrap; }
- .btn {
- height: 32px; padding: 0 12px; border-radius: 8px; font-size: 13px; font-weight: 500;
- border: 1px solid var(--border); background: var(--surface); cursor: pointer;
- }
- .btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
- .btn-sm { height: 28px; padding: 0 10px; font-size: 12px; }
- .readonly-panel { background: var(--table-head); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; margin-bottom: 12px; }
- .readonly-panel h4 { font-size: 12px; color: var(--text-muted); margin-bottom: 8px; }
- .kv-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
- .kv-row dt { font-size: 11px; color: var(--text-muted); }
- .kv-row dd { font-size: 13px; font-weight: 500; }
- .parallel-note {
- margin-top: 12px; padding: 10px 12px; background: var(--info-bg); border: 1px solid rgba(59,130,246,0.2);
- border-radius: 8px; font-size: 12px; color: #1d4ed8;
- }
- .locked-msg { text-align: center; padding: 40px 20px; color: var(--text-muted); }
- .toast {
- position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(80px);
- background: var(--text); color: #fff; padding: 12px 20px; border-radius: var(--radius);
- font-size: 13px; opacity: 0; transition: all 0.3s; z-index: 99; max-width: 92vw; text-align: center;
- }
- .toast.show { transform: translateX(-50%) translateY(0); opacity: 1; }
- @media (max-width: 960px) {
- .page { flex-direction: column; }
- .case-list { width: 100%; max-height: 200px; }
- .kv-row { grid-template-columns: 1fr 1fr; }
- }
- </style>
- </head>
- <body>
- <div class="layout">
- <aside class="sidebar">
- <div class="logo">中科兴皖<span> · 管理端</span></div>
- <a href="#" class="nav-item active">细胞存储 · 业务办理</a>
- </aside>
- <div class="main">
- <header class="topbar">
- <h1>细胞存储 · 业务办理</h1>
- <span class="topbar-meta">v2 · 并行/独立依赖,非简单串行</span>
- </header>
- <div class="page">
- <aside class="case-list">
- <div class="case-list-head">
- <h2>进行中业务</h2>
- <input class="search-input" id="caseSearch" placeholder="协议号 / 客户 / 样本" />
- </div>
- <div class="case-items" id="caseList"></div>
- </aside>
- <section class="console">
- <div class="proto-tip">
- <strong>v2 修正:</strong>流程不是 8 步串行。缴费<strong>不阻塞</strong>接收/质检/生产;接收后<strong>质检与生产并行</strong>;放行需<strong>两者都完成</strong>(汇合门禁)。点击节点查看下方工作区。
- </div>
- <div class="legend">
- <span><i class="solid"></i> 主链 / 已完成</span>
- <span><i class="fork"></i> 并行进行中</span>
- <span><i class="dash"></i> 独立支路(不阻塞实验链)</span>
- <span>汇合 ⬦ = 需全部前置完成才解锁</span>
- </div>
- <div class="summary-bar" id="summaryBar"></div>
- <div class="flow-panel">
- <h3>里程碑依赖图(每个节点独立状态,只按 dependsOn 解锁)</h3>
- <div id="flowGraph"></div>
- </div>
- <div class="workspace">
- <div class="workspace-head">
- <h3><span id="nodeTitle">—</span> <span class="node-badge" id="nodeBadge">—</span></h3>
- <div class="workspace-actions" id="nodeActions"></div>
- </div>
- <div class="dep-hint" id="depHint"></div>
- <div class="workspace-body" id="nodeBody"></div>
- </div>
- </section>
- </div>
- </div>
- </div>
- <div class="toast" id="toast"></div>
- <script>
- /**
- * 节点定义:dependsOn = 硬依赖(全部完成才解锁)
- * independent = true → 不参与阻塞其他节点(如缴费)
- * parallelGroup = 仅 UI 分组,逻辑上各自独立状态
- */
- const NODES = {
- protocol: { name: '协议单', dept: '市场', dependsOn: [], embed: 'ProtocolAuditTab' },
- collection: { name: '样本采集', dept: '采集岗', dependsOn: ['protocol'], embed: 'SampleCollectionInfoTab' },
- reception: { name: '样本接收', dept: '质检接收', dependsOn: ['collection'], embed: 'sample-reception' },
- qc: { name: '质检工单', dept: '质检部', dependsOn: ['reception'], parallelGroup: 'lab', embed: 'qc-work-order' },
- production: { name: '生产工单', dept: '生产部', dependsOn: ['reception'], parallelGroup: 'lab', embed: 'production-work-order' },
- release: { name: '入库放行', dept: 'QA', dependsOn: ['qc', 'production'], joinGate: true, embed: 'storage-release-review' },
- storage: { name: '样本入库', dept: '库管', dependsOn: ['release'], embed: 'StorageEntryInfoTab' },
- payment: { name: '缴费', dept: '市场+财务', dependsOn: [], independent: true, embed: 'PaymentInfoTab' }
- };
- const STATUS_LABEL = {
- locked: '未解锁', ready: '可办理', active: '进行中', done: '已完成', skipped: '—'
- };
- const CASES = [
- {
- id: 'c1', protocol: 'XY-2025-0066', customer: '张明华', sample: 'ZK202506120001', type: '脐带血',
- // 已接收:质检、生产并行进行中;缴费仍待办但不阻塞
- nodes: {
- protocol: 'done', collection: 'done', reception: 'done',
- qc: 'active', production: 'ready',
- release: 'locked', storage: 'locked', payment: 'ready'
- }
- },
- {
- id: 'c2', protocol: 'XY-2025-0077', customer: '李芳', sample: '—', type: '胎盘',
- nodes: {
- protocol: 'done', collection: 'ready', reception: 'locked',
- qc: 'locked', production: 'locked', release: 'locked', storage: 'locked',
- payment: 'active' // 财务在催缴费,但不影响后续实验链解锁逻辑
- }
- },
- {
- id: 'c3', protocol: 'XY-2025-0088', customer: '王强', sample: 'XY202506100003', type: '脐带血',
- nodes: {
- protocol: 'done', collection: 'done', reception: 'done',
- qc: 'done', production: 'active', // 质检已完成,生产还在做
- release: 'locked', storage: 'locked', payment: 'done'
- }
- },
- {
- id: 'c4', protocol: 'XY-2025-0055', customer: '赵敏', sample: 'ZK202506090005', type: '脐带血',
- nodes: {
- protocol: 'done', collection: 'done', reception: 'done',
- qc: 'done', production: 'done', release: 'ready', storage: 'locked',
- payment: 'done'
- }
- }
- ];
- let activeCaseId = 'c1';
- let selectedNode = 'qc';
- function getCase(id) { return CASES.find(c => c.id === id) || CASES[0]; }
- /** 计算节点是否解锁(不含 independent 对别人的影响) */
- function isUnlocked(caseData, nodeId) {
- const node = NODES[nodeId];
- return (node.dependsOn || []).every(dep => caseData.nodes[dep] === 'done');
- }
- function getNodeUiStatus(caseData, nodeId) {
- const raw = caseData.nodes[nodeId];
- if (raw === 'done') return 'done';
- if (!isUnlocked(caseData, nodeId)) return 'locked';
- if (raw === 'active') return 'active';
- return 'ready'; // ready = 可办理
- }
- function getActiveTags(caseData) {
- return Object.entries(caseData.nodes)
- .filter(([, st]) => st === 'active' || st === 'ready')
- .filter(([id]) => getNodeUiStatus(caseData, id) !== 'locked')
- .map(([id]) => NODES[id].name);
- }
- function renderCaseList() {
- const q = (document.getElementById('caseSearch').value || '').toLowerCase();
- document.getElementById('caseList').innerHTML = CASES.filter(c =>
- !q || [c.protocol, c.customer, c.sample].join(' ').toLowerCase().includes(q)
- ).map(c => {
- const tags = getActiveTags(c);
- return `
- <div class="case-card ${c.id === activeCaseId ? 'active' : ''}" onclick="selectCase('${c.id}')">
- <div class="case-protocol">${c.protocol}</div>
- <div class="case-customer">${c.customer} · ${c.type}</div>
- <div class="active-tags">
- ${tags.length ? tags.map(t => `<span class="tag warning">${t}</span>`).join('') : '<span class="tag muted">暂无待办节点</span>'}
- ${c.nodes.payment !== 'done' ? '<span class="tag info">缴费未完成</span>' : ''}
- </div>
- </div>`;
- }).join('');
- }
- function renderSummary(c) {
- const tags = getActiveTags(c);
- document.getElementById('summaryBar').innerHTML = `
- <dl class="summary-item"><dt>协议号</dt><dd>${c.protocol}</dd></dl>
- <dl class="summary-item"><dt>客户</dt><dd>${c.customer}</dd></dl>
- <dl class="summary-item"><dt>样本</dt><dd>${c.sample}</dd></dl>
- <dl class="summary-item"><dt>并行进行中</dt><dd style="color:var(--warn)">${tags.join('、') || '—'}</dd></dl>
- `;
- }
- function nodeHtml(id, caseData) {
- const n = NODES[id];
- const ui = getNodeUiStatus(caseData, id);
- const sel = id === selectedNode ? ' selected' : '';
- const indep = n.independent ? ' independent' : '';
- const st = caseData.nodes[id];
- const statusText = ui === 'locked' ? '未解锁' : STATUS_LABEL[st] || STATUS_LABEL[ui];
- return `
- <div class="flow-node ${ui}${indep}${sel}" onclick="selectNode('${id}')" title="${n.dept}">
- <div class="node-box">${n.name}</div>
- <div class="node-dept">${n.dept}</div>
- <div class="node-status">${statusText}</div>
- </div>`;
- }
- function renderFlowGraph(c) {
- document.getElementById('flowGraph').innerHTML = `
- <div class="flow-main">
- ${nodeHtml('protocol', c)}
- <span class="flow-arrow">→</span>
- ${nodeHtml('collection', c)}
- <span class="flow-arrow">→</span>
- ${nodeHtml('reception', c)}
- <span class="flow-arrow">→</span>
- <div class="flow-fork">
- <span class="fork-label">接收后并行创建</span>
- <div class="fork-row">
- ${nodeHtml('qc', c)}
- ${nodeHtml('production', c)}
- </div>
- </div>
- <span class="flow-arrow">→</span>
- <div class="join-wrap">
- <span class="join-label">⬦ 质检+生产均完成</span>
- ${nodeHtml('release', c)}
- </div>
- <span class="flow-arrow">→</span>
- ${nodeHtml('storage', c)}
- </div>
- <div class="flow-side">
- <span class="side-label">独立支路(不阻塞实验主链):</span>
- ${nodeHtml('payment', c)}
- <span style="font-size:11px;color:var(--text-muted)">— 未缴费仍可接收、质检、生产;储存证/合同等另按业务规则控制</span>
- </div>
- `;
- }
- function getDepHint(id, c) {
- const n = NODES[id];
- if (n.independent) return '独立节点:与实验主链无阻塞关系,可随时办理。';
- if (!isUnlocked(c, id)) {
- const pending = (n.dependsOn || []).filter(d => c.nodes[d] !== 'done');
- return `未解锁:需先完成 ${pending.map(d => '「' + NODES[d].name + '」').join(n.joinGate ? ' 且 ' : '、')}`;
- }
- if (n.parallelGroup === 'lab') return '并行节点:与「' + (id === 'qc' ? '生产工单' : '质检工单') + '」同时进行,互不等对方完成。';
- if (n.joinGate) return '汇合门禁:质检工单与生产工单均完成后,方可审核放行。';
- return '已解锁,可按权限办理。';
- }
- function renderNode(c, id) {
- const n = NODES[id];
- const ui = getNodeUiStatus(c, id);
- document.getElementById('nodeTitle').textContent = n.name;
- document.getElementById('nodeBadge').textContent = n.dept + ' · ' + n.embed;
- document.getElementById('depHint').innerHTML = getDepHint(id, c);
- if (ui === 'locked') {
- document.getElementById('nodeActions').innerHTML = '';
- document.getElementById('nodeBody').innerHTML = `
- <div class="locked-msg">
- <p><strong>🔒 此节点尚未解锁</strong></p>
- <p style="margin-top:8px;font-size:13px">${getDepHint(id, c)}</p>
- </div>`;
- return;
- }
- const st = c.nodes[id];
- const isActive = st === 'active' || st === 'ready';
- document.getElementById('nodeActions').innerHTML = getActions(id, isActive);
- document.getElementById('nodeBody').innerHTML = getBody(c, id, isActive);
- }
- function getActions(id, canEdit) {
- const btns = {
- protocol: canEdit ? '<button class="btn btn-primary btn-sm" onclick="completeNode(\'protocol\')">提交审核</button>' : '<button class="btn btn-sm">查看</button>',
- collection: canEdit ? '<button class="btn btn-primary btn-sm" onclick="completeNode(\'collection\')">确认采集</button>' : '<button class="btn btn-sm">查看</button>',
- reception: canEdit ? '<button class="btn btn-primary btn-sm" onclick="completeReception()">提交接收</button>' : '<button class="btn btn-sm">查看</button>',
- 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>',
- 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>',
- release: canEdit ? '<button class="btn btn-primary btn-sm" onclick="completeNode(\'release\')">审核通过</button>' : '<button class="btn btn-sm">查看</button>',
- storage: canEdit ? '<button class="btn btn-primary btn-sm" onclick="completeNode(\'storage\')">确认入库</button>' : '<button class="btn btn-sm">查看</button>',
- payment: canEdit ? '<button class="btn btn-primary btn-sm" onclick="completeNode(\'payment\')">提交/确认缴费</button>' : '<button class="btn btn-sm">查看</button>'
- };
- return btns[id] || '';
- }
- function getBody(c, id, canEdit) {
- if (id === 'reception' && canEdit) {
- return `<div class="readonly-panel"><h4>接收登记(不依赖缴费状态)</h4>
- <p style="font-size:13px;color:var(--text-muted)">即使缴费未完成,只要采集完成即可接收。提交后<strong>同时</strong>创建质检工单 + 生产工单。</p></div>
- <div class="parallel-note">⚡ 提交接收 → 后端同一事务创建 ZJ 质检工单 与 SC 生产工单(与现有实现一致)</div>`;
- }
- if (id === 'qc' || id === 'production') {
- const other = id === 'qc' ? 'production' : 'qc';
- const otherSt = c.nodes[other];
- return `<div class="readonly-panel"><h4>${NODES[id].name}</h4>
- <dl class="kv-row">
- <div><dt>本节点</dt><dd>${STATUS_LABEL[c.nodes[id]]}</dd></div>
- <div><dt>并行节点「${NODES[other].name}」</dt><dd>${STATUS_LABEL[otherSt]}(不阻塞本节点)</dd></div>
- </dl></div>
- <div class="parallel-note">两路可不同人、不同时间推进;放行环节才要求两者都 done。</div>`;
- }
- if (id === 'release') {
- return `<div class="readonly-panel"><h4>汇合门禁</h4>
- <dl class="kv-row">
- <div><dt>质检工单</dt><dd>${c.nodes.qc === 'done' ? '✓ 已完成' : '✗ 未完成'}</dd></div>
- <div><dt>生产工单</dt><dd>${c.nodes.production === 'done' ? '✓ 已完成' : '✗ 未完成'}</dd></div>
- <div><dt>缴费</dt><dd>${c.nodes.payment === 'done' ? '已缴费' : '未缴费(不阻塞放行)'}</dd></div>
- </dl></div>`;
- }
- if (id === 'payment') {
- return `<div class="readonly-panel"><h4>独立支路 · 缴费</h4>
- <p style="font-size:13px">与接收/质检/生产<strong>无 dependsOn 关系</strong>。列表上显示「缴费未完成」提醒,但不锁实验主链。</p>
- <p style="font-size:12px;color:var(--text-muted);margin-top:8px">若储存证开具等业务需要已缴费,在<strong>储存证节点</strong>单独加 softDependsOn: ['payment'] 即可。</p></div>`;
- }
- return `<p style="color:var(--text-muted);font-size:13px">嵌入 ${NODES[id].embed} · ${canEdit ? '可编辑' : '只读'}</p>`;
- }
- 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(); }
- function selectNode(id) {
- const c = getCase(activeCaseId);
- if (getNodeUiStatus(c, id) === 'locked') { toast(getDepHint(id, c)); return; }
- selectedNode = id; renderAll();
- }
- function completeReception() {
- const c = getCase(activeCaseId);
- c.nodes.reception = 'done';
- c.nodes.qc = 'active';
- c.nodes.production = 'ready';
- if (c.sample === '—') c.sample = 'ZK' + Date.now().toString().slice(-6);
- toast('接收完成 → 质检工单、生产工单已并行创建');
- selectedNode = 'qc';
- renderAll();
- }
- function completeNode(id) {
- const c = getCase(activeCaseId);
- c.nodes[id] = 'done';
- // 解锁:把 dependsOn 含 id 且全部 deps done 的 locked 节点改为 ready
- Object.keys(NODES).forEach(nid => {
- if (c.nodes[nid] === 'locked' || c.nodes[nid] === 'ready' || c.nodes[nid] === 'active') {
- if (isUnlocked(c, nid) && c.nodes[nid] !== 'done' && c.nodes[nid] !== 'active') {
- c.nodes[nid] = 'ready';
- }
- }
- });
- if (id === 'qc' || id === 'production') {
- if (c.nodes.qc === 'done' && c.nodes.production === 'done') {
- c.nodes.release = 'ready';
- toast('质检+生产均完成 → 入库放行已解锁');
- } else {
- toast('「' + NODES[id].name + '」已完成,并行另一路仍可继续');
- }
- } else {
- toast('「' + NODES[id].name + '」已完成');
- }
- renderAll();
- }
- function renderAll() {
- const c = getCase(activeCaseId);
- renderCaseList(); renderSummary(c); renderFlowGraph(c); renderNode(c, selectedNode);
- }
- function toast(msg) {
- const t = document.getElementById('toast'); t.textContent = msg; t.classList.add('show');
- setTimeout(() => t.classList.remove('show'), 2800);
- }
- document.getElementById('caseSearch').addEventListener('input', renderCaseList);
- renderAll();
- </script>
- </body>
- </html>
|