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>
This commit is contained in:
zlwuandCursor committed 2026-07-28 19:08:43 +08:00
1 parent d323e09894
commit 45c2e3df01
2 files changed
+782

No files matched your search

@@ -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>
@@ -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. 测试要点
- 手动入库「其他」物品与细胞产品占用格均可勾选并出库。
- 仅空格的叶子节点:无勾选框,「出库已选」禁用。
- 筛选命中部分占用格时,仅能勾选可见占用格。
- 出库后格子变为空,勾选框消失。