Explorar el Código

docs: add storage facility batch outbound design and demo

Capture confirmed UI (always-on checkboxes) and serial remove via existing occupancy API for implementation planning.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu hace 3 meses
padre
commit
45c2e3df01

+ 666 - 0
docs/demos/storage-facility-batch-outbound-demo.html

@@ -0,0 +1,666 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>库位管理 · 批量出库 Demo</title>
+  <style>
+    :root {
+      --primary: #10b981;
+      --primary-dark: #059669;
+      --primary-bg: #ecfdf5;
+      --text: #1e293b;
+      --muted: #64748b;
+      --border: #e2e8f0;
+      --danger: #ef4444;
+      --danger-bg: #fef2f2;
+      --danger-border: #fecaca;
+      --orange: #ea580c;
+      --orange-border: #fed7aa;
+      --orange-soft: #fff7ed;
+    }
+    * { box-sizing: border-box; }
+    body {
+      margin: 0;
+      font-family: Inter, "PingFang SC", "Helvetica Neue", Arial, sans-serif;
+      background: #f1f5f9;
+      color: var(--text);
+      font-size: 14px;
+      line-height: 1.5;
+    }
+    .page { max-width: 1100px; margin: 0 auto; padding: 20px 16px 80px; }
+    .intro {
+      background: #fff;
+      border: 1px solid var(--border);
+      border-radius: 10px;
+      padding: 16px 18px;
+      margin-bottom: 16px;
+    }
+    .intro h1 { margin: 0 0 6px; font-size: 18px; }
+    .intro p { margin: 0; color: var(--muted); font-size: 13px; }
+    .approaches {
+      display: grid;
+      grid-template-columns: repeat(3, 1fr);
+      gap: 12px;
+      margin-bottom: 16px;
+    }
+    @media (max-width: 900px) {
+      .approaches { grid-template-columns: 1fr; }
+    }
+    .approach {
+      background: #fff;
+      border: 2px solid var(--border);
+      border-radius: 10px;
+      padding: 14px;
+      cursor: pointer;
+      transition: border-color .15s, box-shadow .15s;
+    }
+    .approach:hover { border-color: #a7f3d0; }
+    .approach.active {
+      border-color: var(--primary);
+      box-shadow: 0 0 0 3px rgba(16,185,129,.15);
+    }
+    .approach .badge {
+      display: inline-block;
+      font-size: 11px;
+      font-weight: 600;
+      padding: 2px 8px;
+      border-radius: 999px;
+      background: var(--primary-bg);
+      color: var(--primary-dark);
+      margin-bottom: 8px;
+    }
+    .approach .badge.rec { background: #d1fae5; }
+    .approach h3 { margin: 0 0 6px; font-size: 15px; }
+    .approach p { margin: 0; font-size: 12px; color: var(--muted); }
+    .panel {
+      background: #fff;
+      border: 1px solid var(--border);
+      border-radius: 10px;
+      padding: 16px 18px 20px;
+      display: flex;
+      gap: 16px;
+      min-height: 520px;
+    }
+    .aside {
+      width: 220px;
+      flex-shrink: 0;
+      border-right: 1px solid var(--border);
+      padding-right: 14px;
+    }
+    .aside h2 {
+      margin: 0 0 12px;
+      font-size: 15px;
+      display: flex;
+      align-items: center;
+      gap: 8px;
+    }
+    .aside h2::before {
+      content: "";
+      width: 3px;
+      height: 14px;
+      background: var(--primary);
+      border-radius: 2px;
+    }
+    .tree-node {
+      padding: 8px 10px;
+      border-radius: 6px;
+      font-size: 13px;
+      color: var(--muted);
+    }
+    .tree-node.active {
+      background: var(--primary-bg);
+      color: var(--primary-dark);
+      font-weight: 600;
+    }
+    .tree-node .leaf {
+      float: right;
+      font-size: 11px;
+      color: var(--primary);
+      background: #d1fae5;
+      padding: 0 6px;
+      border-radius: 4px;
+    }
+    .main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
+    .header { margin-bottom: 12px; padding-bottom: 10px; border-bottom: 1px solid #f0f0f0; }
+    .header h3 { margin: 0; font-size: 15px; }
+    .stats { font-size: 12px; color: var(--muted); margin-top: 4px; }
+    .stats strong { color: var(--text); }
+    .filter {
+      display: flex;
+      justify-content: center;
+      margin-bottom: 12px;
+    }
+    .filter input {
+      width: 100%;
+      max-width: 360px;
+      height: 36px;
+      border: 1px solid var(--border);
+      border-radius: 8px;
+      padding: 0 12px;
+      font-size: 13px;
+    }
+    .toolbar {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      margin-bottom: 14px;
+      flex-wrap: wrap;
+      min-height: 36px;
+    }
+    .toolbar .selected-hint {
+      font-size: 13px;
+      color: var(--muted);
+      margin-right: 4px;
+    }
+    .toolbar .selected-hint em {
+      font-style: normal;
+      color: var(--primary-dark);
+      font-weight: 600;
+    }
+    .btn {
+      height: 32px;
+      padding: 0 14px;
+      border-radius: 8px;
+      border: 1px solid var(--border);
+      background: #fff;
+      color: var(--text);
+      font-size: 13px;
+      cursor: pointer;
+      display: inline-flex;
+      align-items: center;
+      gap: 6px;
+    }
+    .btn:disabled { opacity: .45; cursor: not-allowed; }
+    .btn-primary {
+      background: var(--primary);
+      border-color: var(--primary);
+      color: #fff;
+    }
+    .btn-primary:hover:not(:disabled) { background: var(--primary-dark); }
+    .btn-danger {
+      background: #fff;
+      border-color: #fca5a5;
+      color: #dc2626;
+    }
+    .btn-danger:hover:not(:disabled) { background: var(--danger-bg); }
+    .btn-ghost:hover:not(:disabled) { background: #f8fafc; }
+    .mode-banner {
+      display: none;
+      align-items: center;
+      gap: 10px;
+      background: #fff7ed;
+      border: 1px solid #fdba74;
+      color: #9a3412;
+      border-radius: 8px;
+      padding: 8px 12px;
+      font-size: 13px;
+      margin-bottom: 12px;
+    }
+    .mode-banner.show { display: flex; }
+    .mode-banner strong { color: #c2410c; }
+    .shelf { display: flex; flex-direction: column; gap: 10px; }
+    .row { display: flex; align-items: stretch; }
+    .row-label {
+      width: 36px;
+      flex-shrink: 0;
+      background: var(--orange);
+      color: #fff;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      font-weight: 600;
+      border-radius: 4px 0 0 4px;
+      font-size: 14px;
+    }
+    .cells { display: flex; flex-wrap: wrap; gap: 6px; }
+    .cell {
+      width: 80px;
+      height: 80px;
+      border: 1px solid var(--orange-border);
+      border-radius: 4px;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      cursor: pointer;
+      position: relative;
+      padding: 4px 2px;
+      gap: 1px;
+      background: #fff;
+      transition: all .15s;
+      user-select: none;
+    }
+    .cell:hover { background: var(--orange-soft); }
+    .cell.occupied {
+      background: var(--danger-bg);
+      border-color: var(--danger-border);
+    }
+    .cell.occupied:hover { background: #fee2e2; }
+    .cell .name {
+      font-size: 11px;
+      font-weight: 600;
+      color: #991b1b;
+      text-align: center;
+      line-height: 1.2;
+      max-width: 72px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+    .cell .type {
+      font-size: 10px;
+      color: #b91c1c;
+      max-width: 72px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+    .cell .empty { font-size: 12px; font-weight: 600; color: #303133; }
+    .cell .check {
+      display: none;
+      position: absolute;
+      top: 3px;
+      left: 3px;
+      width: 18px;
+      height: 18px;
+      border-radius: 4px;
+      border: 1.5px solid #cbd5e1;
+      background: #fff;
+      align-items: center;
+      justify-content: center;
+      font-size: 11px;
+      color: transparent;
+      z-index: 2;
+    }
+    body.select-mode .cell.occupied .check { display: flex; }
+    body.select-mode .cell.empty-slot {
+      opacity: .4;
+      cursor: not-allowed;
+    }
+    body.select-mode .cell.occupied.selected {
+      outline: 2px solid var(--primary);
+      outline-offset: 1px;
+      background: #ecfdf5;
+      border-color: #a7f3d0;
+    }
+    body.select-mode .cell.occupied.selected .name,
+    body.select-mode .cell.occupied.selected .type { color: var(--primary-dark); }
+    body.select-mode .cell.occupied.selected .check {
+      background: var(--primary);
+      border-color: var(--primary);
+      color: #fff;
+    }
+    body.select-mode .cell.occupied:not(.selected):hover .check {
+      border-color: var(--primary);
+    }
+    .toast {
+      position: fixed;
+      top: 20px;
+      left: 50%;
+      transform: translateX(-50%) translateY(-20px);
+      background: #065f46;
+      color: #fff;
+      padding: 10px 18px;
+      border-radius: 8px;
+      font-size: 13px;
+      opacity: 0;
+      pointer-events: none;
+      transition: all .25s;
+      z-index: 100;
+    }
+    .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
+    .mask {
+      display: none;
+      position: fixed;
+      inset: 0;
+      background: rgba(15, 23, 42, .35);
+      z-index: 90;
+      align-items: center;
+      justify-content: center;
+    }
+    .mask.show { display: flex; }
+    .dialog {
+      background: #fff;
+      border-radius: 10px;
+      width: 420px;
+      max-width: 92vw;
+      padding: 20px;
+      box-shadow: 0 20px 40px rgba(0,0,0,.12);
+    }
+    .dialog h4 { margin: 0 0 8px; font-size: 16px; }
+    .dialog p { margin: 0 0 14px; color: var(--muted); font-size: 13px; }
+    .dialog .list {
+      max-height: 160px;
+      overflow: auto;
+      background: #f8fafc;
+      border-radius: 8px;
+      padding: 8px 10px;
+      margin-bottom: 16px;
+      font-size: 12px;
+    }
+    .dialog .list div { padding: 3px 0; }
+    .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
+    .hint-bar {
+      margin-top: 14px;
+      font-size: 12px;
+      color: var(--muted);
+      background: #f8fafc;
+      border-radius: 8px;
+      padding: 10px 12px;
+    }
+    .hint-bar code {
+      background: #e2e8f0;
+      padding: 1px 5px;
+      border-radius: 4px;
+      font-size: 11px;
+    }
+  </style>
+</head>
+<body>
+  <div class="page">
+    <div class="intro">
+      <h1>库位管理 · 批量出库交互 Demo</h1>
+      <p>先点下方方案切换预览,再在货架上试操作。推荐方案 A(工具栏进入多选模式)。</p>
+    </div>
+
+    <div class="approaches">
+      <div class="approach active" data-choice="a" onclick="pickApproach(this)">
+        <span class="badge rec">推荐 · A</span>
+        <h3>工具栏「批量出库」→ 多选格子</h3>
+        <p>点按钮进入选择模式,只可选占用格;确认后一次性出库。空格不可选。</p>
+      </div>
+      <div class="approach" data-choice="b" onclick="pickApproach(this)">
+        <span class="badge">B</span>
+        <h3>格子常驻勾选框</h3>
+        <p>占用格左上角始终显示勾选框,选完后点工具栏「出库」。更直接,但日常浏览稍吵。</p>
+      </div>
+      <div class="approach" data-choice="c" onclick="pickApproach(this)">
+        <span class="badge">C</span>
+        <h3>框选 / 起止区间</h3>
+        <p>拖拽或选起止格批量勾选同盒连续占用格。适合密集出库,交互更重。</p>
+      </div>
+    </div>
+
+    <div class="panel">
+      <aside class="aside">
+        <h2>库位</h2>
+        <div class="tree-node">A区冷库</div>
+        <div class="tree-node" style="padding-left:22px">1层</div>
+        <div class="tree-node active" style="padding-left:34px">
+          盒-01 <span class="leaf">存</span>
+        </div>
+      </aside>
+
+      <div class="main">
+        <div class="header">
+          <h3>A区冷库 / 1层 / 盒-01</h3>
+          <div class="stats">
+            总数 <strong id="statTotal">12</strong>
+            · 空闲 <strong id="statEmpty">4</strong>
+            · 占用 <strong id="statOcc">8</strong>
+          </div>
+        </div>
+
+        <div class="filter">
+          <input type="text" placeholder="物品名称 / 产品类型 / 批次号" disabled />
+        </div>
+
+        <div class="mode-banner" id="modeBanner">
+          <strong>批量出库模式</strong>
+          <span>请勾选要出库的占用储物格,空格不可选。</span>
+        </div>
+
+        <div class="toolbar" id="toolbar"></div>
+
+        <div class="shelf" id="shelf"></div>
+
+        <div class="hint-bar" id="hint">
+          当前方案 A:先点「批量出库」进入选择模式,再点占用格勾选,最后确认出库。
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div class="toast" id="toast"></div>
+
+  <div class="mask" id="mask">
+    <div class="dialog">
+      <h4>批量出库确认</h4>
+      <p id="confirmText">确认将选中储物格中的物品出库?</p>
+      <div class="list" id="confirmList"></div>
+      <div class="dialog-actions">
+        <button class="btn btn-ghost" onclick="closeConfirm()">取消</button>
+        <button class="btn btn-primary" onclick="doOutbound()">确认出库</button>
+      </div>
+    </div>
+  </div>
+
+  <script>
+    const HINTS = {
+      a: '当前方案 A:先点「批量出库」进入选择模式,再点占用格勾选,最后确认出库。',
+      b: '当前方案 B:占用格左上角常驻勾选框,勾选后点工具栏「出库已选」。',
+      c: '当前方案 C:演示简化为「按行全选占用格」+ 手动点选;正式版可再做拖拽框选/起止区间。'
+    }
+
+    let approach = 'a'
+    let selectMode = false
+    let selected = new Set()
+
+    const initialCells = [
+      // row A
+      { id: 'A1', row: 'A', name: 'CP-1001', type: 'MSC', occupied: true },
+      { id: 'A2', row: 'A', name: 'CP-1002', type: 'MSC', occupied: true },
+      { id: 'A3', row: 'A', name: '', type: '', occupied: false },
+      { id: 'A4', row: 'A', name: 'CP-1004', type: 'NK', occupied: true },
+      // row B
+      { id: 'B1', row: 'B', name: 'CP-2001', type: 'CAR-T', occupied: true },
+      { id: 'B2', row: 'B', name: '', type: '', occupied: false },
+      { id: 'B3', row: 'B', name: 'CP-2003', type: 'MSC', occupied: true },
+      { id: 'B4', row: 'B', name: 'CP-2004', type: 'MSC', occupied: true },
+      // row C
+      { id: 'C1', row: 'C', name: '', type: '', occupied: false },
+      { id: 'C2', row: 'C', name: 'CP-3002', type: 'NK', occupied: true },
+      { id: 'C3', row: 'C', name: 'CP-3003', type: 'NK', occupied: true },
+      { id: 'C4', row: 'C', name: '', type: '', occupied: false }
+    ]
+
+    let cells = JSON.parse(JSON.stringify(initialCells))
+
+    function pickApproach(el) {
+      document.querySelectorAll('.approach').forEach(a => a.classList.remove('active'))
+      el.classList.add('active')
+      approach = el.dataset.choice
+      selectMode = approach === 'b' // B always selectable
+      selected.clear()
+      document.getElementById('hint').textContent = HINTS[approach]
+      render()
+      if (window.toggleSelect) {
+        // companion helper if present
+        try { window.toggleSelect(el) } catch (_) {}
+      }
+    }
+
+    function updateStats() {
+      const total = cells.length
+      const occ = cells.filter(c => c.occupied).length
+      document.getElementById('statTotal').textContent = total
+      document.getElementById('statEmpty').textContent = total - occ
+      document.getElementById('statOcc').textContent = occ
+    }
+
+    function renderToolbar() {
+      const tb = document.getElementById('toolbar')
+      const n = selected.size
+      if (approach === 'a') {
+        if (!selectMode) {
+          tb.innerHTML = `
+            <button class="btn btn-primary" onclick="enterSelectMode()">⬆ 批量出库</button>
+            <button class="btn btn-ghost" disabled>批量生成格子</button>
+            <button class="btn btn-ghost" disabled>清空格子</button>
+          `
+        } else {
+          tb.innerHTML = `
+            <span class="selected-hint">已选 <em>${n}</em> 项</span>
+            <button class="btn btn-primary" ${n ? '' : 'disabled'} onclick="openConfirm()">确认出库</button>
+            <button class="btn btn-ghost" onclick="selectAllOccupied()">全选占用</button>
+            <button class="btn btn-ghost" onclick="clearSelection()">清空选择</button>
+            <button class="btn btn-ghost" onclick="exitSelectMode()">取消</button>
+          `
+        }
+      } else if (approach === 'b') {
+        tb.innerHTML = `
+          <span class="selected-hint">已选 <em>${n}</em> 项</span>
+          <button class="btn btn-primary" ${n ? '' : 'disabled'} onclick="openConfirm()">⬆ 出库已选</button>
+          <button class="btn btn-ghost" onclick="selectAllOccupied()">全选占用</button>
+          <button class="btn btn-ghost" onclick="clearSelection()">清空选择</button>
+        `
+      } else {
+        tb.innerHTML = `
+          <span class="selected-hint">已选 <em>${n}</em> 项</span>
+          <button class="btn btn-primary" ${n ? '' : 'disabled'} onclick="openConfirm()">⬆ 确认出库</button>
+          <button class="btn btn-ghost" onclick="selectRow('A')">全选 A 行</button>
+          <button class="btn btn-ghost" onclick="selectRow('B')">全选 B 行</button>
+          <button class="btn btn-ghost" onclick="selectRow('C')">全选 C 行</button>
+          <button class="btn btn-ghost" onclick="clearSelection()">清空选择</button>
+        `
+      }
+    }
+
+    function renderShelf() {
+      const shelf = document.getElementById('shelf')
+      const rows = ['A', 'B', 'C']
+      shelf.innerHTML = rows.map(row => {
+        const rowCells = cells.filter(c => c.row === row)
+        return `
+          <div class="row">
+            <div class="row-label">${row}</div>
+            <div class="cells">
+              ${rowCells.map(c => {
+                const occ = c.occupied
+                const sel = selected.has(c.id)
+                return `
+                  <div class="cell ${occ ? 'occupied' : 'empty-slot'} ${sel ? 'selected' : ''}"
+                       data-id="${c.id}"
+                       onclick="onCellClick('${c.id}')"
+                       title="${occ ? c.name + ' · 点击选择/取消' : '空格不可出库'}">
+                    <span class="check">✓</span>
+                    ${occ
+                      ? `<span class="name">${c.name}</span><span class="type">${c.type}</span>`
+                      : `<span class="empty">—</span>`}
+                  </div>
+                `
+              }).join('')}
+            </div>
+          </div>
+        `
+      }).join('')
+    }
+
+    function render() {
+      const showBanner = approach === 'a' && selectMode
+      document.getElementById('modeBanner').classList.toggle('show', showBanner)
+      document.body.classList.toggle('select-mode', approach === 'a' ? selectMode : true)
+      updateStats()
+      renderToolbar()
+      renderShelf()
+    }
+
+    function enterSelectMode() {
+      selectMode = true
+      selected.clear()
+      render()
+      toast('已进入批量出库模式')
+    }
+
+    function exitSelectMode() {
+      selectMode = false
+      selected.clear()
+      render()
+    }
+
+    function onCellClick(id) {
+      const cell = cells.find(c => c.id === id)
+      if (!cell || !cell.occupied) {
+        if (approach === 'a' && selectMode) toast('空格不可出库')
+        else if (approach !== 'a') toast('空格不可出库')
+        return
+      }
+      // 方案 A:非选择模式 → 模拟打开详情(提示)
+      if (approach === 'a' && !selectMode) {
+        toast(`打开储物格明细 — ${cell.name}(现有行为)`)
+        return
+      }
+      if (selected.has(id)) selected.delete(id)
+      else selected.add(id)
+      render()
+    }
+
+    function selectAllOccupied() {
+      cells.filter(c => c.occupied).forEach(c => selected.add(c.id))
+      render()
+    }
+
+    function selectRow(row) {
+      cells.filter(c => c.row === row && c.occupied).forEach(c => selected.add(c.id))
+      render()
+    }
+
+    function clearSelection() {
+      selected.clear()
+      render()
+    }
+
+    function openConfirm() {
+      if (!selected.size) return
+      const list = [...selected].map(id => {
+        const c = cells.find(x => x.id === id)
+        return `<div>${id} ${c.name} ${c.type}</div>`
+      }).join('')
+      document.getElementById('confirmText').textContent =
+        `确认将选中的 ${selected.size} 个储物格物品出库?`
+      document.getElementById('confirmList').innerHTML = list
+      document.getElementById('mask').classList.add('show')
+    }
+
+    function closeConfirm() {
+      document.getElementById('mask').classList.remove('show')
+    }
+
+    function doOutbound() {
+      const ids = [...selected]
+      cells.forEach(c => {
+        if (ids.includes(c.id)) {
+          c.occupied = false
+          c.name = ''
+          c.type = ''
+        }
+      })
+      selected.clear()
+      if (approach === 'a') selectMode = false
+      closeConfirm()
+      render()
+      toast(`出库成功,共 ${ids.length} 项`)
+    }
+
+    function toast(msg) {
+      const el = document.getElementById('toast')
+      el.textContent = msg
+      el.classList.add('show')
+      clearTimeout(toast._t)
+      toast._t = setTimeout(() => el.classList.remove('show'), 1800)
+    }
+
+    // reset helper for demo
+    window.resetDemo = function () {
+      cells = JSON.parse(JSON.stringify(initialCells))
+      selected.clear()
+      selectMode = approach === 'b'
+      render()
+      toast('已重置货架数据')
+    }
+
+    render()
+  </script>
+</body>
+</html>

+ 116 - 0
docs/superpowers/specs/2026-07-28-storage-facility-batch-outbound-design.md

@@ -0,0 +1,116 @@
+# 库位管理:货架批量出库设计
+
+> 日期: 2026-07-28  
+> 状态: 已确认  
+> 范围: web-admin 库位管理 + 货架列表字段小幅 enrichment(无新批量出库 API)  
+> 参考 Demo: `docs/demos/storage-facility-batch-outbound-demo.html`(方案 B)
+
+## 1. 背景与目标
+
+当前库位管理中,储物格点击仅打开「储物格明细」弹窗,出库须在弹窗「在库物品」操作列逐条完成,大批量出库效率低。
+
+**目标**:在货架界面直接勾选占用储物格,工具栏一键批量出库,减少进出弹窗次数。
+
+**非目标**:
+
+- 不新增后端批量出库事务 API(可后续升级)
+- 不提供「全选占用」
+- 不改小程序端
+- 不改变明细弹窗内单条「出库」行为
+
+## 2. 已确认决策
+
+| 项 | 决策 |
+|----|------|
+| 交互形态 | 方案 B:占用格左上角常驻勾选框 |
+| 点格 vs 点框 | 勾选框切换选中;格子其它区域仍打开储物格明细 |
+| 二次确认 | 需要:确认框列出已选格子编码 + 物品名称 |
+| 全选 | 不要,仅逐格勾选 |
+| 出库实现 | 方案 1:前端串行调用现有 `fetchRemoveOccupancy` |
+| 占用 ID | 货架列表 enrichment 增加 `storedOccupancyId` |
+
+## 3. 界面与交互
+
+### 3.1 格子
+
+- 占用格(`slotStatus === '占用'` 且存在在库占用)左上角常驻勾选框;空格不显示勾选框、不可选。
+- 勾选框 `@click.stop`:切换选中;点格子其它区域:`handleOpenOccupancyDialog`(现有行为)。
+- 选中态:勾选框主色填充 + 格子浅绿描边(Fresh Mint:`--primary` / `#ecfdf5` / `#a7f3d0`)。
+- 删除按钮(×)行为不变,继续 `@click.stop`。
+
+### 3.2 工具栏(叶子节点且已有格子时)
+
+顺序(符合列表工具栏「已选在前」约定):
+
+1. 「已选 N 项」文案  
+2. 「出库已选」主按钮(`type="primary"`,N=0 时禁用;执行中 loading)  
+3. 现有「批量生成格子 / 清空格子」等按钮
+
+不提供「全选占用」「清空选择」独立按钮(取消勾选即可;切换节点会清空选择)。
+
+### 3.3 确认与结果
+
+- 点击「出库已选」→ `ElMessageBox.confirm`(与现有单条出库确认一致),正文列出每项:`格子编码` + `物品名称`(无名称则物品编码);条目过多时正文可截断并注明「等共 N 项」。
+- 确认后串行出库;成功提示「出库成功,共 N 项」。
+- 刷新当前叶子节点格子列表与左侧占用统计;清空选择。
+
+### 3.4 选择生命周期
+
+- 切换左侧树节点、执行「清空格子」后:清空选择。
+- 筛选关键词变化:保留仍在当前可见且仍占用的选中项;不可见或已空的自动剔除。
+- 单格在明细弹窗内出库成功后:若该格在选中集合中,从集合移除并刷新。
+
+## 4. 数据流与错误处理
+
+### 4.1 占用 ID
+
+现有 `enrich_slots_with_stored_cell_products` 仅附带展示字段,不含占用记录 id。  
+单条出库接口 `PUT /web/admin/storage-slot-occupancies/{occupancy_id}/remove` 需要 occupancy id。
+
+**改动**:enrichment 时写入 `storedOccupancyId = occ.id`(当前 `occupancy_status == stored` 的那条;一格一物)。
+
+前端选中结构示例:
+
+```ts
+{ slotId, occupancyId, slotCode, itemName }
+```
+
+仅当 `storedOccupancyId` 存在时允许勾选。
+
+### 4.2 执行流程
+
+1. 用户勾选 → `selectedMap`(key=`slotId`)  
+2. 确认框展示列表  
+3. 按选中顺序串行 `await fetchRemoveOccupancy(occupancyId)`  
+4. 全部成功:刷新货架 + 统计;清空选择;成功提示  
+5. 过程中主按钮 loading,防止重复提交  
+
+### 4.3 错误处理
+
+- 第 k 条失败:停止后续请求;已成功的 k-1 条保留出库结果;提示「已出库 M 项,第 M+1 项失败:…」(或接口返回文案);刷新货架;清理选中中已不存在/已空的项。  
+- 用户取消确认:无请求。  
+- 不做事务级「全成或全败」(若业务后续强制要求,另开批量 API 变更)。
+
+## 5. 改动清单
+
+| 文件 | 改动 |
+|------|------|
+| `backend/app/crud/storage_occupancy.py` | `enrich_slots_with_stored_cell_products` 增加 `storedOccupancyId` |
+| `web-admin/src/views/operations/storage-facility/index.vue` | 勾选 UI、工具栏、确认与串行出库、选中态样式、生命周期清理 |
+| `docs/demos/storage-facility-batch-outbound-demo.html` | 参考用 Demo(非上线依赖) |
+
+## 6. 验收标准
+
+1. 占用格有勾选框,空格无;点框选中,点格身仍开明细。  
+2. N>0 时可点「出库已选」;确认框列出编码 + 名称。  
+3. 确认后逐条出库,货架与左侧统计刷新,选择清空。  
+4. 中途失败:已成功保留,提示已出库数与失败原因,货架刷新。  
+5. 切换树节点后选择清空;明细弹窗单条出库行为不变。  
+6. 货架接口响应中占用格含 `storedOccupancyId`。
+
+## 7. 测试要点
+
+- 手动入库「其他」物品与细胞产品占用格均可勾选并出库。  
+- 仅空格的叶子节点:无勾选框,「出库已选」禁用。  
+- 筛选命中部分占用格时,仅能勾选可见占用格。  
+- 出库后格子变为空,勾选框消失。