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