docs: add collection region staff binding design and demo

Capture the approved design for region–user binding with multi-leader support, plus an interactive Fresh Mint HTML demo for UI review.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-09-18 11:09:59 +08:00
1 parent 679285dcc6
commit e24b3dbadf
2 files changed
+1116

No files matched your search

@@ -0,0 +1,931 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>采集地区 · 人员绑定 Demo</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<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: #f8fafc;
--surface: #ffffff;
--table-head: #f8fafc;
--row-alt: #fafafa;
--row-hover: #f1f5f9;
--danger: #dc2626;
--warning: #d97706;
--radius: 10px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: Inter, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--text);
background: var(--bg-page);
}
.page {
max-width: 1180px;
margin: 0 auto;
padding: 24px;
}
.demo-banner {
margin-bottom: 16px;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #a7f3d0;
background: var(--primary-bg);
color: var(--primary-dark);
font-size: 13px;
}
.demo-banner strong { font-weight: 700; }
h1 {
margin: 0 0 6px;
font-size: 20px;
font-weight: 700;
}
.page-desc {
margin: 0 0 20px;
color: var(--text-muted);
font-size: 13px;
}
.variant-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.variant-tab {
border: 1px solid var(--border);
background: #fff;
border-radius: 8px;
padding: 8px 14px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
}
.variant-tab.active {
background: var(--primary-bg);
border-color: #a7f3d0;
color: var(--primary-dark);
}
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
}
.panel-top {
padding: 16px 20px 12px;
border-bottom: 1px solid var(--border);
}
.title-bar-heading {
margin: 0;
font-size: 16px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.title-bar-heading::before {
content: "";
width: 3px;
height: 16px;
border-radius: 2px;
background: var(--primary);
}
.filter-block {
padding: 16px 20px 8px;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px 16px;
}
.field label {
display: block;
font-size: 11px;
color: var(--text-muted);
margin-bottom: 4px;
}
.field input, .field select {
width: 100%;
height: 36px;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0 10px;
font: inherit;
color: var(--text);
background: #fff;
}
.filter-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.list-toolbar {
padding: 12px 20px;
border-top: 1px solid var(--border);
}
.list-toolbar-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.sel-label { color: var(--text-muted); font-size: 13px; }
.sel-num { color: var(--primary-dark); font-weight: 700; }
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 14px;
border-radius: 8px;
border: 1px solid var(--border);
background: #fff;
color: var(--text);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.btn:hover { background: var(--row-hover); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-main {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.btn-main:hover { background: var(--primary-dark); }
.btn-ghost { background: #fff; }
.btn-danger { color: var(--danger); border-color: #fecaca; }
.btn-sm {
height: 28px;
padding: 0 10px;
font-size: 12px;
font-weight: 600;
}
.btn-op {
height: 28px;
padding: 0 10px;
font-size: 12px;
color: var(--primary-dark);
border-color: #a7f3d0;
background: var(--primary-bg);
}
.btn-op.neutral {
color: var(--text);
border-color: var(--border);
background: #fff;
}
.list-body { border-top: 1px solid var(--border); }
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
text-align: center;
vertical-align: middle;
font-size: 13px;
}
th {
background: var(--table-head);
color: var(--text-muted);
font-weight: 600;
font-size: 12px;
}
tbody tr:nth-child(even) { background: var(--row-alt); }
tbody tr:hover { background: var(--row-hover); }
td.left { text-align: left; }
.muted { color: var(--text-muted); }
.stack-lines { display: flex; flex-direction: column; gap: 2px; align-items: flex-start; }
.ops { display: flex; justify-content: center; gap: 8px; flex-wrap: wrap; }
.tag {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.tag-success { background: var(--primary-bg); color: var(--primary-dark); }
.tag-warning { background: #fff7ed; color: var(--warning); }
.tag-info { background: #f1f5f9; color: var(--text-muted); }
.tag-muted { background: #f8fafc; color: var(--text-muted); border: 1px solid var(--border); }
.pager {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid var(--border);
color: var(--text-muted);
font-size: 13px;
}
/* Modal */
.mask {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
display: none;
align-items: center;
justify-content: center;
padding: 16px;
z-index: 50;
backdrop-filter: none;
}
.mask.open { display: flex; }
.dialog {
width: min(860px, 100%);
max-height: calc(100vh - 100px);
background: #fff;
border-radius: 12px;
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.18);
display: flex;
flex-direction: column;
overflow: hidden;
}
.dialog-hd {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.dialog-title { font-size: 16px; font-weight: 700; }
.dialog-close {
width: 32px; height: 32px;
border: none; background: transparent;
border-radius: 8px; cursor: pointer;
color: var(--text-muted); font-size: 18px;
}
.dialog-close:hover { background: var(--row-hover); }
.dialog-bd {
padding: 16px 20px;
overflow: auto;
flex: 1;
}
.dialog-ft {
padding: 12px 20px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 8px;
}
.dialog-meta {
margin-bottom: 12px;
color: var(--text-muted);
font-size: 13px;
}
.picker-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
margin-bottom: 12px;
align-items: end;
}
.picker-row .field { margin: 0; }
.leader-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
font-size: 13px;
}
.leader-toggle input { accent-color: var(--primary); width: 16px; height: 16px; }
.note-box {
margin-top: 16px;
padding: 12px 14px;
border-radius: 8px;
background: #f8fafc;
border: 1px dashed var(--border);
color: var(--text-muted);
font-size: 12px;
}
.note-box code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
background: #fff;
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 4px;
}
.approach-card {
display: none;
}
.approach-card.active { display: block; }
.split-layout {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 16px;
}
@media (max-width: 900px) {
.split-layout { grid-template-columns: 1fr; }
.filter-grid { grid-template-columns: 1fr; }
}
.mini-panel {
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
.mini-panel-hd {
padding: 10px 12px;
background: var(--table-head);
font-weight: 700;
font-size: 13px;
border-bottom: 1px solid var(--border);
}
.chip-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px;
min-height: 64px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 8px 0 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: #fff;
font-size: 12px;
}
.chip.leader {
border-color: #a7f3d0;
background: var(--primary-bg);
color: var(--primary-dark);
font-weight: 600;
}
.chip button {
border: none;
background: transparent;
cursor: pointer;
color: var(--text-muted);
padding: 0 2px;
font-size: 14px;
line-height: 1;
}
.chip button:hover { color: var(--danger); }
.toast {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: #0f172a;
color: #fff;
padding: 10px 16px;
border-radius: 8px;
font-size: 13px;
z-index: 100;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
.toast.show { opacity: 1; }
</style>
</head>
<body>
<div class="page">
<div class="demo-banner">
<strong>交互 Demo(非最终实现)</strong>:展示「采集地区 ↔ 系统人员」绑定,以及将部分人员设为<strong>地区负责人</strong>。可切换两种交互方案对比。
</div>
<h1>采集地区 · 人员绑定</h1>
<p class="page-desc">
页面:细胞采集基础信息维护 → 采集地区及医院。本 Demo 在现有「地区医院配置」旁增加「地区人员配置」。
</p>
<div class="variant-tabs">
<button class="variant-tab active" data-variant="a" type="button">方案 A:独立配置弹窗(推荐)</button>
<button class="variant-tab" data-variant="b" type="button">方案 B:主表直接展示 + 行内设置负责人</button>
</div>
<!-- ========== Approach A ========== -->
<div class="approach-card active" id="approach-a">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">采集地区及医院</h2>
</div>
<div class="filter-block">
<div class="filter-grid">
<div class="field">
<label>关键词</label>
<input type="text" placeholder="地区名称" value="" />
</div>
<div class="field">
<label>负责人</label>
<input type="text" placeholder="可选,按负责人姓名筛" />
</div>
<div class="field">
<label>绑定人员</label>
<input type="text" placeholder="可选,按工作人员姓名筛" />
</div>
</div>
<div class="filter-actions">
<button class="btn btn-ghost" type="button">重置</button>
<button class="btn btn-main" type="button">搜索</button>
</div>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="sel-label">已选 <span class="sel-num">0</span> 项</div>
<button class="btn btn-main" type="button">+ 新增地区</button>
<button class="btn btn-ghost btn-danger" type="button" disabled>批量删除</button>
</div>
</div>
<div class="list-body">
<table>
<thead>
<tr>
<th style="width:48px"><input type="checkbox" disabled /></th>
<th style="width:64px">序号</th>
<th>地区名称</th>
<th class="left">医院信息</th>
<th class="left">工作人员</th>
<th>负责人</th>
<th>创建时间</th>
<th style="min-width:280px">操作</th>
</tr>
</thead>
<tbody id="region-tbody-a"></tbody>
</table>
<div class="pager">共 3 条 · 10 条/页 · 1</div>
</div>
</section>
<div class="note-box">
方案 A:与现有「地区医院配置」对称 —— 操作列增加「地区人员配置」弹窗;主列表用摘要列展示工作人员数 / 负责人 Tag。
数据建议:关联表 <code>biz_collection_region_staff</code>(region_id + user_id + is_leader)。
</div>
</div>
<!-- ========== Approach B ========== -->
<div class="approach-card" id="approach-b">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">采集地区及医院</h2>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="sel-label">已选 <span class="sel-num">0</span> 项</div>
<button class="btn btn-main" type="button">+ 新增地区</button>
</div>
</div>
<div class="list-body">
<table>
<thead>
<tr>
<th>地区</th>
<th class="left">已绑定人员(点击星标设为负责人)</th>
<th style="min-width:160px">操作</th>
</tr>
</thead>
<tbody id="region-tbody-b"></tbody>
</table>
</div>
</section>
<div class="note-box">
方案 B:主表行内直接展示人员 Chip,星标切换负责人;适合人员少、频繁调整的场景,但主表会变「胖」。
</div>
</div>
</div>
<!-- Staff config dialog (Approach A) -->
<div class="mask" id="staff-mask" role="dialog" aria-modal="true">
<div class="dialog">
<div class="dialog-hd">
<div class="dialog-title">地区人员配置</div>
<button class="dialog-close" type="button" id="staff-close" aria-label="关闭">×</button>
</div>
<div class="dialog-bd">
<div class="dialog-meta">当前地区:<strong id="staff-region-name">—</strong></div>
<div class="picker-row">
<div class="field">
<label>添加系统人员</label>
<select id="staff-picker">
<option value="">请选择用户(演示数据)</option>
</select>
</div>
<button class="btn btn-main" type="button" id="staff-add">+ 添加</button>
</div>
<div class="list-toolbar" style="padding:0 0 10px; border:none">
<div class="list-toolbar-row">
<div class="sel-label">已绑定 <span class="sel-num" id="staff-count">0</span> 人 · 负责人 <span class="sel-num" id="leader-count">0</span> 人</div>
</div>
</div>
<div class="list-body" style="border:1px solid var(--border); border-radius:10px; overflow:hidden">
<table>
<thead>
<tr>
<th style="width:56px">序号</th>
<th class="left">姓名</th>
<th>工号</th>
<th>部门</th>
<th>角色</th>
<th>是否负责人</th>
<th style="min-width:100px">操作</th>
</tr>
</thead>
<tbody id="staff-tbody"></tbody>
</table>
</div>
<div class="note-box" style="margin-top:12px">
Demo 约定:同一地区可绑定多名工作人员;负责人可为 <strong>0~多人</strong>(勾选「是否负责人」)。保存后写入关联表,不修改 <code>sys_user</code>。
</div>
</div>
<div class="dialog-ft">
<button class="btn btn-ghost" type="button" id="staff-cancel">取消</button>
<button class="btn btn-main" type="button" id="staff-save">✓ 确定</button>
</div>
</div>
</div>
<!-- Nested: quick view of approach B detail -->
<div class="mask" id="b-mask">
<div class="dialog" style="width:min(720px,100%)">
<div class="dialog-hd">
<div class="dialog-title">人员绑定(方案 B 弹窗备选)</div>
<button class="dialog-close" type="button" id="b-close">×</button>
</div>
<div class="dialog-bd">
<div class="split-layout">
<div class="mini-panel">
<div class="mini-panel-hd">候选人员</div>
<div class="chip-list" id="b-candidates"></div>
</div>
<div class="mini-panel">
<div class="mini-panel-hd">已绑定(绿底 = 负责人)</div>
<div class="chip-list" id="b-bound"></div>
</div>
</div>
</div>
<div class="dialog-ft">
<button class="btn btn-ghost" type="button" id="b-cancel">取消</button>
<button class="btn btn-main" type="button" id="b-save">✓ 确定</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const ALL_USERS = [
{ id: 'u1', name: '张敏', userNo: 'SQ001', dept: '市场一部', role: '采集专员' },
{ id: 'u2', name: '李强', userNo: 'SQ002', dept: '市场一部', role: '采集专员' },
{ id: 'u3', name: '王芳', userNo: 'SQ003', dept: '市场二部', role: '销售' },
{ id: 'u4', name: '陈静', userNo: 'SQ004', dept: '市场支持', role: '市场支持专员' },
{ id: 'u5', name: '赵磊', userNo: 'SQ005', dept: '市场二部', role: '采集专员' },
{ id: 'u6', name: '周婷', userNo: 'SQ006', dept: '市场一部', role: '销售' },
];
const regions = [
{
id: 'r1',
name: '合肥市区',
hospitals: ['安徽省立医院', '安医大一附院'],
createTime: '2026-03-12 10:20:00',
staff: [
{ userId: 'u1', isLeader: true },
{ userId: 'u2', isLeader: false },
{ userId: 'u6', isLeader: false },
],
},
{
id: 'r2',
name: '芜湖市',
hospitals: ['皖南医学院弋矶山医院'],
createTime: '2026-04-02 14:08:00',
staff: [
{ userId: 'u3', isLeader: true },
{ userId: 'u5', isLeader: true },
],
},
{
id: 'r3',
name: '阜阳市',
hospitals: [],
createTime: '2026-05-18 09:41:00',
staff: [],
},
];
let editingRegionId = null;
let draftStaff = [];
function userById(id) {
return ALL_USERS.find((u) => u.id === id);
}
function toast(msg) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 1800);
}
function staffSummary(staff) {
const people = staff.map((s) => userById(s.userId)).filter(Boolean);
const leaders = staff.filter((s) => s.isLeader).map((s) => userById(s.userId)).filter(Boolean);
return { people, leaders };
}
function renderApproachA() {
const tbody = document.getElementById('region-tbody-a');
tbody.innerHTML = regions.map((r, idx) => {
const { people, leaders } = staffSummary(r.staff);
const hospitalHtml = r.hospitals.length
? `<div class="stack-lines">${r.hospitals.map((h) => `<span>${h}</span>`).join('')}</div>`
: '<span class="muted">—</span>';
const staffHtml = people.length
? `<div class="stack-lines">${people.map((p) => `<span>${p.name}<span class="muted">(${p.userNo})</span></span>`).join('')}</div>`
: '<span class="muted">未绑定</span>';
const leaderHtml = leaders.length
? leaders.map((l) => `<span class="tag tag-success">${l.name}</span>`).join(' ')
: '<span class="tag tag-info">未设置</span>';
return `
<tr>
<td><input type="checkbox" /></td>
<td>${idx + 1}</td>
<td><strong>${r.name}</strong></td>
<td class="left">${hospitalHtml}</td>
<td class="left">${staffHtml}</td>
<td>${leaderHtml}</td>
<td>${r.createTime}</td>
<td>
<div class="ops">
<button class="btn btn-op" type="button" data-open-hospital="${r.id}">地区医院配置</button>
<button class="btn btn-op" type="button" data-open-staff="${r.id}">地区人员配置</button>
<button class="btn btn-op neutral btn-sm" type="button">⋯</button>
</div>
</td>
</tr>`;
}).join('');
tbody.querySelectorAll('[data-open-staff]').forEach((btn) => {
btn.addEventListener('click', () => openStaffDialog(btn.getAttribute('data-open-staff')));
});
tbody.querySelectorAll('[data-open-hospital]').forEach((btn) => {
btn.addEventListener('click', () => toast('医院配置沿用现有弹窗(本 Demo 不展开)'));
});
}
function renderApproachB() {
const tbody = document.getElementById('region-tbody-b');
tbody.innerHTML = regions.map((r) => {
const chips = r.staff.length
? r.staff.map((s) => {
const u = userById(s.userId);
if (!u) return '';
const star = s.isLeader ? '★' : '☆';
return `<span class="chip ${s.isLeader ? 'leader' : ''}">
<button type="button" title="切换负责人" data-toggle-leader="${r.id}:${s.userId}">${star}</button>
${u.name}
<button type="button" title="移除" data-remove="${r.id}:${s.userId}">×</button>
</span>`;
}).join('')
: '<span class="muted">暂无人员,请点击「绑定人员」</span>';
return `
<tr>
<td><strong>${r.name}</strong></td>
<td class="left"><div class="chip-list" style="padding:0">${chips}</div></td>
<td>
<div class="ops">
<button class="btn btn-op" type="button" data-open-b="${r.id}">绑定人员</button>
</div>
</td>
</tr>`;
}).join('');
tbody.querySelectorAll('[data-toggle-leader]').forEach((btn) => {
btn.addEventListener('click', () => {
const [rid, uid] = btn.getAttribute('data-toggle-leader').split(':');
const region = regions.find((x) => x.id === rid);
const row = region.staff.find((s) => s.userId === uid);
row.isLeader = !row.isLeader;
renderApproachB();
renderApproachA();
toast(row.isLeader ? '已设为负责人' : '已取消负责人');
});
});
tbody.querySelectorAll('[data-remove]').forEach((btn) => {
btn.addEventListener('click', () => {
const [rid, uid] = btn.getAttribute('data-remove').split(':');
const region = regions.find((x) => x.id === rid);
region.staff = region.staff.filter((s) => s.userId !== uid);
renderApproachB();
renderApproachA();
toast('已移除人员');
});
});
tbody.querySelectorAll('[data-open-b]').forEach((btn) => {
btn.addEventListener('click', () => openBDialog(btn.getAttribute('data-open-b')));
});
}
function fillPicker() {
const sel = document.getElementById('staff-picker');
const bound = new Set(draftStaff.map((s) => s.userId));
sel.innerHTML = '<option value="">请选择用户(演示数据)</option>' +
ALL_USERS.filter((u) => !bound.has(u.id))
.map((u) => `<option value="${u.id}">${u.name}(${u.userNo})· ${u.dept}</option>`)
.join('');
}
function renderStaffTable() {
const tbody = document.getElementById('staff-tbody');
document.getElementById('staff-count').textContent = String(draftStaff.length);
document.getElementById('leader-count').textContent = String(draftStaff.filter((s) => s.isLeader).length);
if (!draftStaff.length) {
tbody.innerHTML = `<tr><td colspan="7" class="muted">暂无绑定人员,请从上方下拉添加</td></tr>`;
fillPicker();
return;
}
tbody.innerHTML = draftStaff.map((s, idx) => {
const u = userById(s.userId);
return `
<tr>
<td>${idx + 1}</td>
<td class="left"><strong>${u.name}</strong></td>
<td>${u.userNo}</td>
<td>${u.dept}</td>
<td><span class="tag tag-muted">${u.role}</span></td>
<td>
<label class="leader-toggle">
<input type="checkbox" data-leader="${s.userId}" ${s.isLeader ? 'checked' : ''} />
设为负责人
</label>
</td>
<td>
<button class="btn btn-sm btn-danger" type="button" data-unbind="${s.userId}">移除</button>
</td>
</tr>`;
}).join('');
tbody.querySelectorAll('[data-leader]').forEach((input) => {
input.addEventListener('change', () => {
const uid = input.getAttribute('data-leader');
const row = draftStaff.find((s) => s.userId === uid);
row.isLeader = input.checked;
document.getElementById('leader-count').textContent = String(draftStaff.filter((s) => s.isLeader).length);
});
});
tbody.querySelectorAll('[data-unbind]').forEach((btn) => {
btn.addEventListener('click', () => {
const uid = btn.getAttribute('data-unbind');
draftStaff = draftStaff.filter((s) => s.userId !== uid);
renderStaffTable();
});
});
fillPicker();
}
function openStaffDialog(regionId) {
const region = regions.find((r) => r.id === regionId);
editingRegionId = regionId;
draftStaff = region.staff.map((s) => ({ ...s }));
document.getElementById('staff-region-name').textContent = region.name;
renderStaffTable();
document.getElementById('staff-mask').classList.add('open');
}
function closeStaffDialog() {
document.getElementById('staff-mask').classList.remove('open');
editingRegionId = null;
}
function openBDialog(regionId) {
editingRegionId = regionId;
draftStaff = regions.find((r) => r.id === regionId).staff.map((s) => ({ ...s }));
const boundIds = new Set(draftStaff.map((s) => s.userId));
document.getElementById('b-candidates').innerHTML = ALL_USERS.filter((u) => !boundIds.has(u.id))
.map((u) => `<button class="btn btn-sm" type="button" data-add="${u.id}">+ ${u.name}</button>`)
.join('') || '<span class="muted">无可选人员</span>';
document.getElementById('b-bound').innerHTML = draftStaff.map((s) => {
const u = userById(s.userId);
return `<span class="chip ${s.isLeader ? 'leader' : ''}">
<button type="button" data-star="${s.userId}">${s.isLeader ? '★' : '☆'}</button>
${u.name}
<button type="button" data-rm="${s.userId}">×</button>
</span>`;
}).join('') || '<span class="muted">尚未绑定</span>';
document.getElementById('b-candidates').querySelectorAll('[data-add]').forEach((btn) => {
btn.addEventListener('click', () => {
draftStaff.push({ userId: btn.getAttribute('data-add'), isLeader: false });
openBDialog(regionId);
});
});
document.getElementById('b-bound').querySelectorAll('[data-star]').forEach((btn) => {
btn.addEventListener('click', () => {
const row = draftStaff.find((s) => s.userId === btn.getAttribute('data-star'));
row.isLeader = !row.isLeader;
openBDialog(regionId);
});
});
document.getElementById('b-bound').querySelectorAll('[data-rm]').forEach((btn) => {
btn.addEventListener('click', () => {
draftStaff = draftStaff.filter((s) => s.userId !== btn.getAttribute('data-rm'));
openBDialog(regionId);
});
});
document.getElementById('b-mask').classList.add('open');
}
// Events
document.querySelectorAll('.variant-tab').forEach((tab) => {
tab.addEventListener('click', () => {
document.querySelectorAll('.variant-tab').forEach((t) => t.classList.remove('active'));
tab.classList.add('active');
const v = tab.getAttribute('data-variant');
document.getElementById('approach-a').classList.toggle('active', v === 'a');
document.getElementById('approach-b').classList.toggle('active', v === 'b');
});
});
document.getElementById('staff-add').addEventListener('click', () => {
const uid = document.getElementById('staff-picker').value;
if (!uid) { toast('请先选择人员'); return; }
draftStaff.push({ userId: uid, isLeader: false });
renderStaffTable();
});
document.getElementById('staff-save').addEventListener('click', () => {
const region = regions.find((r) => r.id === editingRegionId);
region.staff = draftStaff.map((s) => ({ ...s }));
closeStaffDialog();
renderApproachA();
renderApproachB();
toast('人员配置已保存(Demo)');
});
document.getElementById('staff-cancel').addEventListener('click', closeStaffDialog);
document.getElementById('staff-close').addEventListener('click', closeStaffDialog);
document.getElementById('staff-mask').addEventListener('click', (e) => {
if (e.target.id === 'staff-mask') closeStaffDialog();
});
document.getElementById('b-save').addEventListener('click', () => {
const region = regions.find((r) => r.id === editingRegionId);
region.staff = draftStaff.map((s) => ({ ...s }));
document.getElementById('b-mask').classList.remove('open');
renderApproachA();
renderApproachB();
toast('人员配置已保存(Demo)');
});
document.getElementById('b-cancel').addEventListener('click', () => document.getElementById('b-mask').classList.remove('open'));
document.getElementById('b-close').addEventListener('click', () => document.getElementById('b-mask').classList.remove('open'));
renderApproachA();
renderApproachB();
</script>
</body>
</html>
@@ -0,0 +1,185 @@
# 采集地区 · 人员绑定设计
**日期**:2026-09-18
**页面**:`/#/market/market-info/collection-region`(细胞采集基础信息维护 → 采集地区及医院)
**Demo**:`docs/superpowers/demos/2026-09-18-collection-region-staff-binding-demo.html`
**状态**:设计已确认,待实现计划
---
## 1. 背景与目标
现有「采集地区及医院」仅维护地区与下属医院(`biz_collection_region` / `biz_collection_hospital`),需求文档 `docs/中科兴皖需求V0.3.md` §2.2.3 未覆盖人员。
**目标**:为每个采集地区绑定本租户系统人员,表示该区域工作人员;支持将部分绑定人员设为该地区**负责人**(可多名)。
**首期成功标准**:主数据可维护、列表与查看可展示摘要;不驱动协议默认人、待办过滤、数据权限等业务逻辑。表结构与接口按可扩展方式设计,供后续接入。
---
## 2. 已确认决策
| 项 | 结论 |
|---|---|
| 负责人数量 | 可多名;允许 0 |
| 一人多区 | 允许同一 `user_id` 绑定多个地区 |
| 首期范围 | 仅主数据维护与展示 |
| 扩展预留 | 关联表 + `is_leader`;具体后续场景本轮不定、不开发 |
| 候选用户 | 本租户全部**启用**用户(`sys_user`) |
| UI | 独立「地区人员配置」弹窗,与「地区医院配置」对称 |
| 实现路径 | **方案 1**:关联表 + 子资源 CRUD(对齐医院) |
---
## 3. 数据模型
### 3.1 新表 `biz_collection_region_staff`
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | VARCHAR(64) PK | UUID |
| `region_id` | VARCHAR(64) NOT NULL | 采集地区 ID |
| `user_id` | VARCHAR(64) NOT NULL | `sys_user.id` |
| `is_leader` | TINYINT NOT NULL DEFAULT 0 | 是否地区负责人(0/1) |
| `tenant_no` | VARCHAR(64) | 租户编号 |
| `order_num` | INT | 排序号 |
| `remark` | VARCHAR(512) | 备注 |
| `create_by` / `create_dept_id` / `create_time` | 公共字段 | |
| `update_by` / `update_time` | 公共字段 | |
| `del_flag` | SMALLINT DEFAULT 0 | 软删 |
字段顺序遵循仓库约定:`id` → 业务字段 → 公共字段。
### 3.2 约束与规则
- 业务唯一:同一租户下 `(tenant_no, region_id, user_id)` 在 `del_flag=0` 时唯一(应用层校验;MySQL 5.7 软删场景不依赖部分唯一索引)。
- **不修改** `biz_collection_region`、`sys_user` 表结构。
- 软删地区时:同一事务内级联软删该地区下未删除的 **医院** 与 **staff**(扩展现有医院级联)。
- 用户停用/删除:已有绑定行保留(便于历史展示);**新增**时仅允许启用用户。首期不做绑定自动清理。
- 预留:首期不加「绑定角色类型」等字段;后续业务用 `region_id + user_id + is_leader` 查询即可。
### 3.3 明确不采用
- 地区表上逗号分隔 `user_id` / `leader_id`(与医院子表设计原则一致)。
- 弹窗「整表覆盖一次提交」作为主 API(与医院单条 CRUD 不一致;可作为后续优化,非首期)。
---
## 4. API
挂在现有基础信息模块(`backend/app/api/basic_info.py` + `app/crud/collection_region.py`),路径前缀与现有 `collection-regions` / `hospitals` 一致。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/collection-regions/{region_id}/staff` | 分页列出绑定人员 |
| POST | `/collection-regions/{region_id}/staff` | 新增;body: `userId`, `isLeader?`(默认 false) |
| PUT | `/collection-regions/{region_id}/staff/{staff_id}` | 更新;主要改 `isLeader`;**不允许**改 `userId` |
| DELETE | `/collection-regions/{region_id}/staff/{staff_id}` | 软删绑定 |
### 4.1 行为约定
- 租户隔离 + 现有 `apply_data_scope`(与地区 CRUD 一致)。
- POST:同区已存在未删除同 `userId` → 业务错误(不可重复绑定)。
- POST:用户须为本租户且启用,否则拒绝。
- DELETE 地区:事务内级联软删下属 hospitals 与 staff。
- 选人:复用现有用户列表/选项接口(如 `fetchUserList` / `fetchUserOptions`);本需求不新增用户 API。
### 4.2 地区列表摘要
`GET /collection-regions` 的 `_region_to_dict`(或等价)增加:
```json
{
"staffNames": ["张敏", "李强"],
"leaderNames": ["张敏"]
}
```
- 展示名取自 `sys_user.nick_name`;为空时回退 `user_no`(与用户选项接口一致)。
- 排序建议:负责人优先,再按 `order_num` / `create_time`。
- 批量查询,避免 N+1。
### 4.3 Staff 列表项字段(建议)
`id`, `regionId`, `userId`, `userName`(或 nickName), `userNo`, `deptName?`, `isLeader`, `createTime` 等;以 `_staff_to_dict` 为可读字段单一来源,Create/Update body 与之对齐可写字段。
---
## 5. 前端
**文件**:`web-admin/src/views/basic-info/collection-region/index.vue`
### 5.1 主列表
- 新增列:**工作人员**(多行姓名;空为 `—`)、**负责人**(`<el-tag type="success" size="small">`;空为 info「未设置」)。
- 操作列:在「地区医院配置」旁增加 **「地区人员配置」**;`min-width` ≥ 280。
- 筛选:首期仅保留地区关键词;不按人员筛选。
### 5.2 「地区人员配置」弹窗
- 对齐医院配置:当前地区提示、工具栏(已选 N → 添加人员主按钮 → 批量移除)、表格、分页。
- 表格:序号、姓名、工号/账号、部门(若有)、是否负责人、操作(切换负责人、移除)。
- 添加人员:`el-select` remote 或等价,候选为本租户启用用户;已绑定排除或提交拦截。
- 是否负责人:行内切换后调 PUT;允许多名与全部取消。
- 遮罩无 blur;`dialog-form-layout`;按钮带图标(OpenSpec UI 约定)。
### 5.3 查看弹窗
`SystemViewDialog` 增加:工作人员、负责人(Tag)。地区新增/编辑表单仍只维护地区名称/备注;人员仅走配置弹窗。
### 5.4 类型
`Api.BizCollectionRegion` 增加 `staffNames` / `leaderNames` 及 Staff 子项类型;API 封装与医院子资源对称。
---
## 6. 错误处理
| 情况 | 处理 |
|---|---|
| 地区不存在 / 非本租户 / 已软删 | 404 或业务错误 + Toast |
| 重复绑定 | 明确文案,如「该人员已绑定到此地区」 |
| 用户不存在或未启用 | 拒绝新增 |
| staff 行不属于路径中的 `region_id` | 拒绝更新/删除 |
| 移除 / 批量移除 | 二次确认 |
---
## 7. 范围外(首期不做)
- 协议单默认销售/采集专员、按地区过滤待办、数据权限
- 按角色过滤候选用户
- 按人员姓名筛地区列表
- 小程序端改动
- 在 `sys_user` 或地区主表冗余人员字段
---
## 8. 验收要点
1. 一地区可绑多名启用用户;一人可绑多地区。
2. 可设多名负责人,也可全部取消。
3. 主列表与查看弹窗正确显示 `staffNames` / `leaderNames`。
4. 软删地区后,下属 hospital + staff 均软删且列表不可见。
5. 重复绑定被拒绝;停用用户不可新绑。
6. UI 与「地区医院配置」对称,符合 Fresh Mint / Tag / 按钮图标约定。
---
## 9. 实现改动面(预估)
| 层 | 内容 |
|---|---|
| SQL | `docs/sql/migrations/2026-09-18-collection-region-staff.sql`;按仓库约定同步全量 schema |
| Backend | `models.py`、`collection_region` crud、`basic_info` 路由、删除级联、列表摘要 |
| Frontend | `collection-region/index.vue`、API 模块与类型 |
| Spec(可选) | 扩展 OpenSpec `collection-region-hospital-management` 或新增 capability |
| Demo | 已有 HTML Demo,实现时可对照 |
---
## 10. 参考
- 现有医院子资源:`GET/POST/PUT/DELETE .../collection-regions/{id}/hospitals`
- OpenSpec:`openspec/changes/basic-info-management/specs/collection-region-hospital-management/spec.md`
- UI:`docs/web-admin-ui-spec-fresh-mint.md`;模板页医院弹窗交互以本页现有实现为准