Files
zkxw/docs/demos/storage-facility-batch-outbound-demo.html
T
zlwuandCursor 45c2e3df01 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>
2026-07-28 19:08:43 +08:00

667 lines
21 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>