cell-storage-hybrid-prototype.html 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  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>中科兴皖 · 一体化流程 + 角色聚焦(兼容原型)</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. --surface: #fff; --table-head: #f8fafc; --row-hover: #f1f5f9;
  13. --radius: 10px; --warn: #d97706; --warn-bg: #fffbeb;
  14. --info: #3b82f6; --info-bg: #eff6ff;
  15. }
  16. * { box-sizing: border-box; margin: 0; padding: 0; }
  17. body { font-family: Inter, sans-serif; font-size: 14px; line-height: 1.5; color: var(--text); background: #fefefe; }
  18. .layout { display: flex; min-height: 100vh; }
  19. .sidebar { width: 220px; background: var(--surface); border-right: 1px solid var(--border); padding: 20px 14px; flex-shrink: 0; }
  20. .logo { font-size: 15px; font-weight: 700; padding: 0 10px 16px; border-bottom: 1px solid var(--border); margin-bottom: 12px; }
  21. .logo span { color: var(--primary); }
  22. .nav-item { display: block; padding: 8px 12px; color: var(--text-muted); text-decoration: none; border-radius: 8px; font-size: 13px; margin-bottom: 2px; }
  23. .nav-item.active { background: var(--primary-bg); color: var(--primary-dark); font-weight: 600; }
  24. .nav-sub { font-size: 11px; color: var(--text-muted); padding: 8px 12px 4px; }
  25. .main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
  26. /* Role bar */
  27. .role-bar {
  28. background: var(--surface); border-bottom: 1px solid var(--border);
  29. padding: 10px 20px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
  30. }
  31. .role-bar-label { font-size: 12px; font-weight: 600; color: var(--text-muted); }
  32. .role-tabs { display: flex; gap: 6px; flex-wrap: wrap; }
  33. .role-tab {
  34. padding: 6px 14px; border-radius: 8px; border: 1px solid var(--border);
  35. background: var(--surface); font-size: 13px; cursor: pointer; transition: all 0.15s;
  36. }
  37. .role-tab:hover { border-color: var(--primary); }
  38. .role-tab.active { background: var(--primary); border-color: var(--primary); color: #fff; font-weight: 600; }
  39. .role-desc { font-size: 12px; color: var(--text-muted); flex: 1; min-width: 200px; }
  40. .view-toggle { display: flex; gap: 4px; margin-left: auto; }
  41. .view-toggle button {
  42. padding: 5px 10px; font-size: 12px; border: 1px solid var(--border); border-radius: 6px;
  43. background: var(--surface); cursor: pointer;
  44. }
  45. .view-toggle button.active { background: var(--primary-bg); border-color: var(--primary); color: var(--primary-dark); font-weight: 600; }
  46. .topbar { padding: 12px 20px; border-bottom: 1px solid var(--border); background: var(--surface); }
  47. .topbar h1 { font-size: 15px; font-weight: 600; }
  48. .topbar p { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
  49. .content { flex: 1; padding: 16px 20px; overflow: auto; }
  50. .concept-banner {
  51. padding: 10px 14px; background: linear-gradient(90deg, var(--primary-bg), #fff);
  52. border: 1px solid var(--border); border-radius: var(--radius); font-size: 12px; color: var(--text-muted); margin-bottom: 14px;
  53. }
  54. .concept-banner strong { color: var(--primary-dark); }
  55. .panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 14px; }
  56. .panel-head {
  57. padding: 12px 16px; border-bottom: 1px solid var(--border);
  58. display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px;
  59. }
  60. .panel-head h2 { font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
  61. .panel-head h2::before { content: ''; width: 3px; height: 14px; background: var(--primary); border-radius: 2px; }
  62. .stat-row { display: flex; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
  63. .stat-pill {
  64. padding: 6px 12px; border-radius: 8px; background: var(--table-head); font-size: 12px;
  65. }
  66. .stat-pill em { font-style: normal; font-weight: 700; color: var(--warn); margin-left: 4px; }
  67. table { width: 100%; border-collapse: collapse; }
  68. th { background: var(--table-head); font-size: 12px; color: var(--text-muted); padding: 9px 12px; text-align: center; border-bottom: 1px solid var(--border); }
  69. td { padding: 11px 12px; text-align: center; border-bottom: 1px solid var(--border); font-size: 13px; }
  70. tr { cursor: pointer; }
  71. tr:hover td { background: var(--row-hover); }
  72. tr.selected td { background: var(--primary-bg); }
  73. .tag { display: inline-block; padding: 2px 7px; border-radius: 5px; font-size: 11px; font-weight: 500; }
  74. .tag.success { background: var(--primary-bg); color: var(--primary-dark); }
  75. .tag.warning { background: var(--warn-bg); color: var(--warn); }
  76. .tag.info { background: var(--info-bg); color: var(--info); }
  77. .tag.muted { background: var(--table-head); color: var(--text-muted); }
  78. .btn {
  79. height: 30px; padding: 0 11px; border-radius: 7px; font-size: 12px; font-weight: 500;
  80. border: 1px solid var(--border); background: var(--surface); cursor: pointer;
  81. }
  82. .btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
  83. /* ── Process Spine (shared component) ── */
  84. .spine-wrap {
  85. border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface);
  86. margin-bottom: 14px; overflow: hidden;
  87. }
  88. .spine-header {
  89. padding: 10px 14px; display: flex; align-items: center; justify-content: space-between;
  90. background: var(--table-head); cursor: pointer; user-select: none;
  91. }
  92. .spine-header h3 { font-size: 13px; font-weight: 600; }
  93. .spine-header .meta { font-size: 12px; color: var(--text-muted); }
  94. .spine-toggle { font-size: 12px; color: var(--primary-dark); font-weight: 500; }
  95. .spine-body { padding: 12px 14px 14px; border-top: 1px solid var(--border); }
  96. .spine-body.collapsed { display: none; }
  97. .spine-compact { display: flex; align-items: center; gap: 0; overflow-x: auto; padding: 4px 0; }
  98. .sp-node {
  99. flex: 0 0 auto; text-align: center; min-width: 72px; position: relative; padding: 0 2px;
  100. }
  101. .sp-node:not(:last-child)::after {
  102. content: ''; position: absolute; top: 11px; left: calc(50% + 18px); width: calc(100% - 36px); height: 2px; background: var(--border);
  103. }
  104. .sp-node.done:not(:last-child)::after { background: var(--primary); }
  105. .sp-dot {
  106. width: 22px; height: 22px; border-radius: 50%; border: 2px solid var(--border); margin: 0 auto 4px;
  107. font-size: 10px; display: flex; align-items: center; justify-content: center; background: var(--surface); position: relative; z-index: 1;
  108. }
  109. .sp-node.done .sp-dot { border-color: var(--primary); background: var(--primary-bg); color: var(--primary-dark); }
  110. .sp-node.you .sp-dot { border-color: var(--primary); background: var(--primary); color: #fff; box-shadow: 0 0 0 3px rgba(16,185,129,0.2); }
  111. .sp-node.parallel .sp-dot { border-color: var(--warn); background: var(--warn-bg); color: var(--warn); }
  112. .sp-node.indep .sp-dot { border-style: dashed; }
  113. .sp-name { font-size: 10px; font-weight: 600; line-height: 1.2; }
  114. .sp-node.dim .sp-name, .sp-node.dim .sp-dot { opacity: 0.35; }
  115. .spine-side {
  116. margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--border);
  117. display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--text-muted);
  118. }
  119. /* Detail drawer / bottom panel */
  120. .detail-panel { display: none; }
  121. .detail-panel.open { display: block; }
  122. .detail-grid { display: grid; grid-template-columns: 1fr 340px; gap: 14px; }
  123. .work-area { padding: 16px; }
  124. .work-area h3 { font-size: 14px; font-weight: 600; margin-bottom: 12px; }
  125. .form-row { margin-bottom: 12px; }
  126. .form-row label { display: block; font-size: 11px; font-weight: 600; color: var(--text-muted); margin-bottom: 4px; }
  127. .form-row input, .form-row select { width: 100%; max-width: 360px; height: 34px; border: 1px solid var(--border); border-radius: 8px; padding: 0 10px; }
  128. .context-card {
  129. padding: 14px; background: var(--table-head); border-left: 1px solid var(--border);
  130. }
  131. .context-card h4 { font-size: 12px; font-weight: 600; color: var(--text-muted); margin-bottom: 10px; }
  132. .ctx-line { font-size: 12px; margin-bottom: 6px; }
  133. .ctx-line span { color: var(--text-muted); }
  134. /* Full process view (coordinator only) */
  135. .full-process { display: none; }
  136. .full-process.show { display: block; }
  137. .focused-view.hidden { display: none; }
  138. .fork-row { display: flex; gap: 20px; justify-content: center; margin: 8px 0; }
  139. .flow-card {
  140. width: 120px; padding: 10px; border: 2px solid var(--border); border-radius: 8px; text-align: center; font-size: 12px;
  141. }
  142. .flow-card.active { border-color: var(--primary); background: var(--primary-bg); }
  143. .flow-card.done { border-color: var(--primary); opacity: 0.85; }
  144. .toast {
  145. position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(60px);
  146. background: var(--text); color: #fff; padding: 10px 18px; border-radius: 8px; font-size: 13px;
  147. opacity: 0; transition: all 0.3s; z-index: 99;
  148. }
  149. .toast.show { transform: translateX(-50%) translateY(0); opacity: 1; }
  150. @media (max-width: 900px) {
  151. .detail-grid { grid-template-columns: 1fr; }
  152. .layout { flex-direction: column; }
  153. .sidebar { width: 100%; }
  154. }
  155. </style>
  156. </head>
  157. <body>
  158. <div class="layout">
  159. <aside class="sidebar" id="sidebar"></aside>
  160. <div class="main">
  161. <div class="role-bar">
  162. <span class="role-bar-label">模拟角色(切换看兼容效果)</span>
  163. <div class="role-tabs" id="roleTabs"></div>
  164. <div class="view-toggle" id="viewToggle" style="display:none">
  165. <button type="button" data-view="focused" class="active">聚焦作业</button>
  166. <button type="button" data-view="full">流程全景</button>
  167. </div>
  168. </div>
  169. <p class="role-desc" id="roleDesc" style="padding:8px 20px 0;font-size:12px;color:var(--text-muted)"></p>
  170. <header class="topbar">
  171. <h1 id="pageTitle">—</h1>
  172. <p id="pageSub">—</p>
  173. </header>
  174. <div class="content">
  175. <div class="concept-banner">
  176. <strong>兼容模式:</strong>同一套流程数据 · 角色默认只看<strong>本岗列表+操作区</strong> · 流程通过可折叠<strong>脊柱</strong>保持一体清晰 · 跟单可切换<strong>流程全景</strong>
  177. </div>
  178. <!-- 聚焦视图:各角色主路径 -->
  179. <div class="focused-view" id="focusedView">
  180. <div class="panel">
  181. <div class="panel-head">
  182. <h2 id="listTitle">待办列表</h2>
  183. <span id="listHint" style="font-size:12px;color:var(--text-muted)"></span>
  184. </div>
  185. <div class="stat-row" id="statRow"></div>
  186. <table>
  187. <thead id="tableHead"></thead>
  188. <tbody id="tableBody"></tbody>
  189. </table>
  190. </div>
  191. <!-- 选中行后:脊柱 + 作业区 -->
  192. <div class="detail-panel" id="detailPanel">
  193. <div id="detailSpine"></div>
  194. <div class="panel detail-grid">
  195. <div class="work-area">
  196. <h3 id="workTitle">—</h3>
  197. <div id="workForm"></div>
  198. <div style="margin-top:14px" id="workActions"></div>
  199. </div>
  200. <div class="context-card">
  201. <h4>流程上下文(只读,跨部门共享)</h4>
  202. <div id="contextLines"></div>
  203. </div>
  204. </div>
  205. </div>
  206. </div>
  207. <!-- 全景视图:仅跟单 -->
  208. <div class="full-process" id="fullProcess">
  209. <div class="panel" style="padding:16px">
  210. <h2 style="font-size:14px;margin-bottom:12px">业务办理 · 依赖全景(跟单/coordinator 专用)</h2>
  211. <div id="fullFlowDiagram"></div>
  212. <p style="font-size:12px;color:var(--text-muted);margin-top:12px">全景与职能列表是同一数据;实验员/财务日常使用「聚焦作业」即可。</p>
  213. </div>
  214. </div>
  215. </div>
  216. </div>
  217. </div>
  218. <div class="toast" id="toast"></div>
  219. <script>
  220. const ROLES = {
  221. finance: {
  222. label: '财务', menu: ['缴退费确认'], defaultMenu: 0,
  223. desc: '主路径:确认列表。流程脊柱默认折叠,只看金额与协议摘要。',
  224. pageTitle: '缴退费财务确认', pageSub: '仅看待确认单据 · 不关心实验主链操作',
  225. highlightNode: 'payment', dimNodes: ['qc', 'production'],
  226. spineDefaultCollapsed: true,
  227. canFullView: false,
  228. stats: [{ label: '待确认', count: 3 }, { label: '今日已确认', count: 8 }],
  229. columns: ['协议号', '客户', '金额', '类型', '实验进度', '操作'],
  230. list: [
  231. { id: 1, protocol: 'XY-2025-0066', customer: '张明华', amount: '28,800', type: '缴费确认', milestone: '已接收', nodes: mkNodes({ payment: 'you', reception: 'done', qc: 'parallel', production: 'parallel' }) },
  232. { id: 2, protocol: 'XY-2025-0077', customer: '李芳', amount: '18,000', type: '缴费确认', milestone: '待采集', nodes: mkNodes({ payment: 'you', collection: 'parallel' }) },
  233. { id: 3, protocol: 'XY-2025-0044', customer: '陈静', amount: '2,000', type: '退费确认', milestone: '质检中', nodes: mkNodes({ payment: 'you', qc: 'parallel', production: 'parallel' }) }
  234. ]
  235. },
  236. qc_recv: {
  237. label: '质检接收', menu: ['样本接收', '接收检查项'], defaultMenu: 0,
  238. desc: '主路径:接收列表。脊柱高亮「接收」,缴费为虚线独立支路。',
  239. pageTitle: '样本接收', pageSub: '待接收 / 已接收 · 提交后并行创建质检+生产工单',
  240. highlightNode: 'reception', dimNodes: ['payment'],
  241. spineDefaultCollapsed: false,
  242. canFullView: false,
  243. stats: [{ label: '待接收', count: 5 }, { label: '今日已接收', count: 12 }],
  244. columns: ['样本编码', '协议号', '客户', '预计到达', '里程碑', '操作'],
  245. list: [
  246. { id: 1, sample: 'ZK202506120001', protocol: 'XY-2025-0066', customer: '张明华', eta: '今日 14:00', milestone: '待接收', nodes: mkNodes({ reception: 'you', payment: 'indep' }) },
  247. { id: 2, sample: 'ZK202506110008', protocol: 'XY-2025-0077', customer: '李芳', eta: '明日 10:00', milestone: '待接收', nodes: mkNodes({ reception: 'you', collection: 'done', payment: 'indep' }) }
  248. ]
  249. },
  250. qc_lab: {
  251. label: '质检实验', menu: ['我的质检任务', '质检工单'], defaultMenu: 0,
  252. desc: '主路径:我的任务。脊柱高亮「质检」,可看生产并行进度(只读)。',
  253. pageTitle: '我的质检任务', pageSub: '待执行 / 进行中 · 与生产工单并行',
  254. highlightNode: 'qc', dimNodes: ['payment'],
  255. spineDefaultCollapsed: false,
  256. canFullView: false,
  257. stats: [{ label: '待执行', count: 4 }, { label: '进行中', count: 2 }],
  258. columns: ['任务号', '样本编码', '检测项目', '工单状态', '生产(只读)', '操作'],
  259. list: [
  260. { id: 1, task: 'RW20260612001', sample: 'ZK202506120001', item: '细胞活性', wo: '配置中', prod: '待配置', nodes: mkNodes({ qc: 'you', production: 'parallel', reception: 'done' }) },
  261. { id: 2, task: 'RW20260612002', sample: 'XY202506100003', item: '无菌检测', wo: '进行中', prod: '进行中', nodes: mkNodes({ qc: 'you', production: 'parallel' }) }
  262. ]
  263. },
  264. production: {
  265. label: '生产', menu: ['生产工单', '生产任务'], defaultMenu: 0,
  266. desc: '主路径:生产工单。脊柱高亮「生产」,质检进度只读。',
  267. pageTitle: '入库生产工单', pageSub: '待配置 / 进行中 · 与质检并行',
  268. highlightNode: 'production', dimNodes: ['payment'],
  269. spineDefaultCollapsed: false,
  270. canFullView: false,
  271. stats: [{ label: '待配置', count: 2 }, { label: '进行中', count: 3 }],
  272. columns: ['工单号', '样本编码', '协议号', '状态', '质检(只读)', '操作'],
  273. list: [
  274. { id: 1, wo: 'SC202606110003', sample: 'ZK202506120001', protocol: 'XY-2025-0066', status: '待配置', qc: '配置中', nodes: mkNodes({ production: 'you', qc: 'parallel', reception: 'done' }) }
  275. ]
  276. },
  277. coordinator: {
  278. label: '市场跟单', menu: ['协议列表', '业务办理'], defaultMenu: 1,
  279. desc: '可选聚焦列表或流程全景。日常跟单用列表+脊柱;卡点时切换全景。',
  280. pageTitle: '细胞存储 · 业务办理', pageSub: '进行中协议 · 可切换「聚焦 / 全景」',
  281. highlightNode: null, dimNodes: [],
  282. spineDefaultCollapsed: false,
  283. canFullView: true,
  284. stats: [{ label: '进行中', count: 23 }, { label: '卡点', count: 4 }],
  285. columns: ['协议号', '客户', '样本', '当前里程碑', '并行状态', '操作'],
  286. list: [
  287. { id: 1, protocol: 'XY-2025-0066', customer: '张明华', sample: 'ZK…001', milestone: '接收完成', parallel: '质检+生产', nodes: mkNodes({ qc: 'parallel', production: 'parallel', reception: 'done', payment: 'indep' }) },
  288. { id: 2, protocol: 'XY-2025-0088', customer: '王强', sample: 'XY…003', milestone: '质检完成', parallel: '生产进行中', nodes: mkNodes({ qc: 'done', production: 'you', reception: 'done' }) }
  289. ]
  290. }
  291. };
  292. const SPINE_STEPS = [
  293. { id: 'protocol', name: '协议' },
  294. { id: 'collection', name: '采集' },
  295. { id: 'reception', name: '接收' },
  296. { id: 'qc', name: '质检' },
  297. { id: 'production', name: '生产' },
  298. { id: 'release', name: '放行' },
  299. { id: 'storage', name: '入库' }
  300. ];
  301. const SPINE_INDEP = { id: 'payment', name: '缴费' };
  302. function mkNodes(overrides) {
  303. const base = { protocol: 'done', collection: 'done', reception: 'done', qc: 'pending', production: 'pending', release: 'pending', storage: 'pending', payment: 'pending' };
  304. return { ...base, ...overrides };
  305. }
  306. let currentRole = 'finance';
  307. let currentView = 'focused';
  308. let selectedRow = null;
  309. let spineCollapsed = true;
  310. function renderRoleTabs() {
  311. document.getElementById('roleTabs').innerHTML = Object.entries(ROLES).map(([k, r]) =>
  312. `<button type="button" class="role-tab ${k === currentRole ? 'active' : ''}" onclick="setRole('${k}')">${r.label}</button>`
  313. ).join('');
  314. }
  315. function renderSidebar() {
  316. const r = ROLES[currentRole];
  317. document.getElementById('sidebar').innerHTML = `
  318. <div class="logo">中科兴皖<span> · 管理端</span></div>
  319. <div class="nav-sub">当前角色菜单(聚焦)</div>
  320. ${r.menu.map((m, i) => `<a href="#" class="nav-item ${i === r.defaultMenu ? 'active' : ''}">${m}</a>`).join('')}
  321. ${r.canFullView ? '<a href="#" class="nav-item" onclick="setView(\'full\');return false">业务办理全景</a>' : ''}
  322. `;
  323. }
  324. function setRole(role) {
  325. currentRole = role;
  326. selectedRow = null;
  327. spineCollapsed = ROLES[role].spineDefaultCollapsed;
  328. currentView = 'focused';
  329. renderAll();
  330. }
  331. function setView(v) {
  332. currentView = v;
  333. document.querySelectorAll('.view-toggle button').forEach(b => b.classList.toggle('active', b.dataset.view === v));
  334. document.getElementById('focusedView').classList.toggle('hidden', v === 'full');
  335. document.getElementById('fullProcess').classList.toggle('show', v === 'full');
  336. }
  337. function renderSpine(row, roleKey, opts = {}) {
  338. const r = ROLES[roleKey];
  339. const nodes = row.nodes || mkNodes({});
  340. const collapsed = opts.forceExpand ? false : spineCollapsed;
  341. const hl = r.highlightNode;
  342. const mainSteps = SPINE_STEPS.map(s => {
  343. const st = nodes[s.id] || 'pending';
  344. let cls = st === 'done' ? 'done' : st === 'you' ? 'you' : st === 'parallel' ? 'parallel' : '';
  345. if (r.dimNodes && r.dimNodes.includes(s.id)) cls += ' dim';
  346. if (hl === s.id && st !== 'you') cls = 'you';
  347. const icon = st === 'done' ? '✓' : st === 'you' ? '●' : st === 'parallel' ? '…' : '○';
  348. return `<div class="sp-node ${cls}"><div class="sp-dot">${icon}</div><div class="sp-name">${s.name}</div></div>`;
  349. }).join('');
  350. const paySt = nodes.payment || 'pending';
  351. const payCls = paySt === 'indep' ? 'indep' : paySt === 'you' ? 'you' : paySt === 'done' ? 'done' : '';
  352. const indep = `<div class="sp-node ${payCls}"><div class="sp-dot">${paySt === 'done' ? '✓' : paySt === 'you' ? '●' : '○'}</div><div class="sp-name">缴费</div></div>`;
  353. const title = row.protocol || row.sample || '—';
  354. const sub = row.customer ? `${row.customer} · ${row.milestone || row.type || ''}` : '';
  355. return `
  356. <div class="spine-wrap">
  357. <div class="spine-header" onclick="toggleSpine()">
  358. <div>
  359. <h3>流程脊柱 · ${title}</h3>
  360. <div class="meta">${sub} ${hl ? '· 高亮你的环节' : '· 跟单视图'}</div>
  361. </div>
  362. <span class="spine-toggle">${collapsed ? '展开流程 ▼' : '收起 ▲'}</span>
  363. </div>
  364. <div class="spine-body ${collapsed ? 'collapsed' : ''}">
  365. <div class="spine-compact">${mainSteps}</div>
  366. <div class="spine-side"><span>独立支路:</span>${indep}<span>— 不阻塞实验主链</span></div>
  367. </div>
  368. </div>`;
  369. }
  370. function toggleSpine() {
  371. spineCollapsed = !spineCollapsed;
  372. if (selectedRow) document.getElementById('detailSpine').innerHTML = renderSpine(selectedRow, currentRole);
  373. }
  374. function renderList(roleKey) {
  375. const r = ROLES[roleKey];
  376. document.getElementById('pageTitle').textContent = r.pageTitle;
  377. document.getElementById('pageSub').textContent = r.pageSub;
  378. document.getElementById('roleDesc').textContent = r.desc;
  379. document.getElementById('listTitle').textContent = r.pageTitle;
  380. document.getElementById('listHint').textContent = r.canFullView ? '可右上角切换流程全景' : '本岗聚焦列表';
  381. document.getElementById('viewToggle').style.display = r.canFullView ? 'flex' : 'none';
  382. document.getElementById('statRow').innerHTML = r.stats.map(s =>
  383. `<div class="stat-pill">${s.label}<em>${s.count}</em></div>`
  384. ).join('');
  385. document.getElementById('tableHead').innerHTML = `<tr>${r.columns.map(c => `<th>${c}</th>`).join('')}</tr>`;
  386. document.getElementById('tableBody').innerHTML = r.list.map((row, i) => {
  387. let cells = '';
  388. if (roleKey === 'finance') {
  389. cells = `<td>${row.protocol}</td><td>${row.customer}</td><td>${row.amount}</td><td>${row.type}</td><td><span class="tag info">${row.milestone}</span></td><td><button class="btn btn-primary btn-sm" onclick="event.stopPropagation();selectRow(${i})">确认</button></td>`;
  390. } else if (roleKey === 'qc_recv') {
  391. cells = `<td>${row.sample}</td><td>${row.protocol}</td><td>${row.customer}</td><td>${row.eta}</td><td><span class="tag warning">${row.milestone}</span></td><td><button class="btn btn-primary" onclick="event.stopPropagation();selectRow(${i})">接收</button></td>`;
  392. } else if (roleKey === 'qc_lab') {
  393. cells = `<td>${row.task}</td><td>${row.sample}</td><td>${row.item}</td><td>${row.wo}</td><td><span class="tag muted">${row.prod}</span></td><td><button class="btn btn-primary" onclick="event.stopPropagation();selectRow(${i})">执行</button></td>`;
  394. } else if (roleKey === 'production') {
  395. cells = `<td>${row.wo}</td><td>${row.sample}</td><td>${row.protocol}</td><td>${row.status}</td><td><span class="tag muted">${row.qc}</span></td><td><button class="btn btn-primary" onclick="event.stopPropagation();selectRow(${i})">配置</button></td>`;
  396. } else {
  397. cells = `<td>${row.protocol}</td><td>${row.customer}</td><td>${row.sample}</td><td>${row.milestone}</td><td>${row.parallel}</td><td><button class="btn" onclick="event.stopPropagation();selectRow(${i})">跟单</button></td>`;
  398. }
  399. return `<tr class="${selectedRow === row ? 'selected' : ''}" onclick="selectRow(${i})">${cells}</tr>`;
  400. }).join('');
  401. }
  402. function selectRow(index) {
  403. const r = ROLES[currentRole];
  404. selectedRow = r.list[index];
  405. document.getElementById('detailPanel').classList.add('open');
  406. document.getElementById('detailSpine').innerHTML = renderSpine(selectedRow, currentRole);
  407. const forms = {
  408. finance: `
  409. <div class="form-row"><label>到账金额</label><input value="${selectedRow.amount}" /></div>
  410. <div class="form-row"><label>到账日期</label><input type="date" value="2026-06-12" /></div>
  411. <div class="form-row"><label>财务备注</label><input placeholder="可选" /></div>`,
  412. qc_recv: `
  413. <div class="form-row"><label>接收时间</label><input type="datetime-local" /></div>
  414. <div class="form-row"><label>接收温度 ℃</label><input value="4.2" /></div>
  415. <div class="form-row"><label>接收结论</label><select><option>接收</option><option>拒收</option></select></div>`,
  416. qc_lab: `
  417. <div class="form-row"><label>检测项目</label><input value="${selectedRow.item}" readonly /></div>
  418. <div class="form-row"><label>采集值 / 结果</label><input placeholder="实验录入" /></div>`,
  419. production: `
  420. <div class="form-row"><label>工艺路线</label><select><option>入库标准工艺 v2</option></select></div>
  421. <div class="form-row"><label>生产任务</label><input placeholder="创建任务…" /></div>`,
  422. coordinator: `
  423. <div class="form-row"><label>跟单备注</label><input placeholder="记录协调事项" /></div>
  424. <p style="font-size:12px;color:var(--text-muted)">跟单在此查看脊柱;需要时可切「流程全景」。</p>`
  425. };
  426. const titles = { finance: '财务确认', qc_recv: '样本接收登记', qc_lab: '质检任务执行', production: '生产工单配置', coordinator: '跟单备注' };
  427. document.getElementById('workTitle').textContent = titles[currentRole];
  428. document.getElementById('workForm').innerHTML = forms[currentRole];
  429. document.getElementById('workActions').innerHTML = `<button class="btn btn-primary" onclick="toast('已提交 · 流程状态已同步更新')">提交</button><button class="btn" onclick="closeDetail()">关闭</button>`;
  430. document.getElementById('contextLines').innerHTML = `
  431. <div class="ctx-line"><span>协议:</span>${selectedRow.protocol || '—'}</div>
  432. <div class="ctx-line"><span>客户:</span>${selectedRow.customer || '—'}</div>
  433. <div class="ctx-line"><span>样本:</span>${selectedRow.sample || '—'}</div>
  434. <div class="ctx-line"><span>实验里程碑:</span>${selectedRow.milestone || selectedRow.type || '—'}</div>
  435. <div class="ctx-line" style="margin-top:10px;font-size:11px;color:var(--text-muted)">↑ 其他部门可见同一上下文,无需进同一操作页</div>`;
  436. renderList(currentRole);
  437. }
  438. function closeDetail() {
  439. selectedRow = null;
  440. document.getElementById('detailPanel').classList.remove('open');
  441. renderList(currentRole);
  442. }
  443. function renderFullProcess() {
  444. document.getElementById('fullFlowDiagram').innerHTML = `
  445. <div style="font-size:12px;color:var(--text-muted);margin-bottom:10px">协议 → 采集 → 接收 → (质检 ∥ 生产) → 放行 → 入库 · 缴费独立</div>
  446. <div class="fork-row">
  447. <div class="flow-card done">协议✓</div><span>→</span>
  448. <div class="flow-card done">采集✓</div><span>→</span>
  449. <div class="flow-card done">接收✓</div><span>→</span>
  450. <div class="flow-card active">质检…</div>
  451. <div class="flow-card active">生产…</div><span>→</span>
  452. <div class="flow-card">放行</div><span>→</span>
  453. <div class="flow-card">入库</div>
  454. </div>
  455. <div style="margin-top:12px;padding:10px;border:1px dashed var(--border);border-radius:8px;font-size:12px">缴费 ○ 独立支路(虚线关联,不阻塞主链)</div>`;
  456. }
  457. function renderAll() {
  458. renderRoleTabs();
  459. renderSidebar();
  460. renderList(currentRole);
  461. setView(currentView);
  462. renderFullProcess();
  463. if (selectedRow) document.getElementById('detailSpine').innerHTML = renderSpine(selectedRow, currentRole);
  464. }
  465. document.getElementById('viewToggle').addEventListener('click', e => {
  466. const btn = e.target.closest('button[data-view]');
  467. if (btn) setView(btn.dataset.view);
  468. });
  469. function toast(msg) {
  470. const t = document.getElementById('toast'); t.textContent = msg; t.classList.add('show');
  471. setTimeout(() => t.classList.remove('show'), 2500);
  472. }
  473. renderAll();
  474. </script>
  475. </body>
  476. </html>