Kaynağa Gözat

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>
zlwu 1 ay önce
ebeveyn
işleme
e24b3dbadf

+ 931 - 0
docs/superpowers/demos/2026-09-18-collection-region-staff-binding-demo.html

@@ -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>

+ 185 - 0
docs/superpowers/specs/2026-09-18-collection-region-staff-binding-design.md

@@ -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`;模板页医院弹窗交互以本页现有实现为准