Files
zkxw/docs/demos/2026-10-11-market-remaining-mobile-parity-demo.html
T

1976 lines
75 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>市场现场端剩余模块移动复刻 Demo</title>
<style>
:root {
--brand: #0a9f6d;
--brand-dark: #087f58;
--brand-soft: #e7f7f1;
--canvas: #f4f7f5;
--surface: #fff;
--text: #1f2d27;
--muted: #73817a;
--line: #e6ece9;
--danger: #e5484d;
--danger-soft: #fff0f0;
--warning: #d98516;
--warning-soft: #fff6e8;
--info: #4d6a5d;
--shadow: 0 10px 30px rgba(25, 65, 49, 0.12);
--radius: 16px;
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
margin: 0;
color: var(--text);
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei", sans-serif;
background: #eaf1ed;
}
button,
input,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
.demo-stage {
min-height: 100vh;
display: grid;
grid-template-columns: minmax(270px, 330px) 390px;
align-items: center;
justify-content: center;
gap: 36px;
padding: 30px;
}
.demo-controls {
align-self: center;
padding: 26px;
border: 1px solid rgba(10, 159, 109, 0.12);
border-radius: 24px;
background: rgba(255, 255, 255, 0.94);
box-shadow: var(--shadow);
}
.eyebrow {
margin: 0 0 8px;
color: var(--brand);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.demo-controls h2 {
margin: 0;
font-size: 24px;
line-height: 1.3;
}
.demo-controls .intro {
margin: 10px 0 22px;
color: var(--muted);
font-size: 14px;
line-height: 1.75;
}
.control-label {
display: block;
margin: 18px 0 9px;
color: #506259;
font-size: 12px;
font-weight: 750;
}
.segmented {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px;
padding: 4px;
border-radius: 14px;
background: #edf3f0;
}
.segmented button,
.module-grid button {
min-height: 44px;
border: 0;
border-radius: 11px;
color: #52635b;
background: transparent;
font-size: 14px;
font-weight: 700;
}
.segmented button.is-active,
.module-grid button.is-active {
color: #fff;
background: var(--brand);
box-shadow: 0 5px 14px rgba(10, 159, 109, 0.2);
}
.module-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.module-grid button {
border: 1px solid var(--line);
background: #fff;
}
.module-grid button:last-child {
grid-column: 1 / -1;
}
.scope-hint {
margin: 18px 0 0;
padding: 13px 14px;
border-left: 3px solid var(--brand);
border-radius: 10px;
color: #4c6258;
background: var(--brand-soft);
font-size: 13px;
line-height: 1.65;
}
.demo-note {
margin: 13px 0 0;
color: #8a9891;
font-size: 12px;
line-height: 1.6;
}
.phone {
position: relative;
width: 390px;
height: min(844px, calc(100vh - 36px));
min-height: 650px;
overflow: hidden;
border: 8px solid #203129;
border-radius: 34px;
background: var(--canvas);
box-shadow: 0 28px 70px rgba(19, 47, 36, 0.3);
}
.phone::before {
content: "";
position: absolute;
z-index: 30;
top: 8px;
left: 50%;
width: 104px;
height: 23px;
border-radius: 0 0 14px 14px;
background: #203129;
transform: translateX(-50%);
}
.mobile-nav {
position: absolute;
z-index: 10;
top: 0;
left: 0;
right: 0;
height: 66px;
display: grid;
grid-template-columns: 52px 1fr 52px;
align-items: end;
padding: 0 8px 10px;
border-bottom: 1px solid var(--line);
background: #fff;
}
.mobile-nav h1 {
margin: 0;
overflow: hidden;
font-size: 17px;
line-height: 34px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-back,
.nav-more {
min-width: 44px;
min-height: 44px;
border: 0;
color: #263b31;
background: transparent;
font-size: 30px;
line-height: 1;
}
.nav-more {
font-size: 20px;
}
.mobile-body {
position: absolute;
top: 66px;
left: 0;
right: 0;
bottom: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
}
.mobile-body::-webkit-scrollbar,
.sheet-body::-webkit-scrollbar {
display: none;
}
.search-wrap {
position: sticky;
z-index: 5;
top: 0;
padding: 12px 14px;
border-bottom: 1px solid rgba(230, 236, 233, 0.8);
background: #fff;
}
.search-box {
position: relative;
}
.search-icon {
position: absolute;
top: 50%;
left: 14px;
color: #829189;
transform: translateY(-50%);
}
.search-box input {
width: 100%;
height: 42px;
padding: 0 38px;
border: 1px solid transparent;
border-radius: 22px;
outline: 0;
color: var(--text);
background: #f1f5f3;
font-size: 14px;
}
.search-box input:focus {
border-color: rgba(10, 159, 109, 0.36);
background: #fff;
}
.search-clear {
position: absolute;
top: 50%;
right: 5px;
width: 34px;
min-height: 34px;
border: 0;
border-radius: 50%;
color: #829189;
background: transparent;
transform: translateY(-50%);
}
.list-scope {
display: flex;
gap: 7px;
align-items: center;
padding: 9px 16px 0;
color: #6d7e75;
font-size: 12px;
}
.role-dot {
width: 7px;
height: 7px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--brand);
}
.card-list {
display: grid;
gap: 12px;
padding: 12px 12px 96px;
}
.biz-card {
overflow: hidden;
border: 1px solid #e3ebe7;
border-radius: var(--radius);
background: var(--surface);
box-shadow: 0 5px 16px rgba(30, 65, 51, 0.07);
}
.card-head {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 14px 14px 11px;
border-bottom: 1px solid #edf1ef;
}
.index-dot {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 50%;
color: #fff;
background: var(--brand);
font-size: 13px;
font-weight: 800;
}
.card-title {
min-width: 0;
}
.card-title strong {
display: block;
overflow: hidden;
font-size: 15px;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-title small {
display: block;
margin-top: 3px;
overflow: hidden;
color: var(--muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag {
display: inline-flex;
min-height: 24px;
align-items: center;
justify-content: center;
padding: 2px 8px;
border: 1px solid transparent;
border-radius: 999px;
color: var(--brand-dark);
background: var(--brand-soft);
font-size: 11px;
font-weight: 750;
white-space: nowrap;
}
.tag.warning {
color: #a75b00;
background: var(--warning-soft);
}
.tag.danger {
color: #bf3439;
background: var(--danger-soft);
}
.tag.info {
color: #5b6f65;
background: #eef2f0;
}
.tag.primary {
color: #1f66ae;
background: #edf5ff;
}
.card-body {
padding: 12px 14px;
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 15px;
}
.field-item {
min-width: 0;
}
.field-item.span-2 {
grid-column: 1 / -1;
}
.field-item label {
display: block;
margin-bottom: 3px;
color: #89968f;
font-size: 11px;
}
.field-item div {
overflow: hidden;
color: #33483e;
font-size: 13px;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.field-item .money {
color: #a95e09;
font-weight: 800;
}
.status-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
padding-top: 11px;
border-top: 1px dashed #dfe7e3;
}
.privacy-note {
margin-top: 12px;
padding: 9px 10px;
border-radius: 9px;
color: #61756a;
background: #f0f4f2;
font-size: 11px;
line-height: 1.5;
}
.card-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 10px 14px 13px;
border-top: 1px solid #edf1ef;
}
.action-btn {
min-height: 38px;
display: inline-flex;
flex: 1 1 76px;
align-items: center;
justify-content: center;
gap: 5px;
padding: 0 10px;
border: 1px solid #dce6e1;
border-radius: 10px;
color: #40564b;
background: #fff;
font-size: 12px;
font-weight: 700;
}
.action-btn.primary {
border-color: var(--brand);
color: #fff;
background: var(--brand);
}
.action-btn.danger {
border-color: #f2c8ca;
color: var(--danger);
background: #fff;
}
.fab {
position: absolute;
z-index: 15;
right: 18px;
bottom: 22px;
width: 56px;
height: 56px;
display: grid;
place-items: center;
padding: 0 0 3px;
border: 0;
border-radius: 50%;
color: #fff;
background: var(--brand);
box-shadow: 0 9px 24px rgba(10, 159, 109, 0.35);
font-size: 34px;
font-weight: 300;
line-height: 1;
}
.fab[hidden] {
display: none;
}
.list-footer {
padding: 4px 0 18px;
color: #929e98;
font-size: 12px;
text-align: center;
}
.empty-state {
display: grid;
min-height: 420px;
place-items: center;
padding: 30px;
color: var(--muted);
text-align: center;
}
.empty-state .empty-icon {
display: block;
margin-bottom: 10px;
font-size: 40px;
}
.screen-overlay {
position: fixed;
z-index: 100;
inset: 0;
display: grid;
place-items: center;
padding: 18px;
background: rgba(18, 34, 27, 0.52);
}
.sheet {
position: relative;
width: min(390px, calc(100vw - 24px));
height: min(844px, calc(100vh - 30px));
overflow: hidden;
border: 7px solid #203129;
border-radius: 30px;
background: var(--canvas);
box-shadow: 0 28px 70px rgba(14, 35, 27, 0.4);
}
.sheet-nav {
height: 62px;
display: grid;
grid-template-columns: 52px 1fr 52px;
align-items: end;
padding: 0 8px 8px;
border-bottom: 1px solid var(--line);
background: #fff;
}
.sheet-nav h2 {
margin: 0;
overflow: hidden;
font-size: 16px;
line-height: 42px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.sheet-body {
position: absolute;
top: 62px;
left: 0;
right: 0;
bottom: 68px;
overflow-y: auto;
padding: 12px;
}
.sheet.no-footer .sheet-body {
bottom: 0;
}
.sheet-footer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
min-height: 68px;
display: flex;
gap: 10px;
align-items: center;
padding: 10px 14px;
border-top: 1px solid var(--line);
background: #fff;
}
.bottom-btn {
min-height: 46px;
flex: 1;
border: 1px solid #dce6e1;
border-radius: 12px;
color: #465b51;
background: #fff;
font-weight: 750;
}
.bottom-btn.primary {
border-color: var(--brand);
color: #fff;
background: var(--brand);
}
.form-section,
.detail-section {
margin-bottom: 12px;
overflow: hidden;
border: 1px solid #e4ebe7;
border-radius: 15px;
background: #fff;
}
.section-title {
display: flex;
gap: 8px;
align-items: center;
margin: 0;
padding: 13px 14px 10px;
font-size: 14px;
}
.section-title::before {
content: "";
width: 3px;
height: 16px;
border-radius: 2px;
background: var(--brand);
}
.van-cell {
position: relative;
min-height: 56px;
display: grid;
grid-template-columns: 116px minmax(0, 1fr) 18px;
gap: 8px;
align-items: center;
margin: 0 14px;
border-top: 1px solid #edf1ef;
}
.van-cell:first-of-type {
border-top: 0;
}
.van-cell label {
color: #4a5c53;
font-size: 13px;
}
.van-cell .required::before {
content: "*";
margin-right: 3px;
color: var(--danger);
}
.van-cell input,
.van-cell textarea,
.cell-value {
min-width: 0;
width: 100%;
padding: 8px 0;
border: 0;
outline: 0;
color: #253a30;
background: transparent;
font-size: 13px;
text-align: right;
}
.van-cell textarea {
min-height: 72px;
resize: none;
text-align: left;
}
.van-cell.textarea-cell {
grid-template-columns: 1fr;
gap: 0;
align-items: start;
padding: 10px 0;
}
.cell-arrow {
color: #a0aaa5;
font-size: 18px;
text-align: right;
}
.cell-value.placeholder {
color: #a6b0ab;
}
.cell-value.disabled {
color: #adb7b2;
}
.choice-row {
display: flex;
gap: 8px;
padding: 4px 14px 14px;
}
.choice-chip {
min-height: 38px;
padding: 0 13px;
border: 1px solid #dce6e1;
border-radius: 10px;
color: #52655b;
background: #fff;
}
.choice-chip.active {
border-color: var(--brand);
color: var(--brand-dark);
background: var(--brand-soft);
}
.attachment-row {
display: flex;
gap: 10px;
overflow-x: auto;
padding: 0 14px 14px;
}
.upload-card,
.file-card {
width: 118px;
height: 86px;
flex: 0 0 auto;
border-radius: 12px;
}
.upload-card {
display: grid;
place-items: center;
border: 1.5px dashed #72c5a6;
color: var(--brand);
background: #f5fcf9;
font-size: 12px;
font-weight: 750;
}
.upload-card span {
display: block;
margin-bottom: 4px;
font-size: 25px;
line-height: 1;
text-align: center;
}
.file-card {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
overflow: hidden;
border: 1px solid #dfe9e4;
color: #3f554a;
background: #eef5f2;
}
.file-icon {
display: grid;
flex: 1;
place-items: center;
color: var(--brand);
font-size: 25px;
}
.file-name {
overflow: hidden;
padding: 6px 8px;
background: #fff;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-delete {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
min-height: 24px;
border: 0;
border-radius: 50%;
color: #fff;
background: rgba(31, 45, 39, 0.72);
}
.attachment-hint {
margin: -6px 14px 14px;
color: #8d9993;
font-size: 11px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.summary-item {
padding: 12px 8px;
border: 1px solid #e1e9e5;
border-radius: 12px;
background: #fff;
text-align: center;
}
.summary-item label {
display: block;
margin-bottom: 5px;
color: var(--muted);
font-size: 10px;
}
.summary-item strong {
color: #31473c;
font-size: 13px;
}
.summary-item strong.highlight {
color: var(--brand-dark);
}
.timeline {
display: grid;
gap: 10px;
padding: 0 14px 14px;
}
.timeline-row {
position: relative;
padding: 10px 11px 10px 31px;
border-radius: 10px;
background: #f6f8f7;
}
.timeline-row::before {
content: "";
position: absolute;
top: 15px;
left: 13px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--brand);
}
.timeline-row strong {
font-size: 12px;
}
.timeline-row p {
margin: 5px 0 0;
color: var(--muted);
font-size: 11px;
line-height: 1.55;
}
.transfer-card {
margin: 0 14px 12px;
padding: 12px;
border: 1px solid #e0e9e4;
border-radius: 12px;
background: #f9fbfa;
}
.transfer-card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.transfer-card p {
margin: 4px 0;
color: #5c6f65;
font-size: 12px;
}
.mini-actions {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 9px;
}
.mini-actions button {
min-height: 34px;
padding: 0 10px;
border: 1px solid #d8e3de;
border-radius: 9px;
color: #41574c;
background: #fff;
font-size: 11px;
}
.picker-layer,
.confirm-layer {
position: fixed;
z-index: 130;
inset: 0;
display: flex;
justify-content: center;
background: rgba(17, 33, 26, 0.48);
}
.picker-layer {
align-items: flex-end;
}
.picker-panel {
width: min(390px, 100vw);
max-height: 68vh;
overflow: hidden;
border-radius: 22px 22px 0 0;
background: #fff;
box-shadow: 0 -12px 36px rgba(19, 44, 34, 0.2);
}
.picker-head {
min-height: 54px;
display: grid;
grid-template-columns: 70px 1fr 70px;
align-items: center;
border-bottom: 1px solid var(--line);
}
.picker-head h3 {
margin: 0;
font-size: 15px;
text-align: center;
}
.picker-head button {
min-height: 44px;
border: 0;
color: var(--brand);
background: transparent;
}
.picker-list {
max-height: 50vh;
overflow-y: auto;
padding: 6px 16px 20px;
}
.picker-option {
width: 100%;
min-height: 50px;
border: 0;
border-bottom: 1px solid #edf1ef;
color: #31473c;
background: #fff;
text-align: center;
}
.confirm-layer {
align-items: center;
padding: 24px;
}
.confirm-box {
width: min(320px, 100%);
overflow: hidden;
border-radius: 18px;
background: #fff;
box-shadow: 0 24px 58px rgba(17, 37, 29, 0.32);
}
.confirm-content {
padding: 24px 22px 18px;
text-align: center;
}
.confirm-content h3 {
margin: 0 0 9px;
font-size: 17px;
}
.confirm-content p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.7;
}
.confirm-actions {
display: grid;
grid-template-columns: repeat(2, 1fr);
border-top: 1px solid var(--line);
}
.confirm-actions button {
min-height: 50px;
border: 0;
background: #fff;
font-weight: 750;
}
.confirm-actions button + button {
border-left: 1px solid var(--line);
color: var(--brand);
}
.toast {
position: fixed;
z-index: 200;
top: 50%;
left: 50%;
max-width: 280px;
padding: 12px 18px;
border-radius: 12px;
color: #fff;
background: rgba(28, 43, 36, 0.9);
font-size: 13px;
opacity: 0;
pointer-events: none;
transform: translate(-50%, -42%);
transition: 0.2s ease;
}
.toast.show {
opacity: 1;
transform: translate(-50%, -50%);
}
@media (max-width: 820px) {
.demo-stage {
grid-template-columns: minmax(0, 1fr);
gap: 20px;
padding: 16px 10px 30px;
}
.demo-controls {
width: min(390px, 100%);
margin: 0 auto;
padding: 20px;
}
.phone {
width: min(390px, calc(100vw - 20px));
height: 800px;
margin: 0 auto;
}
}
</style>
</head>
<body>
<main class="demo-stage">
<aside class="demo-controls" aria-label="原型控制台">
<p class="eyebrow">Mobile parity demo</p>
<h2>市场现场端<br />剩余模块移动复刻</h2>
<p class="intro">
一个 HTML 查看 2 种角色 × 5 个模块。手机画布采用 OA 工作流与 Vant 4 的移动组件语义。
</p>
<span class="control-label">使用角色</span>
<div id="roleSwitch" class="segmented"></div>
<span class="control-label">业务模块</span>
<div id="moduleSwitch" class="module-grid"></div>
<p id="scopeHint" class="scope-hint"></p>
<p class="demo-note">提示:可点击搜索、卡片按钮、右下角新增按钮,并滚动列表查看动态分页效果。</p>
</aside>
<section class="phone" aria-label="移动端页面预览">
<header class="mobile-nav">
<button class="nav-back" type="button" aria-label="返回">‹</button>
<h1 id="pageTitle"></h1>
<button class="nav-more" type="button" aria-label="更多">•••</button>
</header>
<div id="mobileBody" class="mobile-body"></div>
<button id="fab" class="fab" type="button" aria-label="新增">+</button>
</section>
</main>
<div id="overlayRoot"></div>
<div id="floatLayer"></div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script>
const roles = {
specialist: {
label: '市场专员',
scope: '仅展示当前市场专员本人负责的现场单据;运费批次中的其他专员明细会脱敏。'
},
manager: {
label: '地区经理',
scope: '展示负责采集地区及指定为当前经理的单据;运费批次可查看分管范围内完整明细。'
}
}
const modules = {
protocol: {
title: '协议单',
create: true,
search: '搜索母亲姓名 / 协议编号 / 样本编码'
},
chase: {
title: '催缴信息',
create: false,
search: '搜索协议号 / 客户 / 样本编码'
},
payment: {
title: '缴费信息',
create: false,
search: '搜索协议号 / 客户 / 样本编码'
},
sample: {
title: '样本信息',
create: false,
search: '搜索母亲姓名 / 样本编码'
},
freight: {
title: '运费信息',
create: true,
search: '搜索批次号 / 母亲姓名 / 样本编码'
}
}
const names = ['李安然', '周雨桐', '陈嘉禾', '孙语柔', '吴思宁', '方知夏']
const hospitals = ['安徽省妇幼保健院', '合肥市第一人民医院', '六安市人民医院', '芜湖市第二人民医院', '蚌埠医学院一附院', '安庆市立医院']
const demoData = {
protocol: names.map((name, index) => ({
id: `p-${index + 1}`,
source: index === 2 || index === 5 ? 'research_donation' : 'cell_storage',
collectDate: `2026-10-${String(11 - index).padStart(2, '0')}`,
labStorageCode: `LAB-2610${String(11 - index).padStart(2, '0')}-${String(18 + index).padStart(3, '0')}`,
motherName: name,
protocolNo: index === 2 || index === 5 ? `KYJZ2026100${index + 1}` : `XBC2026101100${index + 1}`,
sampleTypeName: index % 2 ? '脐带间充质干细胞' : '脐带血造血干细胞',
coStored: index === 1 || index === 4,
regionName: index < 3 ? '合肥区域' : '皖南区域',
hospitalName: hospitals[index],
sampleCollectTime: `2026-10-${String(11 - index).padStart(2, '0')} ${index % 2 ? '14:20' : '09:20'}`,
payMode: index === 2 || index === 5 ? '—' : index % 2 ? '分期' : '趸交',
firstPayTime: index === 2 || index === 5 ? '—' : `2026-10-${String(11 - index).padStart(2, '0')} 10:05`,
salesUserName: index % 2 ? '张琳' : '王敏',
managerName: index < 3 ? '赵经理' : '钱经理',
transportMode: index % 2 ? '专员送达' : '冷链配送',
status: index === 4 ? '待生效' : index === 5 ? '已过期' : '生效中'
})),
chase: names.map((name, index) => ({
id: `c-${index + 1}`,
protocolNo: `XBC2026090${index + 1}`,
motherName: name,
motherPhone: `138****${String(3011 + index).slice(-4)}`,
sampleCode: `SMP-2026-${String(112 + index).padStart(4, '0')}`,
salesUserName: index % 2 ? '张琳' : '王敏',
payMode: index % 2 ? '分期' : '趸交',
scheduleNo: index + 1,
dueDate: `2026-${index < 3 ? '10' : '11'}-${String(8 + index).padStart(2, '0')}`,
dueAmount: 3200 + index * 500,
paidAmount: index % 3 === 0 ? 1200 : index % 3 === 1 ? 0 : 2000,
arrearsAmount: index % 3 === 0 ? 2000 + index * 500 : index % 3 === 1 ? 3700 + index * 500 : 2200 + index * 500,
chaseStatus: index < 3 ? '已逾期' : '30 天内到期',
days: index < 3 ? `${4 + index} 天` : `${12 + index} 天`,
protocolStatus: index === 5 ? '待生效' : '生效中'
})),
payment: names.map((name, index) => ({
id: `pay-${index + 1}`,
protocolNo: `XBC2026080${index + 1}`,
motherName: name,
motherPhone: `139****${String(6081 + index).slice(-4)}`,
sampleCode: `SMP-2026-${String(180 + index).padStart(4, '0')}`,
payMode: index % 2 ? '分期' : '趸交',
contractAmount: 28000 + index * 1200,
paidAmount: index % 3 === 0 ? 28000 + index * 1200 : 8000 + index * 1100,
remainAmount: index % 3 === 0 ? 0 : 20000 + index * 100,
cleared: index % 3 === 0 ? 1 : index % 3 === 1 ? 2 : 3,
active: index % 2 ? 6 : 4,
progress: index % 3 === 0 ? '已缴清' : index % 3 === 1 ? '部分缴费' : '待缴费',
canConfirm: index === 1 || index === 4
})),
sample: names.map((name, index) => ({
id: `s-${index + 1}`,
source: index === 5 ? 'research_donation' : 'cell_storage',
protocolStatus: index === 4 ? '待生效' : '生效中',
expectedDeliveryDate: `2026-1${index % 3}-${String(12 + index).padStart(2, '0')}`,
motherName: name,
newbornName: index < 2 ? '—' : ['安安', '乐乐', '晨晨', '一一'][index - 2],
sampleCode: `SMP-2026-${String(301 + index).padStart(4, '0')}`,
labStorageCode: index < 2 ? '—' : `LAB-RK-${String(71 + index).padStart(4, '0')}`,
sampleTypeName: index % 2 ? '脐带间充质干细胞' : '脐带血造血干细胞',
collection: index === 4 ? '未采集' : '已采集',
reception: index < 1 ? '未接收' : '已接收',
qc: index < 2 ? '未完成' : '已完成',
storage: index < 3 ? '未入库' : '已入库',
storageCert: index === 3 ? '已办理' : '未办理',
insurance: index === 3 ? '已办理' : '未办理'
})),
freight: names.map((name, index) => ({
id: `f-${index + 1}`,
batchNo: `YF20261011${String(index + 1).padStart(3, '0')}`,
transportMode: index % 2 ? '专员送达' : '冷链配送',
freightAmount: 120 + index * 25,
linkedSampleCount: 2 + (index % 3),
source: index === 4 ? '科研捐赠' : '细胞存储',
motherName: name,
newbornName: index < 2 ? '—' : ['安安', '乐乐', '晨晨', '一一'][index - 2],
sampleCode: `SMP-2026-${String(421 + index).padStart(4, '0')}`,
sampleTypeName: index % 2 ? '脐带间充质干细胞' : '脐带血造血干细胞',
creator: index % 2 ? '张琳' : '王敏',
createTime: `2026-10-${String(11 - index).padStart(2, '0')} ${index % 2 ? '15:10' : '09:30'}`,
managerDetails: [`${name} · SMP-2026-${String(421 + index).padStart(4, '0')}`, `关联样本 · SMP-2026-${String(521 + index).padStart(4, '0')}`]
}))
}
const state = {
role: 'specialist',
module: 'protocol',
keyword: '',
visibleCount: 3,
pageSize: 3,
loadingMore: false
}
const regions = {
合肥区域: ['安徽省妇幼保健院', '合肥市第一人民医院', '安徽医科大学第一附属医院'],
皖南区域: ['芜湖市第二人民医院', '宣城市人民医院', '马鞍山市妇幼保健院'],
皖西区域: ['六安市人民医院', '阜阳市人民医院']
}
let formDraft = null
let toastTimer = null
let pickerCallback = null
let confirmCallback = null
const el = (id) => document.getElementById(id)
const money = (value) => `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`
const esc = (value) =>
String(value ?? '—')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;')
function tag(text, tone = '') {
return `<span class="tag ${tone}">${esc(text)}</span>`
}
function field(label, value, className = '') {
return `<div class="field-item ${className}"><label>${esc(label)}</label><div>${value}</div></div>`
}
function icon(name) {
const icons = { view: '⌕', edit: '✎', audit: '✓', delete: '⌫', doc: '▤', pay: '¥', link: '⌁', add: '+', cert: '▧', insurance: '◇' }
return `<span aria-hidden="true">${icons[name] || '•'}</span>`
}
function action(label, actionName, id, options = {}) {
return `<button class="action-btn ${options.tone || ''}" type="button" data-action="${actionName}" data-id="${id}">${icon(options.icon)}${esc(label)}</button>`
}
function toneFor(text) {
if (/逾期|作废|拒绝|欠缴/.test(text)) return 'danger'
if (/待|未|部分|到期/.test(text)) return 'warning'
if (/来源|科研|冷链|分期/.test(text)) return 'primary'
if (/已|生效|正常|趸交/.test(text)) return ''
return 'info'
}
function cardShell(row, index, title, subTitle, status, body, actions) {
return `
<article class="biz-card" data-row-id="${row.id}">
<div class="card-head">
<span class="index-dot">${index + 1}</span>
<div class="card-title"><strong>${esc(title)}</strong><small>${esc(subTitle)}</small></div>
${tag(status, toneFor(status))}
</div>
<div class="card-body">${body}</div>
<div class="card-actions">${actions}</div>
</article>`
}
function renderProtocolCard(row, index) {
const sourceLabel = row.source === 'cell_storage' ? '细胞存储' : '科研捐赠'
const body = `
<div class="field-grid">
${field('业务来源', tag(sourceLabel, row.source === 'cell_storage' ? '' : 'warning'))}
${field('采集日期', esc(row.collectDate))}
${field('实验室流转编号', esc(row.labStorageCode), 'span-2')}
${field(row.source === 'cell_storage' ? '协议编号' : '捐赠编号', esc(row.protocolNo), 'span-2')}
${field('样本类型', esc(row.sampleTypeName), 'span-2')}
${field('是否同存', tag(row.coStored ? '是' : '否', row.coStored ? 'warning' : 'info'))}
${field('采集地区', esc(row.regionName))}
${field('采集医院', esc(row.hospitalName), 'span-2')}
${field('采集时间', esc(row.sampleCollectTime), 'span-2')}
${field('缴费方式', tag(row.payMode, toneFor(row.payMode)))}
${field('首期缴费时间', esc(row.firstPayTime))}
${field('市场专员', esc(row.salesUserName))}
${field('地区经理', esc(row.managerName))}
${field('运输方式', esc(row.transportMode), 'span-2')}
</div>`
const actions =
action('审核', 'audit-protocol', row.id, { icon: 'audit', tone: 'primary' }) +
action('编辑', 'edit-protocol', row.id, { icon: 'edit' }) +
action('查看', 'view-protocol', row.id, { icon: 'view' }) +
action('删除', 'delete-protocol', row.id, { icon: 'delete', tone: 'danger' })
return cardShell(row, index, row.motherName, row.protocolNo, row.status, body, actions)
}
function renderChaseCard(row, index) {
const body = `
<div class="field-grid">
${field('协议号', esc(row.protocolNo), 'span-2')}
${field('客户', esc(row.motherName))}
${field('联系电话', esc(row.motherPhone))}
${field('样本编码', esc(row.sampleCode), 'span-2')}
${field('市场专员', esc(row.salesUserName))}
${field('缴费方式', tag(row.payMode, toneFor(row.payMode)))}
${field('期次', `第 ${row.scheduleNo} 期`)}
${field('应缴日', esc(row.dueDate))}
${field('应缴金额', `<span class="money">${money(row.dueAmount)}</span>`)}
${field('已缴金额', `<span class="money">${money(row.paidAmount)}</span>`)}
${field('欠缴金额', `<span class="money">${money(row.arrearsAmount)}</span>`)}
${field('协议状态', tag(row.protocolStatus, toneFor(row.protocolStatus)))}
</div>
<div class="status-row">${tag(`${row.chaseStatus} ${row.days}`, toneFor(row.chaseStatus))}</div>`
const actions =
(row.paidAmount ? action('转账明细', 'view-chase-transfer', row.id, { icon: 'pay' }) : '') +
action('协议单', 'view-related-protocol', row.id, { icon: 'doc', tone: 'primary' })
return cardShell(row, index, `${row.motherName} · 第 ${row.scheduleNo} 期`, row.sampleCode, row.chaseStatus, body, actions)
}
function renderPaymentCard(row, index) {
const body = `
<div class="field-grid">
${field('协议号', esc(row.protocolNo), 'span-2')}
${field('客户', esc(row.motherName))}
${field('联系电话', esc(row.motherPhone))}
${field('样本编码', esc(row.sampleCode), 'span-2')}
${field('缴费方式', tag(row.payMode, toneFor(row.payMode)))}
${field('合同额', `<span class="money">${money(row.contractAmount)}</span>`)}
${field('已缴', `<span class="money">${money(row.paidAmount)}</span>`)}
${field('待缴', `<span class="money">${money(row.remainAmount)}</span>`)}
${field('整体进度', `已缴清 ${row.cleared}/${row.active} 期`)}
${field('缴费进度', tag(row.progress, toneFor(row.progress)))}
</div>`
const actions =
action('查看', 'view-payment', row.id, { icon: 'view', tone: 'primary' }) +
(row.canConfirm ? action('缴费确认', 'confirm-payment', row.id, { icon: 'audit' }) : '') +
action('协议单', 'view-related-protocol', row.id, { icon: 'doc' })
return cardShell(row, index, row.motherName, row.protocolNo, row.progress, body, actions)
}
function renderSampleCard(row, index) {
const body = `
<div class="field-grid">
${field('协议状态', tag(row.protocolStatus, toneFor(row.protocolStatus)))}
${field('预产期', esc(row.expectedDeliveryDate))}
${field('母亲姓名', esc(row.motherName))}
${field('新生儿姓名', esc(row.newbornName))}
${field('样本编码', esc(row.sampleCode), 'span-2')}
${field('实验室入库编码', esc(row.labStorageCode), 'span-2')}
${field('样本类型', esc(row.sampleTypeName), 'span-2')}
</div>
<div class="status-row">
${tag(`采集 · ${row.collection}`, toneFor(row.collection))}
${tag(`接收 · ${row.reception}`, toneFor(row.reception))}
${tag(`质检 · ${row.qc}`, toneFor(row.qc))}
${tag(`入库 · ${row.storage}`, toneFor(row.storage))}
${row.source === 'cell_storage' ? tag(`储存证 · ${row.storageCert}`, toneFor(row.storageCert)) : ''}
${row.source === 'cell_storage' ? tag(`保险 · ${row.insurance}`, toneFor(row.insurance)) : ''}
</div>`
let actions = action('查看', 'view-sample', row.id, { icon: 'view', tone: 'primary' })
if (row.source === 'cell_storage') {
actions += action('储存证质检信息', 'view-storage-qc', row.id, { icon: 'doc' })
if (row.storage === '已入库' && row.storageCert === '未办理') actions += action('登记储存证', 'register-cert', row.id, { icon: 'cert' })
if (row.storage === '已入库' && row.insurance === '未办理') actions += action('登记保险', 'register-insurance', row.id, { icon: 'insurance' })
}
return cardShell(row, index, row.motherName, row.sampleCode, row.protocolStatus, body, actions)
}
function renderFreightCard(row, index) {
const detailBlock = state.role === 'manager'
? `<div class="status-row">${row.managerDetails.map((item) => tag(item, 'info')).join('')}</div>`
: '<div class="privacy-note">🔒 同批次其他专员明细已脱敏</div>'
const body = `
<div class="field-grid">
${field('批次号', esc(row.batchNo), 'span-2')}
${field('运输方式', tag(row.transportMode, 'primary'))}
${field('运费金额', `<span class="money">${money(row.freightAmount)}</span>`)}
${field('关联数', state.role === 'manager' ? String(row.linkedSampleCount) : '仅本人相关')}
${field('来源', tag(row.source, row.source === '细胞存储' ? '' : 'warning'))}
${field('母亲姓名', esc(row.motherName))}
${field('子女姓名', esc(row.newbornName))}
${field('样本编码', esc(row.sampleCode), 'span-2')}
${field('样本类型', esc(row.sampleTypeName), 'span-2')}
${field('创建人', esc(row.creator))}
${field('创建时间', esc(row.createTime))}
</div>${detailBlock}`
const actions =
action('编辑', 'edit-freight', row.id, { icon: 'edit', tone: 'primary' }) +
action('关联样本', 'link-freight', row.id, { icon: 'link' }) +
action('取消关联', 'unlink-freight', row.id, { icon: 'delete', tone: 'danger' })
return cardShell(row, index, row.batchNo, `${row.transportMode} · ${money(row.freightAmount)}`, row.source, body, actions)
}
function renderCard(moduleKey, row, index) {
const renderers = {
protocol: renderProtocolCard,
chase: renderChaseCard,
payment: renderPaymentCard,
sample: renderSampleCard,
freight: renderFreightCard
}
return renderers[moduleKey](row, index)
}
function filteredRows() {
const keyword = state.keyword.trim().toLowerCase()
if (!keyword) return demoData[state.module]
return demoData[state.module].filter((row) => JSON.stringify(row).toLowerCase().includes(keyword))
}
function renderControls() {
el('roleSwitch').innerHTML = Object.entries(roles)
.map(([key, item]) => `<button type="button" data-role="${key}" class="${state.role === key ? 'is-active' : ''}">${item.label}</button>`)
.join('')
el('moduleSwitch').innerHTML = Object.entries(modules)
.map(([key, item]) => `<button type="button" data-module="${key}" class="${state.module === key ? 'is-active' : ''}">${item.title}</button>`)
.join('')
el('scopeHint').textContent = roles[state.role].scope
}
function renderList() {
const rows = filteredRows()
const visible = rows.slice(0, state.visibleCount)
el('pageTitle').textContent = `${roles[state.role].label} · ${modules[state.module].title}`
el('fab').hidden = !modules[state.module].create
el('mobileBody').innerHTML = `
<div class="search-wrap">
<div class="search-box">
<span class="search-icon">⌕</span>
<input id="keywordInput" type="search" value="${esc(state.keyword)}" placeholder="${esc(modules[state.module].search)}" aria-label="关键词搜索" />
${state.keyword ? '<button class="search-clear" type="button" data-action="clear-search" aria-label="清空">×</button>' : ''}
</div>
</div>
<div class="list-scope"><span class="role-dot"></span>${roles[state.role].label}数据范围 · 共 ${rows.length} 条示例数据</div>
${visible.length ? `<div class="card-list">${visible.map((row, index) => renderCard(state.module, row, index)).join('')}<div class="list-footer">${state.loadingMore ? '正在加载…' : visible.length < rows.length ? '继续向下滚动加载' : '没有更多了'}</div></div>` : '<div class="empty-state"><div><span class="empty-icon">⌕</span>没有找到匹配的数据</div></div>'}
`
const input = el('keywordInput')
if (input) {
input.addEventListener('input', (event) => {
state.keyword = event.target.value
state.visibleCount = state.pageSize
renderList()
requestAnimationFrame(() => {
const next = el('keywordInput')
if (next) {
next.focus()
next.setSelectionRange(next.value.length, next.value.length)
}
})
})
}
}
function renderApp() {
renderControls()
renderList()
}
function loadMore() {
if (state.loadingMore) return
const total = filteredRows().length
if (state.visibleCount >= total) return
state.loadingMore = true
renderList()
window.setTimeout(() => {
state.visibleCount = Math.min(state.visibleCount + state.pageSize, total)
state.loadingMore = false
renderList()
showToast(`已加载 ${Math.min(state.visibleCount, total)} / ${total} 条`)
}, 520)
}
function showToast(message) {
const node = el('toast')
node.textContent = message
node.classList.add('show')
window.clearTimeout(toastTimer)
toastTimer = window.setTimeout(() => node.classList.remove('show'), 1800)
}
function openSheet({ title, body, footer = '' }) {
el('overlayRoot').innerHTML = `
<div class="screen-overlay">
<section class="sheet ${footer ? '' : 'no-footer'}" role="dialog" aria-modal="true" aria-label="${esc(title)}">
<header class="sheet-nav">
<button class="nav-back" type="button" data-action="close-sheet" aria-label="返回">‹</button>
<h2>${esc(title)}</h2>
<span></span>
</header>
<div class="sheet-body">${body}</div>
${footer ? `<footer class="sheet-footer">${footer}</footer>` : ''}
</section>
</div>`
}
function closeSheet() {
el('overlayRoot').innerHTML = ''
el('floatLayer').innerHTML = ''
}
function openPicker(title, options, callback) {
pickerCallback = callback
el('floatLayer').innerHTML = `
<div class="picker-layer">
<section class="picker-panel" role="dialog" aria-modal="true" aria-label="${esc(title)}">
<header class="picker-head"><button type="button" data-action="close-float">取消</button><h3>${esc(title)}</h3><span></span></header>
<div class="picker-list">${options.map((item, index) => `<button class="picker-option" type="button" data-picker-index="${index}">${esc(item)}</button>`).join('')}</div>
</section>
</div>`
}
function confirmAction(message, callback) {
confirmCallback = callback
el('floatLayer').innerHTML = `
<div class="confirm-layer">
<section class="confirm-box" role="alertdialog" aria-modal="true">
<div class="confirm-content"><h3>请确认</h3><p>${esc(message)}</p></div>
<div class="confirm-actions"><button type="button" data-action="close-float">取消</button><button type="button" data-action="confirm-ok">确认</button></div>
</section>
</div>`
}
function formCell(label, value, options = {}) {
const valueClass = value ? '' : 'placeholder'
return `<div class="van-cell" ${options.action ? `data-action="${options.action}"` : ''}>
<label class="${options.required ? 'required' : ''}">${esc(label)}</label>
${options.input ? `<input data-draft-key="${options.key}" value="${esc(value || '')}" placeholder="${esc(options.placeholder || '请输入')}" />` : `<div class="cell-value ${valueClass} ${options.disabled ? 'disabled' : ''}">${esc(value || options.placeholder || '请选择')}</div>`}
<span class="cell-arrow">${options.action && !options.disabled ? '›' : ''}</span>
</div>`
}
function textareaCell(label, value, key) {
return `<div class="van-cell textarea-cell"><label>${esc(label)}</label><textarea data-draft-key="${key}" placeholder="请输入${esc(label)}">${esc(value || '')}</textarea></div>`
}
function attachmentArea(files, prefix = 'protocol') {
return `<div class="attachment-row">
<button class="upload-card" type="button" data-action="add-attachment" data-prefix="${prefix}"><div><span>+</span>上传附件</div></button>
${files.map((file, index) => `<div class="file-card"><div class="file-icon">▤</div><div class="file-name">${esc(file)}</div><button class="file-delete" type="button" data-action="remove-attachment" data-index="${index}" aria-label="删除附件">×</button></div>`).join('')}
</div><p class="attachment-hint">选择文件后立即上传;单个文件不超过 20MB</p>`
}
function syncDraftInputs() {
document.querySelectorAll('[data-draft-key]').forEach((node) => {
formDraft[node.dataset.draftKey] = node.value
})
}
function openProtocolForm(row = null) {
if (!formDraft || formDraft.kind !== 'protocol' || formDraft.id !== (row?.id || 'new')) {
formDraft = {
kind: 'protocol',
id: row?.id || 'new',
source: row?.source || 'cell_storage',
protocolNo: row?.protocolNo || '',
registerDate: row?.collectDate || '2026-10-11',
motherName: row?.motherName || '',
motherPhone: row ? '138****3011' : '',
idCard: '',
expectedDeliveryDate: '',
region: row?.regionName || '',
hospital: row?.hospitalName || '',
collectTime: row?.sampleCollectTime || '',
sampleType: row?.sampleTypeName || '',
sampleCode: row ? `SMP-${row.protocolNo.slice(-6)}` : '',
storageYears: row?.source === 'research_donation' ? '' : '20',
amount: row?.source === 'research_donation' ? '' : '28800',
remark: '',
attachments: row ? ['协议扫描件.pdf', '知情同意书.jpg'] : []
}
}
const title = row ? '编辑协议单' : '新增协议单'
const hospitalDisabled = !formDraft.region
const body = `
<section class="form-section">
<h3 class="section-title">协议信息</h3>
<div class="choice-row"><button type="button" class="choice-chip ${formDraft.source === 'cell_storage' ? 'active' : ''}" data-action="source-cell">细胞存储</button><button type="button" class="choice-chip ${formDraft.source === 'research_donation' ? 'active' : ''}" data-action="source-donation">科研捐赠</button></div>
${formCell(formDraft.source === 'cell_storage' ? '协议编号' : '捐赠编号', formDraft.protocolNo, { input: true, key: 'protocolNo', placeholder: '请输入编号' })}
${formCell(formDraft.source === 'cell_storage' ? '登记日期' : '捐赠日期', formDraft.registerDate, { action: 'pick-date', required: true })}
${formDraft.source === 'cell_storage' ? formCell('协议总金额', formDraft.amount, { input: true, key: 'amount', placeholder: '请输入金额' }) : ''}
${textareaCell('备注', formDraft.remark, 'remark')}
</section>
<section class="form-section">
<h3 class="section-title">采集信息</h3>
${formCell('采集地区', formDraft.region, { action: 'pick-region', required: true })}
${formCell('采集医院', formDraft.hospital, { action: hospitalDisabled ? '' : 'pick-hospital', required: true, disabled: hospitalDisabled, placeholder: hospitalDisabled ? '请先选择采集地区' : '请选择采集医院' })}
${formCell('采集时间', formDraft.collectTime, { action: 'pick-datetime' })}
</section>
<section class="form-section">
<h3 class="section-title">客户信息</h3>
${formCell('母亲姓名', formDraft.motherName, { input: true, key: 'motherName', required: true })}
${formCell('联系电话', formDraft.motherPhone, { input: true, key: 'motherPhone' })}
${formCell('证件号码', formDraft.idCard, { input: true, key: 'idCard' })}
${formCell('预产期', formDraft.expectedDeliveryDate, { action: 'pick-delivery-date' })}
</section>
<section class="form-section">
<h3 class="section-title">样本信息</h3>
${formCell('样本类型', formDraft.sampleType, { action: 'pick-sample-type', required: true })}
${formCell('样本编码', formDraft.sampleCode, { input: true, key: 'sampleCode' })}
${formDraft.source === 'cell_storage' ? formCell('存储年限', formDraft.storageYears, { input: true, key: 'storageYears' }) : ''}
<div class="choice-row"><button type="button" class="choice-chip" data-action="add-sample">+ 新增一笔样本</button></div>
</section>
<section class="form-section">
<h3 class="section-title">附件与确认</h3>
${attachmentArea(formDraft.attachments)}
<div class="choice-row"><button type="button" class="choice-chip active" data-action="toggle-confirm">✓ 我已核对以上信息</button></div>
</section>`
const footer = '<button class="bottom-btn" type="button" data-action="close-sheet">取消</button><button class="bottom-btn primary" type="button" data-action="save-protocol">确定</button>'
openSheet({ title, body, footer })
}
function protocolDetail(row, title = '协议详情') {
const body = `
<section class="detail-section"><h3 class="section-title">协议信息</h3><div class="card-body"><div class="field-grid">
${field('业务来源', tag(row.source === 'cell_storage' ? '细胞存储' : '科研捐赠'))}
${field('协议状态', tag(row.status, toneFor(row.status)))}
${field('协议编号', esc(row.protocolNo), 'span-2')}
${field('母亲姓名', esc(row.motherName))}
${field('样本类型', esc(row.sampleTypeName))}
</div></div></section>
<section class="detail-section"><h3 class="section-title">采集信息</h3><div class="card-body"><div class="field-grid">
${field('采集地区', esc(row.regionName))}${field('采集医院', esc(row.hospitalName))}${field('采集时间', esc(row.sampleCollectTime), 'span-2')}${field('市场专员', esc(row.salesUserName))}${field('地区经理', esc(row.managerName))}
</div></div></section>
<section class="detail-section"><h3 class="section-title">附件信息</h3>${attachmentArea(['协议扫描件.pdf', '知情同意书.jpg'], 'read-only')}</section>`
openSheet({ title, body, footer: '<button class="bottom-btn primary" type="button" data-action="close-sheet">返回</button>' })
}
function openPaymentDetail(row) {
const body = `
<div class="summary-grid">
<div class="summary-item"><label>合同额</label><strong>${money(row.contractAmount)}</strong></div>
<div class="summary-item"><label>已缴</label><strong class="highlight">${money(row.paidAmount)}</strong></div>
<div class="summary-item"><label>待缴</label><strong>${money(row.remainAmount)}</strong></div>
</div>
<section class="detail-section"><h3 class="section-title">客户与协议</h3><div class="card-body"><div class="field-grid">${field('协议号', esc(row.protocolNo), 'span-2')}${field('客户', esc(row.motherName))}${field('样本编码', esc(row.sampleCode))}${field('缴费方式', tag(row.payMode, toneFor(row.payMode)))}${field('缴费进度', tag(row.progress, toneFor(row.progress)))}</div></div></section>
<section class="detail-section"><h3 class="section-title">缴费计划</h3><div class="timeline">
<div class="timeline-row"><strong>第 1 期 · 已缴清</strong><p>应缴 2026-09-10 · ${money(Math.min(row.paidAmount, 8000))}</p></div>
<div class="timeline-row"><strong>第 2 期 · 待确认</strong><p>应缴 2026-10-10 · ${money(5200)}</p></div>
<div class="timeline-row"><strong>第 3 期 · 待缴费</strong><p>应缴 2026-11-10 · ${money(5200)}</p></div>
</div></section>
<section class="detail-section"><h3 class="section-title">转账明细</h3>
<div class="transfer-card"><div class="transfer-card-head"><strong>转账笔 1</strong>${tag('待财务确认', 'warning')}</div><p>转账方式:微信转账</p><p>转账金额:${money(5200)}</p><p>转账时间:2026-10-11 10:20</p><div class="mini-actions"><button type="button" data-action="edit-transfer">编辑</button><button type="button" data-action="submit-transfer">提交财务确认</button><button type="button" data-action="withdraw-transfer">撤回</button></div></div>
</section>`
openSheet({ title: '样本缴费详情', body, footer: '<button class="bottom-btn" type="button" data-action="close-sheet">返回</button><button class="bottom-btn primary" type="button" data-action="register-transfer">登记转账</button>' })
}
function openChaseTransfer(row) {
const body = `
<div class="summary-grid"><div class="summary-item"><label>应缴</label><strong>${money(row.dueAmount)}</strong></div><div class="summary-item"><label>实缴合计</label><strong class="highlight">${money(row.paidAmount)}</strong></div><div class="summary-item"><label>欠缴</label><strong>${money(row.arrearsAmount)}</strong></div></div>
<section class="detail-section"><h3 class="section-title">转账明细</h3><div class="transfer-card"><div class="transfer-card-head"><strong>微信转账</strong>${tag('已确认')}</div><p>转账金额:${money(row.paidAmount)}</p><p>转账时间:2026-10-06 15:35</p><p>财务备注:凭证清晰,到账无误</p></div></section>`
openSheet({ title: `${row.motherName} · 第 ${row.scheduleNo} 期转账记录`, body, footer: '<button class="bottom-btn primary" type="button" data-action="close-sheet">返回</button>' })
}
function openTransferForm() {
formDraft = { kind: 'transfer', amount: '5200', time: '2026-10-11 10:20', type: '微信转账', remark: '', attachments: ['转账凭证.jpg'] }
renderTransferForm()
}
function renderTransferForm() {
const body = `<section class="form-section"><h3 class="section-title">转账信息</h3>${formCell('转账金额', formDraft.amount, { input: true, key: 'amount', required: true })}${formCell('转账时间', formDraft.time, { action: 'pick-transfer-time' })}${formCell('转账方式', formDraft.type, { action: 'pick-transfer-type', required: true })}${textareaCell('备注', formDraft.remark, 'remark')}</section><section class="form-section"><h3 class="section-title">转账凭证</h3>${attachmentArea(formDraft.attachments, 'transfer')}</section>`
openSheet({ title: '登记转账', body, footer: '<button class="bottom-btn" type="button" data-action="close-sheet">取消</button><button class="bottom-btn primary" type="button" data-action="save-transfer">保存</button>' })
}
function openSampleDetail(row, qcOnly = false) {
const body = qcOnly
? `<section class="detail-section"><h3 class="section-title">储存证质检信息</h3><div class="timeline"><div class="timeline-row"><strong>细胞活率检测 · 通过</strong><p>检测值 96.8% · 2026-10-10 14:30</p></div><div class="timeline-row"><strong>微生物检测 · 通过</strong><p>无菌、支原体阴性 · 2026-10-10 16:20</p></div><div class="timeline-row"><strong>质检结论 · 合格</strong><p>可办理储存证</p></div></div></section>`
: `<section class="detail-section"><h3 class="section-title">样本信息</h3><div class="card-body"><div class="field-grid">${field('母亲姓名', esc(row.motherName))}${field('新生儿姓名', esc(row.newbornName))}${field('样本编码', esc(row.sampleCode), 'span-2')}${field('实验室入库编码', esc(row.labStorageCode), 'span-2')}${field('样本类型', esc(row.sampleTypeName), 'span-2')}</div><div class="status-row">${tag(`采集 · ${row.collection}`, toneFor(row.collection))}${tag(`接收 · ${row.reception}`, toneFor(row.reception))}${tag(`质检 · ${row.qc}`, toneFor(row.qc))}${tag(`入库 · ${row.storage}`, toneFor(row.storage))}</div></div></section>`
openSheet({ title: qcOnly ? '储存证质检信息' : '样本详情', body, footer: '<button class="bottom-btn primary" type="button" data-action="close-sheet">返回</button>' })
}
function openHandleForm(row, kind) {
formDraft = {
kind: 'handle',
rowId: row.id,
handleKind: kind,
time: '2026-10-11 14:30',
user: state.role === 'specialist' ? '王敏' : '赵经理',
remark: '',
attachments: []
}
renderHandleForm()
}
function renderHandleForm() {
const label = formDraft.handleKind === 'cert' ? '储存证' : '保险'
const body = `<section class="form-section"><h3 class="section-title">${label}办理信息</h3>${formCell('办理时间', formDraft.time, { action: 'pick-handle-time', required: true })}${formCell('办理人员', formDraft.user, { action: 'pick-handler', required: true })}${textareaCell('备注', formDraft.remark, 'remark')}</section><section class="form-section"><h3 class="section-title">${label}附件</h3>${attachmentArea(formDraft.attachments, 'handle')}</section>`
openSheet({ title: `登记${label}`, body, footer: `<button class="bottom-btn" type="button" data-action="close-sheet">取消</button><button class="bottom-btn primary" type="button" data-action="save-handle" data-row-id="${formDraft.rowId}">保存</button>` })
}
function openFreightForm(row = null) {
formDraft = { kind: 'freight', id: row?.id || 'new', transportMode: row?.transportMode || '', amount: row ? String(row.freightAmount) : '', selected: row ? [row.sampleCode] : [] }
renderFreightForm()
}
function renderFreightForm() {
const available = state.role === 'specialist' ? demoData.sample.slice(0, 3) : demoData.sample.slice(0, 5)
const body = `<section class="form-section"><h3 class="section-title">运费批次</h3>${formCell('运输方式', formDraft.transportMode, { action: 'pick-transport', required: true })}${formCell('运费金额', formDraft.amount, { input: true, key: 'amount', required: true })}</section><section class="form-section"><h3 class="section-title">关联样本</h3><div class="timeline">${available.map((item) => `<button type="button" class="timeline-row" data-action="toggle-freight-sample" data-sample="${item.sampleCode}" style="border:1px solid ${formDraft.selected.includes(item.sampleCode) ? 'var(--brand)' : 'transparent'};text-align:left"><strong>${formDraft.selected.includes(item.sampleCode) ? '✓ ' : ''}${item.motherName} · ${item.sampleCode}</strong><p>${item.sampleTypeName}</p></button>`).join('')}</div></section>`
openSheet({ title: formDraft.id === 'new' ? '新增运费' : '编辑运费', body, footer: '<button class="bottom-btn" type="button" data-action="close-sheet">取消</button><button class="bottom-btn primary" type="button" data-action="save-freight">保存</button>' })
}
function openFreightRelation(row, unlink = false) {
const items = state.role === 'specialist' ? demoData.sample.slice(0, 2) : demoData.sample.slice(0, 5)
const body = `<section class="detail-section"><h3 class="section-title">${unlink ? '选择要取消关联的样本' : '选择要关联的样本'}</h3><div class="timeline">${items.map((item, index) => `<button type="button" class="timeline-row" data-action="toggle-relation" style="border:1px solid ${index === 0 ? 'var(--brand)' : 'transparent'};text-align:left"><strong>${index === 0 ? '✓ ' : ''}${item.motherName} · ${item.sampleCode}</strong><p>${item.sampleTypeName}</p></button>`).join('')}</div>${state.role === 'specialist' ? '<div class="privacy-note" style="margin:0 14px 14px">仅提供当前市场专员自己的样本;其他专员明细不可选择。</div>' : ''}</section>`
openSheet({ title: unlink ? '取消关联' : '关联样本', body, footer: `<button class="bottom-btn" type="button" data-action="close-sheet">取消</button><button class="bottom-btn primary" type="button" data-action="save-relation">${unlink ? '确认取消' : '确认关联'}</button>` })
}
function openGenericProtocolFromRelated(row) {
const protocol = demoData.protocol.find((item) => item.motherName === row.motherName) || demoData.protocol[0]
protocolDetail(protocol, '关联协议单')
}
function findRow(moduleKey, id) {
return demoData[moduleKey].find((item) => item.id === id)
}
function handleAction(actionName, source) {
const id = source.dataset.id
if (actionName === 'clear-search') {
state.keyword = ''
state.visibleCount = state.pageSize
renderList()
return
}
if (actionName === 'close-sheet') return closeSheet()
if (actionName === 'close-float') {
el('floatLayer').innerHTML = ''
return
}
if (actionName === 'confirm-ok') {
const callback = confirmCallback
el('floatLayer').innerHTML = ''
if (callback) callback()
return
}
if (actionName === 'view-protocol') return protocolDetail(findRow('protocol', id))
if (actionName === 'edit-protocol') {
formDraft = null
return openProtocolForm(findRow('protocol', id))
}
if (actionName === 'audit-protocol') {
const row = findRow('protocol', id)
return confirmAction(`确认审核通过 ${row.motherName} 的协议单 ${row.protocolNo} 吗?`, () => showToast('审核已通过'))
}
if (actionName === 'delete-protocol') {
const row = findRow('protocol', id)
return confirmAction(`删除后该协议单将进入回收状态,确认删除 ${row.protocolNo} 吗?`, () => showToast('协议单已删除(Demo)'))
}
if (actionName === 'view-chase-transfer') return openChaseTransfer(findRow('chase', id))
if (actionName === 'view-related-protocol') return openGenericProtocolFromRelated(findRow(state.module, id))
if (actionName === 'view-payment') return openPaymentDetail(findRow('payment', id))
if (actionName === 'confirm-payment') return confirmAction('确认当前转账凭证与到账金额无误吗?', () => showToast('缴费确认成功'))
if (actionName === 'view-sample') return openSampleDetail(findRow('sample', id))
if (actionName === 'view-storage-qc') return openSampleDetail(findRow('sample', id), true)
if (actionName === 'register-cert') return openHandleForm(findRow('sample', id), 'cert')
if (actionName === 'register-insurance') return openHandleForm(findRow('sample', id), 'insurance')
if (actionName === 'edit-freight') return openFreightForm(findRow('freight', id))
if (actionName === 'link-freight') return openFreightRelation(findRow('freight', id), false)
if (actionName === 'unlink-freight') return openFreightRelation(findRow('freight', id), true)
if (actionName === 'source-cell' || actionName === 'source-donation') {
syncDraftInputs()
formDraft.source = actionName === 'source-cell' ? 'cell_storage' : 'research_donation'
return openProtocolForm(formDraft.id === 'new' ? null : findRow('protocol', formDraft.id))
}
if (actionName === 'pick-region') {
syncDraftInputs()
return openPicker('选择采集地区', Object.keys(regions), (value) => {
formDraft.region = value
formDraft.hospital = ''
openProtocolForm(formDraft.id === 'new' ? null : findRow('protocol', formDraft.id))
})
}
if (actionName === 'pick-hospital') {
syncDraftInputs()
return openPicker('选择采集医院', regions[formDraft.region] || [], (value) => {
formDraft.hospital = value
openProtocolForm(formDraft.id === 'new' ? null : findRow('protocol', formDraft.id))
})
}
if (actionName === 'pick-sample-type') {
syncDraftInputs()
return openPicker('选择样本类型', ['脐带血造血干细胞', '脐带间充质干细胞', '胎盘间充质干细胞'], (value) => {
formDraft.sampleType = value
openProtocolForm(formDraft.id === 'new' ? null : findRow('protocol', formDraft.id))
})
}
if (['pick-date', 'pick-delivery-date', 'pick-datetime'].includes(actionName)) {
syncDraftInputs()
const values = actionName === 'pick-datetime' ? ['2026-10-11 09:20', '2026-10-11 14:30', '2026-10-12 08:00'] : ['2026-10-11', '2026-10-12', '2026-11-01']
return openPicker('选择日期时间', values, (value) => {
if (actionName === 'pick-date') formDraft.registerDate = value
if (actionName === 'pick-delivery-date') formDraft.expectedDeliveryDate = value
if (actionName === 'pick-datetime') formDraft.collectTime = value
openProtocolForm(formDraft.id === 'new' ? null : findRow('protocol', formDraft.id))
})
}
if (actionName === 'add-attachment') {
syncDraftInputs()
formDraft.attachments.push(`新上传附件-${formDraft.attachments.length + 1}.jpg`)
if (formDraft.kind === 'protocol') openProtocolForm(formDraft.id === 'new' ? null : findRow('protocol', formDraft.id))
else if (formDraft.kind === 'transfer') renderTransferForm()
else renderHandleForm()
return showToast('附件上传成功')
}
if (actionName === 'remove-attachment') {
syncDraftInputs()
formDraft.attachments.splice(Number(source.dataset.index), 1)
if (formDraft.kind === 'protocol') openProtocolForm(formDraft.id === 'new' ? null : findRow('protocol', formDraft.id))
else if (formDraft.kind === 'transfer') renderTransferForm()
else renderHandleForm()
return showToast('附件已移除')
}
if (actionName === 'add-sample') return showToast('已新增一笔样本卡片(Demo)')
if (actionName === 'toggle-confirm') return showToast('已确认核对信息')
if (actionName === 'save-protocol') {
syncDraftInputs()
if (!formDraft.motherName || !formDraft.region || !formDraft.hospital || !formDraft.sampleType) return showToast('请完善必填信息')
closeSheet()
return showToast('协议单保存成功')
}
if (actionName === 'register-transfer' || actionName === 'edit-transfer') return openTransferForm()
if (actionName === 'pick-transfer-type') {
syncDraftInputs()
return openPicker('选择转账方式', ['微信转账', '支付宝转账', '银行转账', '现金'], (value) => {
formDraft.type = value
renderTransferForm()
})
}
if (actionName === 'pick-transfer-time') {
syncDraftInputs()
return openPicker('选择转账时间', ['2026-10-11 10:20', '2026-10-11 14:30'], (value) => {
formDraft.time = value
renderTransferForm()
})
}
if (actionName === 'save-transfer') {
closeSheet()
return showToast('转账笔保存成功')
}
if (actionName === 'submit-transfer') return confirmAction('提交后将由财务人员确认到账,是否继续?', () => showToast('已提交财务确认'))
if (actionName === 'withdraw-transfer') return confirmAction('确认撤回该转账笔吗?', () => showToast('转账笔已撤回'))
if (actionName === 'pick-handle-time') return openPicker('选择办理时间', ['2026-10-11 14:30', '2026-10-12 09:00'], (value) => {
formDraft.time = value
renderHandleForm()
})
if (actionName === 'pick-handler') return openPicker('选择办理人员', ['王敏', '张琳', '赵经理'], (value) => {
formDraft.user = value
renderHandleForm()
})
if (actionName === 'save-handle') {
closeSheet()
return showToast(`登记${formDraft.handleKind === 'cert' ? '储存证' : '保险'}成功`)
}
if (actionName === 'pick-transport') {
syncDraftInputs()
return openPicker('选择运输方式', ['冷链配送', '专员送达', '客户自送'], (value) => {
formDraft.transportMode = value
renderFreightForm()
})
}
if (actionName === 'toggle-freight-sample') {
syncDraftInputs()
const sample = source.dataset.sample
formDraft.selected = formDraft.selected.includes(sample) ? formDraft.selected.filter((item) => item !== sample) : [...formDraft.selected, sample]
return renderFreightForm()
}
if (actionName === 'save-freight') {
syncDraftInputs()
if (!formDraft.transportMode || !formDraft.amount) return showToast('请选择运输方式并填写运费金额')
closeSheet()
return showToast('运费信息保存成功')
}
if (actionName === 'toggle-relation') {
source.style.borderColor = source.style.borderColor === 'var(--brand)' ? 'transparent' : 'var(--brand)'
const strong = source.querySelector('strong')
strong.textContent = strong.textContent.startsWith('✓ ') ? strong.textContent.slice(2) : `✓ ${strong.textContent}`
}
if (actionName === 'save-relation') {
closeSheet()
return showToast('关联关系已更新')
}
}
el('roleSwitch').addEventListener('click', (event) => {
const button = event.target.closest('[data-role]')
if (!button) return
state.role = button.dataset.role
state.visibleCount = state.pageSize
state.keyword = ''
renderApp()
})
el('moduleSwitch').addEventListener('click', (event) => {
const button = event.target.closest('[data-module]')
if (!button) return
state.module = button.dataset.module
state.visibleCount = state.pageSize
state.keyword = ''
el('mobileBody').scrollTop = 0
renderApp()
})
el('mobileBody').addEventListener('click', (event) => {
const target = event.target.closest('[data-action]')
if (target) handleAction(target.dataset.action, target)
})
el('mobileBody').addEventListener('scroll', () => {
const body = el('mobileBody')
if (body.scrollTop + body.clientHeight >= body.scrollHeight - 80) loadMore()
})
el('overlayRoot').addEventListener('click', (event) => {
const target = event.target.closest('[data-action]')
if (target) handleAction(target.dataset.action, target)
})
el('floatLayer').addEventListener('click', (event) => {
const option = event.target.closest('[data-picker-index]')
if (option) {
const values = [...event.currentTarget.querySelectorAll('[data-picker-index]')].map((node) => node.textContent)
const value = values[Number(option.dataset.pickerIndex)]
const callback = pickerCallback
el('floatLayer').innerHTML = ''
if (callback) callback(value)
return
}
const target = event.target.closest('[data-action]')
if (target) handleAction(target.dataset.action, target)
})
el('fab').addEventListener('click', () => {
if (state.module === 'protocol') {
formDraft = null
openProtocolForm()
}
if (state.module === 'freight') openFreightForm()
})
renderApp()
</script>
</body>
</html>