Compare commits
36 Commits
073162dc65
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dc714dbeb | ||
|
|
1416cff52c | ||
|
|
12cab16888 | ||
|
|
9aa5d86fde | ||
|
|
f6bfd8cb10 | ||
|
|
9db50af1e6 | ||
|
|
4e8be6a684 | ||
|
|
5fc5c7de2b | ||
|
|
4891fe4dfd | ||
|
|
305083bf3f | ||
|
|
a601858225 | ||
|
|
9ab7a51dfb | ||
|
|
950effc164 | ||
|
|
e87d29e696 | ||
|
|
b9f17d5d49 | ||
|
|
9eb1bde645 | ||
|
|
c2d9f5fd56 | ||
|
|
f2b2f41929 | ||
|
|
0ae4017eee | ||
|
|
b17e0e7e62 | ||
|
|
3fd47f4d7b | ||
|
|
eb833c830d | ||
|
|
b1a76287d6 | ||
|
|
f587194267 | ||
|
|
a6ee6a718b | ||
|
|
7a95f49acc | ||
|
|
8c967a0f99 | ||
|
|
d9f8e066b3 | ||
|
|
f60745607f | ||
|
|
9e4667d68f | ||
|
|
9b9becb008 | ||
|
|
a7570891c5 | ||
|
|
df3569ba22 | ||
|
|
1dc9e3d3a1 | ||
|
|
f964c76382 | ||
|
|
3710e5955d |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -48,4 +48,5 @@ app.*.map.json
|
||||
|
||||
# AI Agents
|
||||
.claude/
|
||||
.agents/
|
||||
.agents/
|
||||
.sisyphus/
|
||||
41
CLAUDE.md
41
CLAUDE.md
@@ -1,5 +1,33 @@
|
||||
# pad_scanner CLAUDE.md
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
- The main branch is `master`, always kept in a releasable state
|
||||
- All new feature development must be done on a `dev` branch, created from `master`
|
||||
- After verification, merge `dev` back into `master`
|
||||
|
||||
## Pre-Commit Checks
|
||||
|
||||
Before committing Flutter app changes, run these checks:
|
||||
|
||||
```bash
|
||||
dart format --set-exit-if-changed .
|
||||
flutter analyze
|
||||
flutter test
|
||||
```
|
||||
|
||||
If `flutter test` fails with a localhost WebSocket/proxy error, follow the proxy cleanup steps in the Flutter Test Rules section below and run it again.
|
||||
|
||||
### Handling Check Failures
|
||||
|
||||
- If a check fails because of the current change, fix the issue before committing.
|
||||
- If `flutter analyze` reports any issue in files changed by the current task, fix it before committing.
|
||||
- If `flutter analyze` reports only unrelated pre-existing issues, do not fix them in the current commit. Report the file, line, and lint/error name, then handle them in a separate cleanup commit or task.
|
||||
- If a check fails because of unrelated pre-existing issues, do not include unrelated fixes in the same commit. Report the failing command and the existing issues, then handle them in a separate cleanup commit or task.
|
||||
- If formatting fails, format only files changed by the current task. Do not run a broad formatting cleanup unless that is the explicit task.
|
||||
- Treat `flutter test` failures as blocking unless the failure is clearly caused by the proxy issue described below and passes after rerunning with proxy variables cleared.
|
||||
- When committing or reporting completion, mention which checks were run and whether any remaining failures are unrelated pre-existing issues.
|
||||
|
||||
## App Installation Rules
|
||||
|
||||
**Always use `adb install -r` to install the app. Never use `flutter install`.**
|
||||
@@ -15,3 +43,16 @@ flutter build apk --release
|
||||
# 2. Install (in-place upgrade, preserves config)
|
||||
adb install -r build/app/outputs/flutter-apk/app-release.apk
|
||||
```
|
||||
|
||||
## Flutter Test Rules
|
||||
|
||||
Before running `flutter test`, temporarily remove proxy environment variables for the current shell. The Flutter tester uses a localhost WebSocket, and proxy settings can break that connection with `Invalid WebSocket upgrade request`.
|
||||
|
||||
### PowerShell
|
||||
|
||||
```powershell
|
||||
$env:HTTP_PROXY=''
|
||||
$env:HTTPS_PROXY=''
|
||||
$env:NO_PROXY='localhost,127.0.0.1,::1'
|
||||
flutter test
|
||||
```
|
||||
|
||||
1189
docs/multi-code-boxing-ui-preview.html
Normal file
1189
docs/multi-code-boxing-ui-preview.html
Normal file
File diff suppressed because it is too large
Load Diff
1146
docs/multi-code-boxing-ui-v2.html
Normal file
1146
docs/multi-code-boxing-ui-v2.html
Normal file
File diff suppressed because it is too large
Load Diff
482
docs/registration-redesign-preview.html
Normal file
482
docs/registration-redesign-preview.html
Normal file
@@ -0,0 +1,482 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>上架登记模块 - UI 重构预览</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, 'Segoe UI', 'Noto Sans SC', 'Roboto', sans-serif;
|
||||
background: #eef1f5; height: 100vh; display: flex; overflow: hidden;
|
||||
}
|
||||
|
||||
/* ========== Layout ========== */
|
||||
.sidebar {
|
||||
width: 280px; min-width: 280px; background: #fff; border-right: 1px solid #ddd;
|
||||
overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
.main {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center; padding: 20px; gap: 40px;
|
||||
}
|
||||
|
||||
/* ========== Control Panel ========== */
|
||||
.group { border: 1px solid #e0e0e0; border-radius: 8px; padding: 10px; background: #fafbfc; }
|
||||
.group-title { font-size: 12px; font-weight: 700; color: #333; margin-bottom: 6px; padding-bottom: 5px; border-bottom: 1px solid #eee; }
|
||||
.btn-row { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 5px; }
|
||||
.btn-row:last-child { margin-bottom: 0; }
|
||||
.btn { padding: 4px 9px; font-size: 11px; border: 1px solid #ccc; border-radius: 5px; background: #fff; cursor: pointer; transition: all .15s; white-space: nowrap; color: #333; }
|
||||
.btn:hover { background: #e8f0fe; border-color: #90b4f0; }
|
||||
.btn.active { background: #1a73e8; color: #fff; border-color: #1a73e8; }
|
||||
|
||||
/* ========== Device Frame ========== */
|
||||
.device {
|
||||
width: 400px; height: 700px; border-radius: 16px; overflow: hidden;
|
||||
box-shadow: 0 6px 30px rgba(0,0,0,.22); background: #fff;
|
||||
display: flex; flex-direction: column; position: relative; border: 2px solid #222;
|
||||
}
|
||||
|
||||
/* ========== AppBar ========== */
|
||||
.appbar { height: 44px; min-height: 44px; background: #1565C0; color: #fff; display: flex; align-items: center; padding: 0 4px 0 12px; }
|
||||
.appbar-title { font-size: 17px; font-weight: 600; white-space: nowrap; }
|
||||
.appbar-gap { width: 8px; }
|
||||
.mode-pill { flex: 1; height: 32px; border-radius: 8px; display: flex; align-items: center; justify-content: center; gap: 5px; font-size: 14px; font-weight: 800; color: #fff; border: none; transition: background .2s; }
|
||||
.mode-pill.unlocked { background: #1976D2; }
|
||||
.mode-pill.locked { background: #F57C00; }
|
||||
.mode-pill .icon { font-size: 15px; }
|
||||
.mode-pill .shortcut { font-size: 11px; font-weight: 700; opacity: .85; margin-left: 3px; }
|
||||
|
||||
/* ========== Body ========== */
|
||||
.body { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
|
||||
/* ========== Paicha Header ========== */
|
||||
.paicha-header { padding: 8px 12px 6px; background: #FAFAFA; border-bottom: 1px solid #E0E0E0; display: flex; align-items: center; gap: 12px; }
|
||||
.paicha-header .left { flex-shrink: 0; }
|
||||
.paicha-header .paicha-no { font-size: 24px; font-weight: 800; color: rgba(0,0,0,.87); }
|
||||
.paicha-header .paicha-no.placeholder { color: #BDBDBD; font-weight: 700; }
|
||||
.paicha-header .right { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: flex-end; gap: 3px; }
|
||||
.loc-info { display: flex; align-items: center; gap: 5px; font-size: 12px; color: #757575; }
|
||||
.loc-info .loc-code { font-weight: 600; color: #555; }
|
||||
.loc-chip { padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 700; white-space: nowrap; }
|
||||
.loc-chip.normal { background: rgba(33,150,243,.12); color: #1976D2; }
|
||||
.loc-chip.transit { background: rgba(255,152,0,.12); color: #E65100; }
|
||||
.stats { display: flex; align-items: center; gap: 5px; justify-content: flex-end; flex-wrap: wrap; }
|
||||
.stat-tag { padding: 1px 7px; border-radius: 10px; font-size: 10px; font-weight: 700; white-space: nowrap; }
|
||||
.stat-tag.total { background: #E3F2FD; color: #1565C0; }
|
||||
.stat-tag.shelved { background: #E8F5E9; color: #2E7D32; }
|
||||
.stat-tag.transferred { background: #FFF3E0; color: #E65100; }
|
||||
.stat-tag.pending { background: #F5F5F5; color: #757575; }
|
||||
.stat-tag.placeholder { background: #F5F5F5; color: #BDBDBD; }
|
||||
|
||||
/* ========== Table Head ========== */
|
||||
.table-head { height: 28px; padding: 0 12px; background: #EEEEEE; border-bottom: 1px solid #BDBDBD; display: flex; align-items: center; }
|
||||
.table-head span { font-size: 10px; font-weight: 700; color: #666; }
|
||||
.table-head .th1 { flex: 22; }
|
||||
.table-head .th2 { flex: 18; }
|
||||
.table-head .th3 { flex: 12; text-align: center; }
|
||||
.table-head .th4 { flex: 28; text-align: right; }
|
||||
|
||||
/* ========== List Body ========== */
|
||||
.list-body { flex: 1; overflow-y: auto; }
|
||||
|
||||
/* Table rows */
|
||||
.table-row { height: 36px; padding: 0 12px; display: flex; align-items: center; border-bottom: 1px solid #EEEEEE; position: relative; }
|
||||
.table-row .color-bar { position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 2px; }
|
||||
.table-row .cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.table-row .cell.c1 { flex: 22; font-size: 12px; font-weight: 600; color: rgba(0,0,0,.87); }
|
||||
.table-row .cell.c2 { flex: 18; font-size: 12px; color: rgba(0,0,0,.6); }
|
||||
.table-row .cell.c3 { flex: 12; font-size: 12px; color: rgba(0,0,0,.6); text-align: center; }
|
||||
.table-row .cell.c4 { flex: 28; font-size: 12px; font-weight: 600; color: rgba(0,0,0,.87); text-align: right; }
|
||||
|
||||
/* Scanned row — always green indicator, regardless of underlying status */
|
||||
.table-row.scanned { background: #F1F8E9; }
|
||||
.table-row.scanned .color-bar { background: #43A047; }
|
||||
.table-row.scanned .cell.c1 { font-weight: 700; }
|
||||
|
||||
/* Unscanned rows — status-based */
|
||||
.table-row.on_shelf { background: #fff; }
|
||||
.table-row.on_shelf .color-bar { background: #2196F3; }
|
||||
.table-row.transferred { background: #FFF8E1; }
|
||||
.table-row.transferred .color-bar { background: #FF9800; }
|
||||
.table-row.not_shelved { background: #FAFAFA; }
|
||||
.table-row.not_shelved .color-bar { background: #BDBDBD; }
|
||||
|
||||
/* Section divider */
|
||||
.section-divider {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 0 12px; height: 24px;
|
||||
border-bottom: 1px solid #EEEEEE;
|
||||
}
|
||||
.section-divider .line { flex: 1; height: 0; border-top: 1px dashed #CCC; }
|
||||
.section-divider .label { font-size: 10px; font-weight: 600; color: #9E9E9E; white-space: nowrap; letter-spacing: .3px; }
|
||||
|
||||
.overview-msg { display: flex; align-items: center; justify-content: center; font-size: 13px; color: #9E9E9E; font-style: italic; height: 100%; }
|
||||
|
||||
/* ========== Bottom Bar ========== */
|
||||
.bottom-bar { min-height: 50px; padding: 8px 12px; background: #fff; border-top: 1px solid #E0E0E0; display: flex; align-items: center; gap: 8px; }
|
||||
.submit-btn { flex: 1; height: 34px; border-radius: 6px; border: none; font-size: 14px; font-weight: 700; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background .15s; }
|
||||
.submit-btn.disabled { background: #E0E0E0; color: #9E9E9E; cursor: default; }
|
||||
.submit-btn.primary { background: #1976D2; color: #fff; }
|
||||
.submit-btn.transit { background: #FF9800; color: #fff; }
|
||||
.submit-btn .spinner { width: 18px; height: 18px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ========== Status Bar ========== */
|
||||
.status-bar { height: 28px; min-height: 28px; padding: 0 12px; background: #F5F5F5; display: flex; align-items: center; gap: 7px; border-top: 1px solid #E0E0E0; }
|
||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
||||
.status-dot.blue { background: #2196F3; }
|
||||
.status-dot.orange { background: #FF9800; }
|
||||
.status-dot.green { background: #4CAF50; }
|
||||
.status-dot.red { background: #F44336; }
|
||||
.status-dot.yellow { background: #FBC02D; }
|
||||
.status-text { font-size: 11px; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
|
||||
/* ========== Dialog ========== */
|
||||
.dialog-overlay { position: absolute; inset: 0; background: rgba(0,0,0,.4); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
.dialog-overlay.hidden { display: none; }
|
||||
.dialog { background: #FFEBEE; border-radius: 12px; padding: 20px; width: 300px; box-shadow: 0 8px 30px rgba(0,0,0,.25); }
|
||||
.dialog-title { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.dialog-title .icon { color: #F44336; font-size: 20px; }
|
||||
.dialog-title .text { font-size: 17px; font-weight: 600; color: #F44336; }
|
||||
.dialog-body { font-size: 13px; color: #333; line-height: 1.7; }
|
||||
.dialog-body strong { font-weight: 700; }
|
||||
.dialog-actions { margin-top: 14px; text-align: right; }
|
||||
.dialog-close { padding: 5px 14px; font-size: 13px; border: none; border-radius: 5px; background: transparent; color: #1976D2; cursor: pointer; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ==================== Sidebar ==================== -->
|
||||
<div class="sidebar">
|
||||
<div style="font-size:15px;font-weight:700;color:#1565C0;text-align:center;padding-bottom:8px;border-bottom:2px solid #1565C0;">
|
||||
上架登记 - 重构预览控制
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<div class="group-title">1. 锁定模式</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setState('locked',false)">未锁定</button>
|
||||
<button class="btn" onclick="setState('locked',true)">已锁定</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<div class="group-title">2. 货位</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setState('location',null)">无</button>
|
||||
<button class="btn" onclick="setState('location','normal')">普通 A-01-03</button>
|
||||
<button class="btn" onclick="setState('location','transit')">转运 TRANS-001</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<div class="group-title">3. 总排号</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setState('zongpai',0)">无</button>
|
||||
<button class="btn" onclick="setState('zongpai',1)">1 条</button>
|
||||
<button class="btn" onclick="setState('zongpai',3)">3 条</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<div class="group-title">4. 提交中</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setState('submitting',false)">空闲</button>
|
||||
<button class="btn" onclick="setState('submitting',true)">Loading</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<div class="group-title">5. 总览</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setState('overview','empty')">未扫描</button>
|
||||
<button class="btn" onclick="setState('overview','loaded')">正常</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<div class="group-title">6. 状态栏</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setState('status','auto')">自动</button>
|
||||
<button class="btn" onclick="setState('status','success')">上架成功</button>
|
||||
<button class="btn" onclick="setState('status','offShelf')">下架成功</button>
|
||||
<button class="btn" onclick="setState('status','batch')">批量成功(3条)</button>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn" onclick="setState('status','invalid')">无效码</button>
|
||||
<button class="btn" onclick="setState('status','network')">网络异常</button>
|
||||
<button class="btn" onclick="setState('status','fail')">提交失败</button>
|
||||
<button class="btn" onclick="setState('status','duplicate')">重复(弹窗)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== Device ==================== -->
|
||||
<div class="main">
|
||||
<div class="device" id="device">
|
||||
|
||||
<!-- AppBar -->
|
||||
<div class="appbar">
|
||||
<span class="appbar-title">上架登记</span>
|
||||
<span class="appbar-gap"></span>
|
||||
<button class="mode-pill unlocked" id="modePill">
|
||||
<span class="icon">⇄</span>
|
||||
<span id="modeLabel">未锁定</span>
|
||||
<span class="shortcut">P2</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="body">
|
||||
|
||||
<!-- Paicha Header -->
|
||||
<div class="paicha-header" id="paichaHeader">
|
||||
<div class="left">
|
||||
<span class="paicha-no placeholder" id="paichaNo">--</span>
|
||||
</div>
|
||||
<div class="right">
|
||||
<span class="loc-info" id="locInfo" style="display:none;"></span>
|
||||
<span class="stats" id="paichaStats"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table Head -->
|
||||
<div class="table-head" id="tableHead" style="display:none;">
|
||||
<span class="th1">总排号</span>
|
||||
<span class="th2">工令号</span>
|
||||
<span class="th3">数量</span>
|
||||
<span class="th4">货位号</span>
|
||||
</div>
|
||||
|
||||
<!-- List Body -->
|
||||
<div class="list-body" id="listBody">
|
||||
<div class="overview-msg">扫描总排号后自动显示排产号上架情况</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Bar -->
|
||||
<div class="bottom-bar">
|
||||
<button class="submit-btn disabled" id="submitBtn">
|
||||
<span id="submitText">确 认 上 架</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<div class="status-bar">
|
||||
<span class="status-dot blue" id="statusDot"></span>
|
||||
<span class="status-text" id="statusTextEl">等待扫描总排号或货位号…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog -->
|
||||
<div class="dialog-overlay hidden" id="dialogOverlay">
|
||||
<div class="dialog">
|
||||
<div class="dialog-title"><span class="icon">⚠</span><span class="text">重复上架</span></div>
|
||||
<div class="dialog-body">
|
||||
<div>总排号:<strong>26B35</strong></div>
|
||||
<div style="margin-top:3px;">已登记货位:<strong>A-02-05</strong></div>
|
||||
<div style="margin-top:3px;">登记时间:<strong>2026-05-19 14:30:22</strong></div>
|
||||
<div style="margin-top:10px;font-weight:700;">请核查实物,确认是否操作错误。</div>
|
||||
</div>
|
||||
<div class="dialog-actions"><button class="dialog-close">关闭</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const S = { locked: false, location: null, zongpai: 0, submitting: false, overview: 'empty', status: 'auto' };
|
||||
|
||||
const DATA = {
|
||||
paichaNo: 'R00015', totalCount: 5, shelvedCount: 2, transferredCount: 1, pendingCount: 2,
|
||||
items: [
|
||||
{ zp: '26B35', wo: '3-1', qty: 50, loc: 'A-01-03', status: 'on_shelf' },
|
||||
{ zp: '26B36', wo: '3-2-1', qty: 100, loc: 'A-01-05', status: 'on_shelf' },
|
||||
{ zp: '26B37', wo: '3-2-2', qty: 100, loc: null, status: 'not_shelved' },
|
||||
{ zp: '26B38', wo: '3-2-3', qty: 50, loc: 'TRANS-001', status: 'transferred' },
|
||||
{ zp: '26B39', wo: '3-3', qty: 45, loc: null, status: 'not_shelved' },
|
||||
]
|
||||
};
|
||||
|
||||
// scanned items are identified by index: when zongpai=1 → [0], zongpai=3 → [0,2,4]
|
||||
const SCANNED_INDICES = { 1: [0], 3: [0, 2, 4] };
|
||||
|
||||
const STATUS_MAP = {
|
||||
success: { dot: 'green', text: '上架成功' },
|
||||
offShelf: { dot: 'green', text: '下架成功' },
|
||||
batch: { dot: 'green', text: '批量上架成功(3 条)' },
|
||||
invalid: { dot: 'red', text: '无效码:ABCXYZ123' },
|
||||
network: { dot: 'yellow', text: '网络异常,请检查网络连接' },
|
||||
fail: { dot: 'red', text: '请求参数错误' },
|
||||
duplicate: { dot: 'red', text: '请确认信息并提交' },
|
||||
};
|
||||
|
||||
function setState(key, val) {
|
||||
S[key] = val;
|
||||
document.querySelectorAll('.btn').forEach(btn => {
|
||||
const m = btn.getAttribute('onclick')?.match(/setState\('(\w+)',\s*(.+)\)/);
|
||||
if (!m) return;
|
||||
btn.classList.toggle('active', String(val) === m[2].replace(/'/g, '') && m[1] === key);
|
||||
});
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderAppBar();
|
||||
renderPaichaHeader();
|
||||
renderList();
|
||||
renderBottomBar();
|
||||
renderStatusBar();
|
||||
renderDialog();
|
||||
}
|
||||
|
||||
function renderAppBar() {
|
||||
const pill = document.getElementById('modePill');
|
||||
const label = document.getElementById('modeLabel');
|
||||
pill.className = 'mode-pill ' + (S.locked ? 'locked' : 'unlocked');
|
||||
label.textContent = S.locked ? '货位锁定' : '未锁定';
|
||||
}
|
||||
|
||||
function renderPaichaHeader() {
|
||||
const no = document.getElementById('paichaNo');
|
||||
const locInfo = document.getElementById('locInfo');
|
||||
const stats = document.getElementById('paichaStats');
|
||||
const hasLoc = S.location !== null;
|
||||
const locCode = S.location === 'normal' ? 'A-01-03' : S.location === 'transit' ? 'TRANS-001' : null;
|
||||
|
||||
// Paicha number (left)
|
||||
if (S.overview === 'loaded') {
|
||||
no.textContent = DATA.paichaNo; no.className = 'paicha-no';
|
||||
} else {
|
||||
no.textContent = '--'; no.className = 'paicha-no placeholder';
|
||||
}
|
||||
|
||||
// Location info (right, top row)
|
||||
if (hasLoc) {
|
||||
locInfo.style.display = 'flex';
|
||||
locInfo.innerHTML = `<span class="loc-code">${locCode}</span><span class="loc-chip ${S.location}">${S.location === 'transit' ? '转运区域' : '普通货架'}</span>`;
|
||||
} else {
|
||||
locInfo.style.display = 'none';
|
||||
}
|
||||
|
||||
// Stats tags (right, bottom row)
|
||||
if (S.overview === 'loaded') {
|
||||
stats.innerHTML =
|
||||
`<span class="stat-tag total">共 ${DATA.totalCount}</span>` +
|
||||
`<span class="stat-tag shelved">上架 ${DATA.shelvedCount}</span>` +
|
||||
`<span class="stat-tag transferred">转运 ${DATA.transferredCount}</span>` +
|
||||
`<span class="stat-tag pending">待上架 ${DATA.pendingCount}</span>`;
|
||||
} else {
|
||||
stats.innerHTML =
|
||||
`<span class="stat-tag placeholder">共 -</span>` +
|
||||
`<span class="stat-tag placeholder">上架 -</span>` +
|
||||
`<span class="stat-tag placeholder">转运 -</span>` +
|
||||
`<span class="stat-tag placeholder">待上架 -</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const tableHead = document.getElementById('tableHead');
|
||||
const body = document.getElementById('listBody');
|
||||
|
||||
if (S.overview !== 'loaded') {
|
||||
tableHead.style.display = 'none';
|
||||
body.innerHTML = '<div class="overview-msg">扫描总排号后自动显示排产号上架情况</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
tableHead.style.display = 'flex';
|
||||
|
||||
// Determine which items are scanned
|
||||
const scannedSet = new Set(S.zongpai > 0 ? (SCANNED_INDICES[S.zongpai] || []) : []);
|
||||
|
||||
// Split into scanned and unscanned groups
|
||||
const scanned = [], unscanned = [];
|
||||
DATA.items.forEach((item, idx) => {
|
||||
if (scannedSet.has(idx)) scanned.push({ ...item, origIdx: idx });
|
||||
else unscanned.push({ ...item, origIdx: idx });
|
||||
});
|
||||
|
||||
let html = '';
|
||||
|
||||
// Scanned items (always at top)
|
||||
scanned.forEach(item => {
|
||||
html += rowHtml(item, true);
|
||||
});
|
||||
|
||||
// Divider between groups (only when both groups exist)
|
||||
if (scanned.length > 0 && unscanned.length > 0) {
|
||||
html += `<div class="section-divider">
|
||||
<span class="line"></span>
|
||||
<span class="label">以下 ${unscanned.length} 项未操作</span>
|
||||
<span class="line"></span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Unscanned items
|
||||
unscanned.forEach(item => {
|
||||
html += rowHtml(item, false);
|
||||
});
|
||||
|
||||
body.innerHTML = html;
|
||||
}
|
||||
|
||||
function rowHtml(item, isScanned) {
|
||||
const cls = isScanned ? 'scanned' : item.status;
|
||||
return `<div class="table-row ${cls}">
|
||||
<div class="color-bar"></div>
|
||||
<span class="cell c1" style="padding-left:10px;">${item.zp}</span>
|
||||
<span class="cell c2">${item.wo}</span>
|
||||
<span class="cell c3">${item.qty}</span>
|
||||
<span class="cell c4">${item.loc || '\u2014'}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderBottomBar() {
|
||||
const btn = document.getElementById('submitBtn');
|
||||
const text = document.getElementById('submitText');
|
||||
const hasLoc = S.location !== null, hasZp = S.zongpai > 0;
|
||||
const isTransit = S.location === 'transit';
|
||||
const canSubmit = hasLoc && hasZp && !S.submitting;
|
||||
|
||||
let label;
|
||||
if (S.submitting) { label = null; }
|
||||
else if (isTransit) { label = S.locked && S.zongpai > 1 ? `批量转运并凑箱(${S.zongpai} 条)` : '转运并装箱'; }
|
||||
else { label = S.locked && S.zongpai > 1 ? `批量上架(${S.zongpai} 条)` : '确 认 上 架'; }
|
||||
|
||||
if (S.submitting) {
|
||||
btn.className = 'submit-btn ' + (isTransit ? 'transit' : (canSubmit ? 'primary' : 'disabled'));
|
||||
text.innerHTML = '<span class="spinner"></span>';
|
||||
} else if (!canSubmit) {
|
||||
btn.className = 'submit-btn disabled'; text.textContent = label;
|
||||
} else {
|
||||
btn.className = 'submit-btn ' + (isTransit ? 'transit' : 'primary'); text.textContent = label;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatusBar() {
|
||||
const dot = document.getElementById('statusDot');
|
||||
const text = document.getElementById('statusTextEl');
|
||||
|
||||
if (S.status !== 'auto' && STATUS_MAP[S.status]) {
|
||||
const o = STATUS_MAP[S.status]; dot.className = 'status-dot ' + o.dot; text.textContent = o.text; return;
|
||||
}
|
||||
if (S.submitting) { dot.className = 'status-dot orange'; text.textContent = '正在提交…'; return; }
|
||||
const hasLoc = S.location !== null, hasZp = S.zongpai > 0;
|
||||
if (S.locked && hasLoc && !hasZp) { dot.className = 'status-dot blue'; text.textContent = '货位已锁定,请扫描下一张执行卡'; }
|
||||
else if (hasLoc && hasZp) { dot.className = 'status-dot blue'; text.textContent = '请确认信息并提交'; }
|
||||
else if (hasZp) { dot.className = 'status-dot blue'; text.textContent = '请扫描目标货位号'; }
|
||||
else if (hasLoc) { dot.className = 'status-dot blue'; text.textContent = '请扫描执行卡'; }
|
||||
else { dot.className = 'status-dot blue'; text.textContent = '等待扫描总排号或货位号…'; }
|
||||
}
|
||||
|
||||
function renderDialog() {
|
||||
document.getElementById('dialogOverlay').classList.toggle('hidden', S.status !== 'duplicate');
|
||||
}
|
||||
|
||||
document.querySelector('.dialog-close').addEventListener('click', () => setState('status', 'auto'));
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
901
docs/registration-ui-states.html
Normal file
901
docs/registration-ui-states.html
Normal file
@@ -0,0 +1,901 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>上架登记模块 - UI 状态全览</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, 'Segoe UI', 'Noto Sans SC', 'Roboto', sans-serif;
|
||||
background: #eef1f5; height: 100vh; display: flex; overflow: hidden;
|
||||
}
|
||||
|
||||
/* ========== Layout ========== */
|
||||
.sidebar {
|
||||
width: 310px; min-width: 310px; background: #fff; border-right: 1px solid #ddd;
|
||||
overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 14px;
|
||||
}
|
||||
.main {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center; padding: 20px;
|
||||
}
|
||||
|
||||
/* ========== Control Panel ========== */
|
||||
.group { border: 1px solid #e0e0e0; border-radius: 10px; padding: 12px; background: #fafbfc; }
|
||||
.group-title {
|
||||
font-size: 13px; font-weight: 700; color: #333; margin-bottom: 8px;
|
||||
padding-bottom: 6px; border-bottom: 1px solid #eee;
|
||||
}
|
||||
.btn-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
|
||||
.btn-row:last-child { margin-bottom: 0; }
|
||||
.btn {
|
||||
padding: 5px 10px; font-size: 12px; border: 1px solid #ccc; border-radius: 6px;
|
||||
background: #fff; cursor: pointer; transition: all .15s; white-space: nowrap;
|
||||
color: #333;
|
||||
}
|
||||
.btn:hover { background: #e8f0fe; border-color: #90b4f0; }
|
||||
.btn.active { background: #1a73e8; color: #fff; border-color: #1a73e8; }
|
||||
.btn.active:hover { background: #1565c0; }
|
||||
.label { font-size: 11px; color: #888; margin-bottom: 4px; margin-top: 4px; }
|
||||
|
||||
/* ========== Device Frame ========== */
|
||||
.device {
|
||||
width: 400px; height: 720px; border-radius: 18px; overflow: hidden;
|
||||
box-shadow: 0 6px 30px rgba(0,0,0,.22); background: #fff;
|
||||
display: flex; flex-direction: column; position: relative;
|
||||
border: 2px solid #222;
|
||||
}
|
||||
|
||||
/* ========== Flutter UI Simulation ========== */
|
||||
|
||||
/* AppBar */
|
||||
.fl-appbar {
|
||||
height: 56px; min-height: 56px; background: #1565C0; color: #fff;
|
||||
display: flex; align-items: center; padding: 0 4px 0 12px; gap: 0;
|
||||
}
|
||||
.fl-appbar-title { font-size: 18px; font-weight: 600; white-space: nowrap; }
|
||||
.fl-appbar-spacer { width: 12px; }
|
||||
.fl-lock-btn {
|
||||
flex: 1; height: 36px; border-radius: 8px; display: flex; align-items: center;
|
||||
justify-content: center; gap: 6px; cursor: pointer; font-size: 15px;
|
||||
font-weight: 800; color: #fff; border: none; transition: background .2s;
|
||||
}
|
||||
.fl-lock-btn.unlocked { background: #1976D2; }
|
||||
.fl-lock-btn.locked { background: #F57C00; }
|
||||
.fl-lock-btn .shortcut {
|
||||
font-size: 12px; font-weight: 700; opacity: .88; margin-left: 4px;
|
||||
}
|
||||
.fl-lock-btn .icon { font-size: 16px; }
|
||||
|
||||
/* Body */
|
||||
.fl-body { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
|
||||
/* Operation Area */
|
||||
.fl-op-area { padding: 10px 12px 8px; }
|
||||
.fl-section-title {
|
||||
font-size: 14px; font-weight: 700; color: #333; margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Input Panel - Single */
|
||||
.fl-input-single {
|
||||
min-height: 58px; padding: 10px; border-radius: 8px;
|
||||
display: flex; align-items: center; gap: 0;
|
||||
transition: border .2s;
|
||||
}
|
||||
.fl-input-single.empty { border: 1px solid #BDBDBD; }
|
||||
.fl-input-single.filled { border: 2px solid #4CAF50; }
|
||||
.fl-input-single .loc-part {
|
||||
flex: 5; display: flex; align-items: center; gap: 6px; min-width: 0;
|
||||
}
|
||||
.fl-input-single .loc-text {
|
||||
font-size: 18px; font-weight: 700; color: rgba(0,0,0,.87);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.fl-input-single .loc-text.placeholder { color: #9E9E9E; }
|
||||
.fl-input-single .divider {
|
||||
padding: 0 8px; font-size: 20px; color: #9E9E9E; flex-shrink: 0;
|
||||
}
|
||||
.fl-input-single .zp-part {
|
||||
flex: 3; text-align: right; min-width: 0;
|
||||
}
|
||||
.fl-input-single .zp-text {
|
||||
font-size: 18px; font-weight: 700; color: rgba(0,0,0,.87);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.fl-input-single .zp-text.placeholder { color: #9E9E9E; }
|
||||
|
||||
/* Input Panel - Locked */
|
||||
.fl-input-locked {
|
||||
min-height: 82px; padding: 8px 10px; border-radius: 8px;
|
||||
transition: border .2s;
|
||||
}
|
||||
.fl-input-locked.empty { border: 1px solid #BDBDBD; }
|
||||
.fl-input-locked.filled { border: 2px solid #4CAF50; }
|
||||
.fl-input-locked .loc-row {
|
||||
display: flex; align-items: center; gap: 6px; margin-bottom: 8px;
|
||||
}
|
||||
.fl-input-locked .loc-text {
|
||||
flex: 1; font-size: 18px; font-weight: 700; color: rgba(0,0,0,.87);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.fl-lock-icon { color: #FF9800; font-size: 16px; flex-shrink: 0; }
|
||||
.fl-chips-area { display: flex; flex-wrap: wrap; gap: 6px; min-height: 30px; }
|
||||
.fl-chips-area.empty-space { height: 24px; }
|
||||
|
||||
/* InputChip */
|
||||
.fl-chip {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 2px 4px 2px 10px; background: #E8E8E8; border-radius: 16px;
|
||||
font-size: 13px; font-weight: 500; color: #333;
|
||||
}
|
||||
.fl-chip-del {
|
||||
width: 18px; height: 18px; border-radius: 50%; border: none;
|
||||
background: rgba(0,0,0,.12); color: #555; font-size: 11px;
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Location Chip */
|
||||
.fl-loc-chip {
|
||||
padding: 2px 6px; border-radius: 4px; font-size: 11px; font-weight: 700;
|
||||
white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
.fl-loc-chip.normal { background: rgba(33,150,243,.15); color: #2196F3; }
|
||||
.fl-loc-chip.transit { background: rgba(255,152,0,.15); color: #FF9800; }
|
||||
|
||||
/* Submit Button */
|
||||
.fl-submit {
|
||||
width: 100%; height: 46px; border-radius: 8px; border: none;
|
||||
font-size: 17px; font-weight: 500; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin-top: 10px; transition: background .15s;
|
||||
}
|
||||
.fl-submit.disabled { background: #E0E0E0; color: #757575; cursor: default; }
|
||||
.fl-submit.primary { background: #1976D2; color: #fff; }
|
||||
.fl-submit.transit { background: #FF9800; color: #fff; }
|
||||
.fl-submit.submitting { cursor: wait; }
|
||||
|
||||
/* Spinner */
|
||||
.fl-spinner {
|
||||
width: 22px; height: 22px; border: 2.5px solid rgba(255,255,255,.3);
|
||||
border-top-color: #fff; border-radius: 50%;
|
||||
animation: fl-spin .8s linear infinite;
|
||||
}
|
||||
.fl-spinner.small {
|
||||
width: 16px; height: 16px; border-width: 2px;
|
||||
border-color: rgba(0,0,0,.08); border-top-color: #666;
|
||||
}
|
||||
@keyframes fl-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Divider */
|
||||
.fl-divider { height: 1px; background: #E0E0E0; }
|
||||
|
||||
/* Overview Section */
|
||||
.fl-overview { flex: 1; padding: 8px 12px; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.fl-overview-header-row {
|
||||
display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
|
||||
}
|
||||
.fl-overview-title { font-size: 14px; font-weight: 700; color: #333; flex: 1; }
|
||||
.fl-overview-body { flex: 1; overflow-y: auto; }
|
||||
|
||||
.fl-overview-msg {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100%; text-align: center; font-size: 13px; color: #757575;
|
||||
}
|
||||
.fl-overview-error {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
height: 100%; gap: 6px;
|
||||
}
|
||||
.fl-overview-error .msg { font-size: 13px; color: #F44336; text-align: center; }
|
||||
.fl-retry-btn {
|
||||
padding: 4px 12px; font-size: 13px; border: 1px solid #ccc; border-radius: 6px;
|
||||
background: #fff; cursor: pointer; color: #1976D2;
|
||||
}
|
||||
|
||||
/* Overview table */
|
||||
.fl-ov-info { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 6px; }
|
||||
.fl-ov-error-bar {
|
||||
padding-bottom: 6px; display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.fl-ov-error-bar .msg { flex: 1; font-size: 12px; color: #F44336; }
|
||||
.fl-table-head {
|
||||
height: 32px; padding: 0 6px; background: #EEEEEE;
|
||||
border: 1px solid #BDBDBD; display: flex; align-items: center;
|
||||
}
|
||||
.fl-table-head span { font-size: 12px; font-weight: 700; color: #333; }
|
||||
.fl-table-head .c1 { flex: 20; }
|
||||
.fl-table-head .c2 { flex: 20; }
|
||||
.fl-table-head .c3 { flex: 14; text-align: center; }
|
||||
.fl-table-head .c4 { flex: 26; text-align: right; }
|
||||
|
||||
.fl-table-row {
|
||||
min-height: 34px; padding: 6px; display: flex; align-items: center;
|
||||
border-left: 1px solid #E0E0E0; border-right: 1px solid #E0E0E0;
|
||||
border-bottom: 1px solid #E0E0E0;
|
||||
}
|
||||
.fl-table-row.current {
|
||||
border-left-color: #4CAF50; border-left-width: 2px;
|
||||
border-right-color: #4CAF50; border-right-width: 2px;
|
||||
border-bottom-color: #4CAF50; border-bottom-width: 2px;
|
||||
}
|
||||
.fl-table-row.bg-onshelf { background: #E3F2FD; }
|
||||
.fl-table-row.bg-transferred { background: #FFF3E0; }
|
||||
.fl-table-row.bg-default { background: #F5F5F5; }
|
||||
.fl-table-row.bg-white { background: #fff; }
|
||||
.fl-table-row span {
|
||||
font-size: 12px; color: rgba(0,0,0,.87); overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.fl-table-row span.bold { font-weight: 700; }
|
||||
.fl-table-row .c1 { flex: 20; }
|
||||
.fl-table-row .c2 { flex: 20; }
|
||||
.fl-table-row .c3 { flex: 14; text-align: center; }
|
||||
.fl-table-row .c4 { flex: 26; text-align: right; }
|
||||
|
||||
/* Status Bar */
|
||||
.fl-statusbar {
|
||||
height: 28px; min-height: 28px; padding: 0 12px;
|
||||
background: #F5F5F5; display: flex; align-items: center; gap: 7px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
.fl-status-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.fl-status-dot.blue { background: #2196F3; }
|
||||
.fl-status-dot.orange { background: #FF9800; }
|
||||
.fl-status-dot.green { background: #4CAF50; }
|
||||
.fl-status-dot.red { background: #F44336; }
|
||||
.fl-status-dot.yellow { background: #FBC02D; }
|
||||
.fl-status-dot.amber { background: #FFC107; }
|
||||
.fl-status-text { font-size: 11px; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
|
||||
/* Dialog overlay */
|
||||
.fl-dialog-overlay {
|
||||
position: absolute; inset: 0; background: rgba(0,0,0,.4);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||
}
|
||||
.fl-dialog-overlay.hidden { display: none; }
|
||||
.fl-dialog {
|
||||
background: #FFEBEE; border-radius: 12px; padding: 20px;
|
||||
width: 320px; box-shadow: 0 8px 30px rgba(0,0,0,.25);
|
||||
}
|
||||
.fl-dialog-title {
|
||||
display: flex; align-items: center; gap: 8px; margin-bottom: 14px;
|
||||
}
|
||||
.fl-dialog-title .icon { color: #F44336; font-size: 22px; }
|
||||
.fl-dialog-title .text { font-size: 18px; font-weight: 600; color: #F44336; }
|
||||
.fl-dialog-body { font-size: 14px; color: #333; line-height: 1.7; }
|
||||
.fl-dialog-body strong { font-weight: 700; }
|
||||
.fl-dialog-actions { margin-top: 16px; text-align: right; }
|
||||
.fl-dialog-close {
|
||||
padding: 6px 16px; font-size: 14px; border: none; border-radius: 6px;
|
||||
background: transparent; color: #1976D2; cursor: pointer; font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ==================== Sidebar Control Panel ==================== -->
|
||||
<div class="sidebar">
|
||||
|
||||
<div style="font-size:16px;font-weight:700;color:#1565C0;text-align:center;padding-bottom:10px;border-bottom:2px solid #1565C0;">
|
||||
上架登记 - UI 状态控制面板
|
||||
</div>
|
||||
|
||||
<!-- Lock Mode -->
|
||||
<div class="group">
|
||||
<div class="group-title">1. 锁定模式</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setLock(false)">未锁定</button>
|
||||
<button class="btn" onclick="setLock(true)">已锁定</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Type -->
|
||||
<div class="group">
|
||||
<div class="group-title">2. 货位类型</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setLocation(null)">无</button>
|
||||
<button class="btn" onclick="setLocation('normal')">普通货架 A-01-03</button>
|
||||
<button class="btn" onclick="setLocation('transit')">转运区域 TRANS-001</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zongpai Count -->
|
||||
<div class="group">
|
||||
<div class="group-title">3. 总排号数量</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setZongpai(0)">无</button>
|
||||
<button class="btn" onclick="setZongpai(1)">1 条</button>
|
||||
<button class="btn" onclick="setZongpai(3)">3 条</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submitting -->
|
||||
<div class="group">
|
||||
<div class="group-title">4. 提交中状态</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setSubmitting(false)">空闲</button>
|
||||
<button class="btn" onclick="setSubmitting(true)">提交中 (Loading)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overview State -->
|
||||
<div class="group">
|
||||
<div class="group-title">5. 排产总览区域</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setOverview('empty')">未扫描</button>
|
||||
<button class="btn" onclick="setOverview('loading')">加载中</button>
|
||||
<button class="btn" onclick="setOverview('notFound')">暂无排产信息</button>
|
||||
<button class="btn" onclick="setOverview('error')">加载失败</button>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn" onclick="setOverview('loaded')">正常显示</button>
|
||||
<button class="btn" onclick="setOverview('errorWithData')">失败(有缓存)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Bar Override -->
|
||||
<div class="group">
|
||||
<div class="group-title">6. 状态栏覆盖状态</div>
|
||||
<div class="label">点击后状态栏显示对应覆盖文本(持续显示至切换)</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setStatusBarOverride(null)">自动 (跟随基础状态)</button>
|
||||
</div>
|
||||
<div class="label">-- 扫描反馈 --</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn" onclick="setStatusBarOverride('invalid')">无效码</button>
|
||||
</div>
|
||||
<div class="label">-- 配置/操作错误 --</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn" onclick="setStatusBarOverride('noApi')">未配置API</button>
|
||||
<button class="btn" onclick="setStatusBarOverride('noLocation')">请先扫描货位号</button>
|
||||
<button class="btn" onclick="setStatusBarOverride('paichaMismatch')">排产号不一致</button>
|
||||
</div>
|
||||
<div class="label">-- 提交成功 --</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn" onclick="setStatusBarOverride('success')">上架成功</button>
|
||||
<button class="btn" onclick="setStatusBarOverride('successLocked')">锁定成功</button>
|
||||
<button class="btn" onclick="setStatusBarOverride('offShelf')">下架成功</button>
|
||||
<button class="btn" onclick="setStatusBarOverride('batchSuccess')">批量上架成功</button>
|
||||
<button class="btn" onclick="setStatusBarOverride('batchOffShelf')">批量下架成功</button>
|
||||
</div>
|
||||
<div class="label">-- 提交失败 --</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn" onclick="setStatusBarOverride('duplicate')">重复上架(弹窗)</button>
|
||||
<button class="btn" onclick="setStatusBarOverride('alreadyOffShelf')">已下架冲突</button>
|
||||
<button class="btn" onclick="setStatusBarOverride('networkError')">网络异常</button>
|
||||
<button class="btn" onclick="setStatusBarOverride('submitFail')">提交失败</button>
|
||||
<button class="btn" onclick="setStatusBarOverride('batchPartial')">批量部分失败</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog -->
|
||||
<div class="group">
|
||||
<div class="group-title">7. 弹窗</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setDialog(false)">关闭</button>
|
||||
<button class="btn" onclick="setDialog(true)">重复上架弹窗</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- State Summary -->
|
||||
<div class="group" style="background:#eef6ff;">
|
||||
<div class="group-title" style="color:#1565C0;">当前状态摘要</div>
|
||||
<div id="stateSummary" style="font-size:11px;color:#555;line-height:1.8;"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ==================== Device Simulation ==================== -->
|
||||
<div class="main">
|
||||
<div class="device" id="device">
|
||||
|
||||
<!-- AppBar -->
|
||||
<div class="fl-appbar">
|
||||
<span class="fl-appbar-title">上架登记</span>
|
||||
<span class="fl-appbar-spacer"></span>
|
||||
<button class="fl-lock-btn unlocked" id="lockBtn">
|
||||
<span class="icon">⇄</span>
|
||||
<span id="lockLabel">未锁定</span>
|
||||
<span class="shortcut">P2</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="fl-body">
|
||||
|
||||
<!-- Operation Area -->
|
||||
<div class="fl-op-area">
|
||||
<div class="fl-section-title">登记操作区</div>
|
||||
|
||||
<!-- Input Panel: Single Mode -->
|
||||
<div class="fl-input-single empty" id="singlePanel">
|
||||
<div class="loc-part">
|
||||
<span class="loc-text placeholder" id="singleLoc"></span>
|
||||
<span id="singleLocChip"></span>
|
||||
</div>
|
||||
<span class="divider">|</span>
|
||||
<div class="zp-part">
|
||||
<span class="zp-text placeholder" id="singleZp"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Panel: Locked Mode -->
|
||||
<div class="fl-input-locked empty" id="lockedPanel" style="display:none;">
|
||||
<div class="loc-row">
|
||||
<span class="loc-text" id="lockedLoc"></span>
|
||||
<span class="fl-lock-icon">🔒</span>
|
||||
<span id="lockedLocChip"></span>
|
||||
</div>
|
||||
<div class="fl-chips-area empty-space" id="chipArea"></div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button class="fl-submit disabled" id="submitBtn">
|
||||
<span id="submitText">确 认 上 架</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="fl-divider"></div>
|
||||
|
||||
<!-- Overview Section -->
|
||||
<div class="fl-overview">
|
||||
<div class="fl-overview-header-row">
|
||||
<span class="fl-overview-title">排产号货架总览</span>
|
||||
<span id="overviewSpinner" style="display:none;"><span class="fl-spinner small"></span></span>
|
||||
</div>
|
||||
<div class="fl-overview-body" id="overviewBody">
|
||||
<div class="fl-overview-msg">扫描总排号后自动显示排产号上架情况</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<div class="fl-statusbar">
|
||||
<span class="fl-status-dot blue" id="statusDot"></span>
|
||||
<span class="fl-status-text" id="statusText">等待扫描总排号或货位号…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog Overlay -->
|
||||
<div class="fl-dialog-overlay hidden" id="dialogOverlay">
|
||||
<div class="fl-dialog">
|
||||
<div class="fl-dialog-title">
|
||||
<span class="icon">⚠</span>
|
||||
<span class="text">重复上架</span>
|
||||
</div>
|
||||
<div class="fl-dialog-body">
|
||||
<div>总排号:<strong>26B35</strong></div>
|
||||
<div style="margin-top:4px;">已登记货位:<strong>A-02-05</strong></div>
|
||||
<div style="margin-top:4px;">登记时间:<strong>2026-05-19 14:30:22</strong></div>
|
||||
<div style="margin-top:12px;"><strong>请核查实物,确认是否操作错误。</strong></div>
|
||||
</div>
|
||||
<div class="fl-dialog-actions">
|
||||
<button class="fl-dialog-close">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ===================== State =====================
|
||||
const state = {
|
||||
locked: false,
|
||||
locationType: null, // null | 'normal' | 'transit'
|
||||
zongpaiCount: 0, // 0, 1, 3
|
||||
submitting: false,
|
||||
overviewState: 'empty',
|
||||
statusOverride: null,
|
||||
showDialog: false,
|
||||
};
|
||||
|
||||
const sampleData = {
|
||||
paichaNo: 'R00015',
|
||||
totalCount: 5,
|
||||
items: [
|
||||
{ zongpaiNo: '26B35', workOrderNo: '3-1', quantity: 50, locationCode: 'A-01-03', status: 'on_shelf' },
|
||||
{ zongpaiNo: '26B36', workOrderNo: '3-2-1', quantity: 100, locationCode: 'A-01-05', status: 'on_shelf' },
|
||||
{ zongpaiNo: '26B37', workOrderNo: '3-2-2', quantity: 100, locationCode: null, status: 'not_shelved' },
|
||||
{ zongpaiNo: '26B38', workOrderNo: '3-2-3', quantity: 50, locationCode: 'TRANS-001', status: 'transferred' },
|
||||
{ zongpaiNo: '26B39', workOrderNo: '3-3', quantity: 45, locationCode: null, status: 'not_shelved' },
|
||||
]
|
||||
};
|
||||
|
||||
const zongpaiSamples = ['26B35', '26B37', '26B39'];
|
||||
|
||||
// ===================== State Setters =====================
|
||||
function setLock(v) {
|
||||
state.locked = v;
|
||||
updateGroup('setLock', v);
|
||||
updateUI();
|
||||
}
|
||||
function setLocation(v) {
|
||||
state.locationType = v;
|
||||
updateGroup('setLocation', v);
|
||||
updateUI();
|
||||
}
|
||||
function setZongpai(v) {
|
||||
state.zongpaiCount = v;
|
||||
updateGroup('setZongpai', v);
|
||||
updateUI();
|
||||
}
|
||||
function setSubmitting(v) {
|
||||
state.submitting = v;
|
||||
updateGroup('setSubmitting', v);
|
||||
updateUI();
|
||||
}
|
||||
function setOverview(v) {
|
||||
state.overviewState = v;
|
||||
updateGroup('setOverview', v);
|
||||
updateUI();
|
||||
}
|
||||
|
||||
const statusOverrideMap = {
|
||||
invalid: { text: '无效码:ABCXYZ123', dot: 'red' },
|
||||
noApi: { text: '未配置 API 地址,请前往设置', dot: 'red' },
|
||||
noLocation: { text: '请先扫描货位号', dot: 'red' },
|
||||
paichaMismatch:{ text: '批量凑箱仅支持同排产号产品,请移除不属于同一批次的总排号', dot: 'red' },
|
||||
success: { text: '上架成功', dot: 'green' },
|
||||
successLocked: { text: '货位已锁定,请扫描下一张执行卡', dot: 'green' },
|
||||
offShelf: { text: '下架成功', dot: 'green' },
|
||||
batchSuccess: { text: '批量上架成功(3 条)', dot: 'green' },
|
||||
batchOffShelf: { text: '批量下架成功(3 条)', dot: 'green' },
|
||||
duplicate: { text: '请确认信息并提交', dot: 'red', dialog: true },
|
||||
alreadyOffShelf:{ text: '该总排号已下架至转运区域,不可重新上架', dot: 'red' },
|
||||
networkError: { text: '网络异常,请检查网络连接', dot: 'yellow' },
|
||||
submitFail: { text: '请求参数错误', dot: 'red' },
|
||||
batchPartial: { text: '成功 2 条,失败 1 条', dot: 'red' },
|
||||
};
|
||||
|
||||
function setStatusBarOverride(key) {
|
||||
state.statusOverride = key;
|
||||
updateGroup('setStatusBarOverride', key);
|
||||
// auto show dialog for duplicate
|
||||
if (key === 'duplicate') {
|
||||
state.showDialog = true;
|
||||
updateGroup('setDialog', true);
|
||||
}
|
||||
updateUI();
|
||||
}
|
||||
|
||||
function setDialog(v) {
|
||||
state.showDialog = v;
|
||||
updateGroup('setDialog', v);
|
||||
updateUI();
|
||||
}
|
||||
|
||||
// ===================== Button Group Helper =====================
|
||||
function updateGroup(fnName, activeValue) {
|
||||
// Find all buttons in the same group by looking at the onclick pattern
|
||||
const allBtns = document.querySelectorAll('.btn');
|
||||
allBtns.forEach(btn => {
|
||||
const onclick = btn.getAttribute('onclick') || '';
|
||||
if (onclick.includes(fnName + '(')) {
|
||||
// Extract the argument
|
||||
const match = onclick.match(fnName + '\\(([^)]+)\\)');
|
||||
if (match) {
|
||||
let arg = match[1].trim().replace(/'/g, '').replace(/"/g, '');
|
||||
let isActive = false;
|
||||
if (arg === String(activeValue) ||
|
||||
(activeValue === null && arg === 'null') ||
|
||||
(activeValue === true && arg === 'true') ||
|
||||
(activeValue === false && arg === 'false')) {
|
||||
isActive = true;
|
||||
}
|
||||
if (isActive) {
|
||||
btn.classList.add('active');
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===================== UI Update =====================
|
||||
function updateUI() {
|
||||
updateAppBar();
|
||||
updateInputPanel();
|
||||
updateSubmitButton();
|
||||
updateOverview();
|
||||
updateStatusBar();
|
||||
updateDialogVisibility();
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
function updateAppBar() {
|
||||
const btn = document.getElementById('lockBtn');
|
||||
const label = document.getElementById('lockLabel');
|
||||
if (state.locked) {
|
||||
btn.className = 'fl-lock-btn locked';
|
||||
label.textContent = '货位锁定';
|
||||
} else {
|
||||
btn.className = 'fl-lock-btn unlocked';
|
||||
label.textContent = '未锁定';
|
||||
}
|
||||
}
|
||||
|
||||
function updateInputPanel() {
|
||||
const singlePanel = document.getElementById('singlePanel');
|
||||
const lockedPanel = document.getElementById('lockedPanel');
|
||||
const hasLoc = state.locationType !== null;
|
||||
const hasZp = state.zongpaiCount > 0;
|
||||
const locCode = state.locationType === 'normal' ? 'A-01-03' : state.locationType === 'transit' ? 'TRANS-001' : null;
|
||||
const locFilled = hasLoc || hasZp;
|
||||
|
||||
if (!state.locked) {
|
||||
// Single mode
|
||||
singlePanel.style.display = 'flex';
|
||||
lockedPanel.style.display = 'none';
|
||||
|
||||
singlePanel.className = 'fl-input-single ' + (locFilled ? 'filled' : 'empty');
|
||||
|
||||
const singleLoc = document.getElementById('singleLoc');
|
||||
const singleLocChip = document.getElementById('singleLocChip');
|
||||
const singleZp = document.getElementById('singleZp');
|
||||
|
||||
if (hasLoc) {
|
||||
singleLoc.textContent = locCode;
|
||||
singleLoc.className = 'loc-text';
|
||||
singleLocChip.innerHTML = buildLocChip(state.locationType);
|
||||
} else {
|
||||
singleLoc.textContent = '';
|
||||
singleLoc.className = 'loc-text placeholder';
|
||||
singleLocChip.innerHTML = '';
|
||||
}
|
||||
|
||||
if (hasZp) {
|
||||
singleZp.textContent = zongpaiSamples[0];
|
||||
singleZp.className = 'zp-text';
|
||||
} else {
|
||||
singleZp.textContent = '';
|
||||
singleZp.className = 'zp-text placeholder';
|
||||
}
|
||||
} else {
|
||||
// Locked mode
|
||||
singlePanel.style.display = 'none';
|
||||
lockedPanel.style.display = 'block';
|
||||
|
||||
lockedPanel.className = 'fl-input-locked ' + (locFilled ? 'filled' : 'empty');
|
||||
|
||||
const lockedLoc = document.getElementById('lockedLoc');
|
||||
const lockedLocChip = document.getElementById('lockedLocChip');
|
||||
const chipArea = document.getElementById('chipArea');
|
||||
|
||||
if (hasLoc) {
|
||||
lockedLoc.textContent = locCode;
|
||||
lockedLocChip.innerHTML = buildLocChip(state.locationType);
|
||||
} else {
|
||||
lockedLoc.textContent = '';
|
||||
lockedLocChip.innerHTML = '';
|
||||
}
|
||||
|
||||
if (state.zongpaiCount === 0) {
|
||||
chipArea.className = 'fl-chips-area empty-space';
|
||||
chipArea.innerHTML = '';
|
||||
} else {
|
||||
chipArea.className = 'fl-chips-area';
|
||||
let html = '';
|
||||
for (let i = 0; i < state.zongpaiCount; i++) {
|
||||
html += `<span class="fl-chip">${zongpaiSamples[i]}<span class="fl-chip-del">x</span></span>`;
|
||||
}
|
||||
chipArea.innerHTML = html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildLocChip(type) {
|
||||
if (!type) return '';
|
||||
const cls = type === 'transit' ? 'transit' : 'normal';
|
||||
const label = type === 'transit' ? '转运区域' : '普通货架';
|
||||
return `<span class="fl-loc-chip ${cls}">${label}</span>`;
|
||||
}
|
||||
|
||||
function updateSubmitButton() {
|
||||
const btn = document.getElementById('submitBtn');
|
||||
const textEl = document.getElementById('submitText');
|
||||
const hasLoc = state.locationType !== null;
|
||||
const hasZp = state.zongpaiCount > 0;
|
||||
const isTransit = state.locationType === 'transit';
|
||||
const canSubmit = hasLoc && hasZp && !state.submitting;
|
||||
|
||||
// Determine label
|
||||
let label;
|
||||
if (state.submitting) {
|
||||
label = null; // show spinner
|
||||
} else if (!canSubmit) {
|
||||
if (isTransit) {
|
||||
label = state.locked && state.zongpaiCount > 1
|
||||
? `批量转运并凑箱(${state.zongpaiCount} 条)`
|
||||
: '转运并装箱';
|
||||
} else {
|
||||
label = state.locked && state.zongpaiCount > 1
|
||||
? `批量上架(${state.zongpaiCount} 条)`
|
||||
: '确 认 上 架';
|
||||
}
|
||||
} else {
|
||||
if (isTransit) {
|
||||
label = state.locked && state.zongpaiCount > 1
|
||||
? `批量转运并凑箱(${state.zongpaiCount} 条)`
|
||||
: '转运并装箱';
|
||||
} else {
|
||||
label = state.locked && state.zongpaiCount > 1
|
||||
? `批量上架(${state.zongpaiCount} 条)`
|
||||
: '确 认 上 架';
|
||||
}
|
||||
}
|
||||
|
||||
// Determine style
|
||||
let cls = 'fl-submit';
|
||||
if (state.submitting) {
|
||||
cls += isTransit ? ' transit submitting' : (canSubmit || (hasLoc && hasZp) ? ' primary submitting' : ' disabled submitting');
|
||||
} else if (!canSubmit) {
|
||||
cls += ' disabled';
|
||||
} else if (isTransit) {
|
||||
cls += ' transit';
|
||||
} else {
|
||||
cls += ' primary';
|
||||
}
|
||||
|
||||
btn.className = cls;
|
||||
|
||||
if (state.submitting) {
|
||||
textEl.innerHTML = '<span class="fl-spinner"></span>';
|
||||
} else {
|
||||
textEl.textContent = label;
|
||||
}
|
||||
}
|
||||
|
||||
function updateOverview() {
|
||||
const body = document.getElementById('overviewBody');
|
||||
const spinner = document.getElementById('overviewSpinner');
|
||||
|
||||
switch (state.overviewState) {
|
||||
case 'empty':
|
||||
spinner.style.display = 'none';
|
||||
body.innerHTML = '<div class="fl-overview-msg">扫描总排号后自动显示排产号上架情况</div>';
|
||||
break;
|
||||
case 'loading':
|
||||
spinner.style.display = 'inline-block';
|
||||
body.innerHTML = '<div class="fl-overview-msg">正在加载排产号上架情况…</div>';
|
||||
break;
|
||||
case 'notFound':
|
||||
spinner.style.display = 'none';
|
||||
body.innerHTML = '<div class="fl-overview-msg">暂无排产信息</div>';
|
||||
break;
|
||||
case 'error':
|
||||
spinner.style.display = 'none';
|
||||
body.innerHTML = '<div class="fl-overview-error"><span class="msg">加载失败,点击重试</span><button class="fl-retry-btn">重试</button></div>';
|
||||
break;
|
||||
case 'loaded':
|
||||
spinner.style.display = 'none';
|
||||
renderOverviewTable(body, false);
|
||||
break;
|
||||
case 'errorWithData':
|
||||
spinner.style.display = 'none';
|
||||
renderOverviewTable(body, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function renderOverviewTable(container, withError) {
|
||||
let html = '';
|
||||
if (withError) {
|
||||
html += `<div class="fl-ov-error-bar">
|
||||
<span class="msg">加载失败,点击重试</span>
|
||||
<button class="fl-retry-btn">重试</button>
|
||||
</div>`;
|
||||
}
|
||||
html += `<div class="fl-ov-info">${sampleData.paichaNo} 共 ${sampleData.totalCount} 个总排号</div>`;
|
||||
html += `<div class="fl-table-head">
|
||||
<span class="c1">总排号</span>
|
||||
<span class="c2">工令号</span>
|
||||
<span class="c3">数量</span>
|
||||
<span class="c4">货位号</span>
|
||||
</div>`;
|
||||
|
||||
sampleData.items.forEach((item, idx) => {
|
||||
const isCurrent = idx === 0; // first item is current
|
||||
let bgCls = 'bg-white';
|
||||
if (item.status === 'on_shelf') bgCls = 'bg-onshelf';
|
||||
else if (item.status === 'transferred') bgCls = 'bg-transferred';
|
||||
else bgCls = 'bg-default';
|
||||
|
||||
const rowCls = `fl-table-row ${bgCls}` + (isCurrent ? ' current' : '');
|
||||
const boldCls = isCurrent ? ' bold' : '';
|
||||
html += `<div class="${rowCls}">
|
||||
<span class="c1${boldCls}">${item.zongpaiNo}</span>
|
||||
<span class="c2${boldCls}">${item.workOrderNo || '--'}</span>
|
||||
<span class="c3${boldCls}">${item.quantity}</span>
|
||||
<span class="c4${boldCls}">${item.locationCode || '\u2014'}</span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function updateStatusBar() {
|
||||
const dot = document.getElementById('statusDot');
|
||||
const text = document.getElementById('statusText');
|
||||
|
||||
// Check override first
|
||||
if (state.statusOverride && statusOverrideMap[state.statusOverride]) {
|
||||
const ov = statusOverrideMap[state.statusOverride];
|
||||
dot.className = 'fl-status-dot ' + ov.dot;
|
||||
text.textContent = ov.text;
|
||||
return;
|
||||
}
|
||||
|
||||
// Base status (mirrors _updateBaseStatus)
|
||||
if (state.submitting) {
|
||||
dot.className = 'fl-status-dot orange';
|
||||
text.textContent = '正在提交…';
|
||||
return;
|
||||
}
|
||||
if (state.locked && state.locationType !== null && state.zongpaiCount === 0) {
|
||||
dot.className = 'fl-status-dot blue';
|
||||
text.textContent = '货位已锁定,请扫描下一张执行卡';
|
||||
return;
|
||||
}
|
||||
const hasZp = state.zongpaiCount > 0;
|
||||
const hasLoc = state.locationType !== null;
|
||||
if (hasZp && hasLoc) {
|
||||
dot.className = 'fl-status-dot blue';
|
||||
text.textContent = '请确认信息并提交';
|
||||
} else if (hasZp) {
|
||||
dot.className = 'fl-status-dot blue';
|
||||
text.textContent = '请扫描目标货位号';
|
||||
} else if (hasLoc) {
|
||||
dot.className = 'fl-status-dot blue';
|
||||
text.textContent = '请扫描执行卡';
|
||||
} else {
|
||||
dot.className = 'fl-status-dot blue';
|
||||
text.textContent = '等待扫描总排号或货位号…';
|
||||
}
|
||||
}
|
||||
|
||||
function updateDialogVisibility() {
|
||||
const overlay = document.getElementById('dialogOverlay');
|
||||
if (state.showDialog) {
|
||||
overlay.classList.remove('hidden');
|
||||
} else {
|
||||
overlay.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function updateSummary() {
|
||||
const el = document.getElementById('stateSummary');
|
||||
const locLabel = state.locationType === 'normal' ? '普通货架' : state.locationType === 'transit' ? '转运区域' : '无';
|
||||
const ovLabels = {
|
||||
empty: '未扫描', loading: '加载中', notFound: '暂无信息',
|
||||
error: '加载失败', loaded: '正常显示', errorWithData: '失败(有缓存)'
|
||||
};
|
||||
const hasLoc = state.locationType !== null;
|
||||
const hasZp = state.zongpaiCount > 0;
|
||||
const canSubmit = hasLoc && hasZp && !state.submitting;
|
||||
|
||||
el.innerHTML = `
|
||||
<div><b>锁定:</b>${state.locked ? '已锁定' : '未锁定'}</div>
|
||||
<div><b>货位:</b>${locLabel}</div>
|
||||
<div><b>总排号:</b>${state.zongpaiCount === 0 ? '无' : state.zongpaiCount + ' 条'}</div>
|
||||
<div><b>提交中:</b>${state.submitting ? '是' : '否'}</div>
|
||||
<div><b>可提交:</b>${canSubmit ? '是' : '否'}</div>
|
||||
<div><b>总览:</b>${ovLabels[state.overviewState]}</div>
|
||||
<div><b>状态覆盖:</b>${state.statusOverride || '(自动)'}</div>
|
||||
<div><b>弹窗:</b>${state.showDialog ? '显示中' : '无'}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Close dialog via button
|
||||
document.querySelector('.fl-dialog-close').addEventListener('click', () => {
|
||||
setDialog(false);
|
||||
});
|
||||
|
||||
// Initial render
|
||||
updateUI();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
2104
docs/single-code-boxing-ui-states.html
Normal file
2104
docs/single-code-boxing-ui-states.html
Normal file
File diff suppressed because it is too large
Load Diff
1506
docs/single-code-boxing-ui-v2.html
Normal file
1506
docs/single-code-boxing-ui-v2.html
Normal file
File diff suppressed because it is too large
Load Diff
279
docs/单码装箱UI重构PRD.md
Normal file
279
docs/单码装箱UI重构PRD.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# 单码装箱页面 UI 重构 PRD
|
||||
|
||||
**文档版本**:v2.0
|
||||
**更新日期**:2026-05-18
|
||||
**涉及页面**:`lib/pages/boxing_page.dart`(BoxingMode.singleCode)
|
||||
**参考设计**:多码凑箱 UI v2(`multi-code-boxing-ui-v2.html`)
|
||||
**产出物**:`single-code-boxing-ui-v2.html`
|
||||
|
||||
---
|
||||
|
||||
## 一、背景与目标
|
||||
|
||||
原单码装箱页面各区块自然堆叠,当已分配记录增多或通知条出现时,下方输入区会被挤出屏幕可视范围,操作体验差。同时,原版与多码凑箱在视觉语言上存在差异,两种模式切换后用户需要重新适应布局。
|
||||
|
||||
**重构目标:**
|
||||
|
||||
1. 与多码凑箱采用统一的设计语言,降低双模式切换的认知成本
|
||||
2. 采用固定顶部 + 可滚动中间 + 固定底栏结构,无论已分配记录多少,输入区始终可见
|
||||
3. 在不损失信息量的前提下,压缩各区块高度,让屏幕展示更多有效内容
|
||||
4. 去掉返回按钮,简化底栏操作区
|
||||
|
||||
---
|
||||
|
||||
## 二、整体布局结构
|
||||
|
||||
### v1(原版)布局
|
||||
|
||||
```
|
||||
AppBar(~52px)
|
||||
扫码卡片(~52px)
|
||||
信息区 - 排产号+工单+件数(~30px)
|
||||
信息区 - 已有箱数+最大箱号+详情(~28px)
|
||||
分隔线
|
||||
已分配列表(自然高度,不可滚动)
|
||||
输入区 - 箱号+数量+确认(~48px)
|
||||
操作行 - 返回按钮(~48px)
|
||||
状态栏(~38px)
|
||||
```
|
||||
|
||||
> 问题:各区全部占据固定高度,列表一旦有 3 条以上记录,输入区就会被推出屏幕,需要手动滚动才能操作。
|
||||
|
||||
### v2(重构后)布局
|
||||
|
||||
```
|
||||
AppBar(44px) ← 固定顶部
|
||||
[通知条 × N](每条 ~30px) ← 可选,固定
|
||||
扫码条(38px) ← 固定
|
||||
排产信息栏(~52px) ← 固定
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
已分配列表(弹性高度) ← 可滚动区域
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
输入行(~50px) ← 固定底栏
|
||||
状态栏(28px) ← 固定底栏
|
||||
```
|
||||
|
||||
输入行与状态栏固定于底部,任何情况下都不会因列表增长而被遮挡。
|
||||
|
||||
---
|
||||
|
||||
## 三、各区块改动详情
|
||||
|
||||
### 3.1 AppBar
|
||||
|
||||
| 项目 | v1 | v2 |
|
||||
|---|---|---|
|
||||
| 高度 | ~52px | **44px** |
|
||||
| 标题 | `装箱编号`,大号字体 | `装箱编号`,15px/700 |
|
||||
| 模式标识 | 独占一行的大模式按钮区 | 右侧蓝色 Pill `⇄ 单码装箱 P2`,与标题同行 |
|
||||
| 模式颜色 | 蓝色 | **蓝色**(单码),橙色(多码),与多码对称 |
|
||||
|
||||
Pill 点击或按 P2 物理键切换模式,行为不变。
|
||||
|
||||
---
|
||||
|
||||
### 3.2 通知条(Notice Banner)
|
||||
|
||||
替代原版内嵌式 `banner` 组件,统一为横条形通知,可多条叠加显示。
|
||||
|
||||
| 颜色 | 触发场景 |
|
||||
|---|---|
|
||||
| 🟠 橙色 | 正在同步转运数据 |
|
||||
| 🟡 琥珀色 | 箱号重复警告 / 忽略无效码 |
|
||||
| 🔵 蓝色 | 排产号切换提示 |
|
||||
| 🟢 绿色 | 全部装箱完毕提示 |
|
||||
| 🔴 红色 | 数量超限 / 提交失败 / 严重错误 |
|
||||
|
||||
每条高度约 **30px**(含 6px 上下 padding),字号 12px,图标 + 文字,`border-bottom: 1px` 与下方区域隔开。
|
||||
|
||||
---
|
||||
|
||||
### 3.3 扫码条
|
||||
|
||||
| 项目 | v1 | v2 |
|
||||
|---|---|---|
|
||||
| 高度 | ~52px 卡片 | **38px** 横条 |
|
||||
| 等待态 | 灰色圆角卡片,居中文字 | 灰底横条,📷 图标 + 文字 |
|
||||
| 已识别态 | 绿色边框卡片 + 大号总排号 | 绿底横条,✓ 绿圆 + 总排号 + `已识别` 徽章 |
|
||||
|
||||
---
|
||||
|
||||
### 3.4 排产信息栏(info-bar)
|
||||
|
||||
原版将排产号/工单/件数与已有箱数分为两个分散区块,共约 80px。v2 合并为一个紧凑的 header-bar,高度约 **52px**。左侧包含三层信息,右侧固定放置醒目的详情按钮:
|
||||
|
||||
```
|
||||
左侧第一层:[排产号] [工单号] [ERP总件数] 右侧:[📋 详情]
|
||||
左侧第二层:[进度条 ████████░░░░░░░░] ← 新增
|
||||
左侧第三层:[已有X箱 · 最大箱号Y · 已装A/B]
|
||||
```
|
||||
|
||||
**新增:装箱进度条**
|
||||
|
||||
- 高度 3px,不单独占行,嵌在第一层与第三层之间
|
||||
- 进度 = 已装件数 / ERP 总件数
|
||||
- 未完成时为蓝色(`#1565c0`),全部装完后变绿色(`#2e7d32`)
|
||||
|
||||
**详情入口**
|
||||
|
||||
- 放在排产信息栏最右侧,与三层信息垂直居中
|
||||
- 使用图标 + 文案的描边按钮样式,正常态为蓝色浅底,空状态置灰不可点击
|
||||
|
||||
**空状态**(未扫码)时,所有字段显示为灰色占位符 `—`,详情按钮置灰不可点击。
|
||||
|
||||
---
|
||||
|
||||
### 3.5 已分配列表
|
||||
|
||||
原版已分配记录直接堆叠在页面中间,无法滚动。v2 改为**弹性高度可滚动区域**,与多码凑箱的「已装入本箱」列表结构完全一致。
|
||||
|
||||
**列表头(sticky,滚动时吸顶):**
|
||||
|
||||
```
|
||||
已分配(此总排号) 2 条
|
||||
```
|
||||
|
||||
**每行结构(高度 40px):**
|
||||
|
||||
```
|
||||
[序号] [彩色竖条] [箱号 / 来源标注] [件数] [✏️] [🗑]
|
||||
```
|
||||
|
||||
| 元素 | 说明 |
|
||||
|---|---|
|
||||
| 序号 | 10px 灰色,右对齐 |
|
||||
| 彩色竖条 | 宽 3px,高 22px;绿=正常,琥珀=编辑中,红=待删除 |
|
||||
| 箱号 | 13px/700,如「3 号箱」 |
|
||||
| 来源标注 | 10px 灰色,如「本次扫码」 |
|
||||
| 件数 | 13px/600,右对齐 |
|
||||
| 操作按钮 | ✏️ 编辑 / 🗑 删除,各 28×28px |
|
||||
|
||||
**空状态**文案:`扫码后显示已分配箱号记录` / `本次扫码尚未分配箱号`
|
||||
|
||||
**全部装箱完毕**时,顶部显示绿色通知 `已全部装箱完毕,无需操作`,进度条变为绿色满格,输入区禁用;已分配列表继续显示具体箱号和件数明细,避免用户只能看到完成提示却无法核对装箱结果。
|
||||
|
||||
#### 行内编辑态
|
||||
|
||||
点击 ✏️ 后该行变为编辑态(琥珀色背景 `#fff8e1`),件数文字变为输入框,操作按钮变为 ✓ 保存 / ✕ 取消。其他行保持正常可交互。
|
||||
|
||||
#### 删除确认态
|
||||
|
||||
点击 🗑 后该行背景变红(`#ffebee`),行下方展开一条 28px 确认条:
|
||||
|
||||
```
|
||||
确认删除 X 号箱记录? [删除] [取消]
|
||||
```
|
||||
|
||||
不弹出 Dialog,行内展开,减少视觉干扰。
|
||||
|
||||
---
|
||||
|
||||
### 3.6 固定底栏(输入区)
|
||||
|
||||
原版输入区含三个独立行:箱号+数量+确认(~48px)、返回按钮(~48px)。
|
||||
|
||||
**v2 改动:**
|
||||
|
||||
- 箱号、数量、确认按钮**合并为单行**(高度 ~50px)
|
||||
- **移除返回按钮**
|
||||
- 正在提交时:两个输入框禁用,确认按钮替换为 loading 转圈
|
||||
|
||||
**输入行布局:**
|
||||
|
||||
```
|
||||
[箱号] [ 8 ] [数量] [ 50 ] [确认]
|
||||
```
|
||||
|
||||
| 元素 | 规格 |
|
||||
|---|---|
|
||||
| 标签 | 11px/700,紧贴输入框左侧 |
|
||||
| 箱号输入 | 宽 52px,高 34px,居中,18px/800 |
|
||||
| 数量输入 | 宽 70px,高 34px,居中,16px/800 |
|
||||
| 确认按钮 | 高 34px,蓝底白字,14px/700 |
|
||||
|
||||
**输入状态对应边框色:**
|
||||
|
||||
| 状态 | 箱号输入框边框 | 数量输入框边框 | 确认按钮 |
|
||||
|---|---|---|---|
|
||||
| 正常可提交 | 蓝色 | 蓝色 | 可点击 |
|
||||
| 箱号重复 | **琥珀色** | 正常 | 禁用 |
|
||||
| 数量超限 | 正常 | **红色** | 禁用 |
|
||||
| 提交中 | 禁用灰 | 禁用灰 | loading 转圈 |
|
||||
| 等待扫码 | 禁用灰 | 禁用灰 | 禁用 |
|
||||
|
||||
**边界规则:** 当提交后刚好装完(剩余数量变为 0)时,输入框中残留的上一次提交数量不应触发“数量超限”;此时按“全部装箱完毕”状态处理,确认按钮禁用。
|
||||
|
||||
---
|
||||
|
||||
### 3.7 状态栏
|
||||
|
||||
| 项目 | v1 | v2 |
|
||||
|---|---|---|
|
||||
| 高度 | ~38px | **28px** |
|
||||
| 内容 | 状态圆点 + 文字 | 同,字号压缩至 11px |
|
||||
| 背景 | `#EEEEEE` | `#f5f5f5` |
|
||||
|
||||
**圆点颜色语义(与多码统一):**
|
||||
|
||||
| 颜色 | 状态 |
|
||||
|---|---|
|
||||
| 🔵 蓝 | 等待/空闲/数量已填入 |
|
||||
| 🟠 橙 | 提交中 / 同步中 |
|
||||
| 🟢 绿 | 操作成功 / 全部装完 |
|
||||
| 🔴 红 | 错误(无效码 / 超限 / 提交失败) |
|
||||
| 🟡 琥珀 | 警告(箱号重复 / 编辑中) |
|
||||
|
||||
---
|
||||
|
||||
## 四、场景状态清单
|
||||
|
||||
| # | 场景 | 通知条 | 扫码条 | 列表 | 输入行 | 状态栏点 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | 等待扫码 | 无 | 灰色等待 | 空 | 全禁用 | 🔵 |
|
||||
| 2 | 已扫码(无历史分配) | 无 | 绿色已识别 | 空 | 自动填充,可编辑 | 🔵 |
|
||||
| 3 | 已扫码 + 已有分配记录 | 无 | 绿色已识别 | 显示分配行 | 自动填充下一箱 | 🔵 |
|
||||
| 4 | 已分配行 — 编辑中 | 无 | 绿色已识别 | 目标行琥珀背景 + 输入 | 正常 | 🟡 |
|
||||
| 5 | 已分配行 — 删除确认 | 无 | 绿色已识别 | 目标行红背景 + 确认条 | 正常 | 🔴 |
|
||||
| 6 | 正在提交 | 无 | 绿色已识别 | 正常 | 全禁用 + loading | 🟠 |
|
||||
| 7 | 箱号重复 | 🟡 琥珀警告 | 绿色已识别 | 正常 | 箱号框红框,确认禁用 | 🟡 |
|
||||
| 8 | 数量超限 | 🔴 红色警告 | 绿色已识别 | 正常 | 数量框红框,确认禁用 | 🔴 |
|
||||
| 9 | 全部装箱完毕 | 🟢 绿色提示 | 绿色已识别 | 显示已分配明细 | 全禁用,进度条全绿 | 🟢 |
|
||||
| 10 | 自动同步中 | 🟠 同步 + 🟡 忽略 | 绿色已识别 | 正常,不可操作 | 全禁用 | 🟠 |
|
||||
|
||||
---
|
||||
|
||||
## 五、垂直空间节省汇总
|
||||
|
||||
| 区块 | v1 高度 | v2 高度 | 节省 |
|
||||
|---|---|---|---|
|
||||
| AppBar | ~52px | 44px | **8px** |
|
||||
| 扫码区 | ~52px | 38px | **14px** |
|
||||
| 排产信息 | ~80px(两块) | ~52px(合并)| **28px** |
|
||||
| 已分配行/条 | ~52px/条 | 40px/条 | **12px/条** |
|
||||
| 通知条 | ~36px | ~30px | **6px/条** |
|
||||
| 操作行(返回) | ~48px | 0(已移除)| **48px** |
|
||||
| 状态栏 | ~38px | 28px | **10px** |
|
||||
|
||||
> 固定区域合计节省约 **114px**,相当于屏幕高度约 1/7,可多展示约 **2~3 条**已分配记录。
|
||||
|
||||
---
|
||||
|
||||
## 六、与多码凑箱的设计统一点
|
||||
|
||||
以下组件与多码凑箱在样式、尺寸、行为上完全一致,无差异:
|
||||
|
||||
- AppBar 高度与 Pill 组件(颜色区分模式)
|
||||
- 通知条(Notice Banner)的结构与色彩语义
|
||||
- 已分配/已装入列表的行高、彩色竖条、序号、操作按钮
|
||||
- 行内编辑(输入框 + ✓/✕)与删除确认条
|
||||
- 状态栏高度、圆点颜色语义
|
||||
- 底栏的 loading 转圈形态
|
||||
|
||||
---
|
||||
|
||||
## 七、不在本次范围内的改动
|
||||
|
||||
- 装箱详情页(`boxing_detail_page.dart`)布局不变
|
||||
- 状态机逻辑(`_Phase` 枚举、`_applyAutoFill`、API 调用时机)不变
|
||||
- 物理按键 P2 切换模式行为不变
|
||||
- 所有字段数据来源(`BoxInfoResult`、`saveBoxRecord` 等 API)不变
|
||||
191
docs/多码凑箱UI布局优化PRD.md
Normal file
191
docs/多码凑箱UI布局优化PRD.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# PRD · 多码凑箱页面布局优化
|
||||
|
||||
**模块**:装箱编号 > 多码凑箱模式(BoxingMode.multiCode)
|
||||
**文件**:`lib/pages/boxing_page.dart`(主要影响 `_buildManyToOneBody` 及相关子方法)
|
||||
**版本**:v2
|
||||
**状态**:待评审
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
多码凑箱页面在手持 PDA / 手机屏幕(宽度约 375–390px)上使用时,原版布局存在以下问题:
|
||||
|
||||
- **垂直空间浪费**:AppBar、箱号区、排产信息区各自独占一行,三块合计高度约 140px,挤压了中间列表区的可视行数。
|
||||
- **操作区随列表滚动**:扫码区和提交区位于列表下方,装入项目多时需要下拉才能看到操作控件,增加操作摩擦。
|
||||
- **信息层次不清晰**:删除确认使用弹窗,打断了列表的整体感知;通知条与内容区边界不明确。
|
||||
|
||||
**优化目标**:在同一屏内展示更多已装入项目,同时保持操作控件始终可见,整体界面整洁、层次分明。
|
||||
|
||||
---
|
||||
|
||||
## 2. 改动总览
|
||||
|
||||
| 区域 | 原版高度(估算) | v2 高度(估算) | 节省 |
|
||||
|------|--------------|--------------|------|
|
||||
| AppBar | ~52px | 44px | 8px |
|
||||
| 箱号区 + 排产信息区 | ~80px(两行) | 48px(合并一行) | 32px |
|
||||
| 列表每行 | ~48–52px | 40px | 8–12px/行 |
|
||||
| 通知条(每条) | ~36px | 30px | 6px |
|
||||
| 状态栏 | ~38px | 28px | 10px |
|
||||
|
||||
三项以上改动叠加后,典型场景下可多显示 **1–2 条**已装入记录,且操作底栏固定可见。
|
||||
|
||||
---
|
||||
|
||||
## 3. 详细改动说明
|
||||
|
||||
### 3.1 AppBar(`_buildAppBar`)
|
||||
|
||||
**原版**:高度约 52px,标题与模式切换按钮分布松散,模式徽章内边距较大。
|
||||
|
||||
**v2**:
|
||||
- 高度收紧至 **44px**(Material 标准最小可触高度)。
|
||||
- 标题(`装箱编号`)与模式徽章(`⇄ 多码凑箱 P2`)左右对齐,中间用弹性空白(`Spacer`)分隔。
|
||||
- 模式徽章内边距从 `6px 10px` 收窄至 `3px 9px`,字号保持 `11px`,快捷键角标用半透明背景小标签呈现。
|
||||
|
||||
---
|
||||
|
||||
### 3.2 箱号区 + 排产信息区 → 合并为 `header-bar`(`_buildManyToOneBoxNoPanel`)
|
||||
|
||||
**原版**:箱号输入框(22px 标签 + 44px 输入框)与排产信息行(排产号、已有箱数、最大箱号、详情按钮)分两个独立区块,共约 80px。
|
||||
|
||||
**v2**:合并为单行 `header-bar`,高度约 **48px**,布局如下:
|
||||
|
||||
```
|
||||
[ 箱号 ██ 1 ██ ] │ W00009 已有 1 箱 · 最大箱号 1 详情
|
||||
```
|
||||
|
||||
- 箱号输入框:宽 52px,高 32px,字号 18px 加粗,左侧配 `11px` 灰色小标签"箱号"。
|
||||
- 竖向分隔线将箱号区与排产信息区视觉隔开。
|
||||
- 排产号(`13px` 加粗)+ 统计信息(`11px` 灰色)两行叠放,`flex: 1` 撑满中间。
|
||||
- `详情` 链接按钮右对齐,无数据时置灰。
|
||||
|
||||
**影响代码**:`_buildManyToOneBoxNoPanel()`、相关 `_boxNoController` 绑定逻辑不变,仅 UI 结构调整。
|
||||
|
||||
---
|
||||
|
||||
### 3.3 已装入列表(`_buildManyToOnePackedList`)
|
||||
|
||||
**原版**:每行 `packed-item` 高度约 48–52px,内含 `8px 8px` 内边距,信息横排一行。
|
||||
|
||||
**v2**:每行高度压缩至 **40px**,采用左侧色条 + 两行叠放的结构:
|
||||
|
||||
```
|
||||
序号 ▌ 26BW0011 80 / 80 ✏️ 🗑
|
||||
▌ WO-2026-001
|
||||
```
|
||||
|
||||
- **左侧色条**(宽 3px,高 26px,圆角):正常 = 绿色,编辑中 = 琥珀色,删除确认 = 红色。用色条替代原版整行背景色染色,减少视觉干扰。
|
||||
- **序号列**(宽 14px):灰色小字,方便操作者快速定位。
|
||||
- **主信息列**:总排号 `12px` 加粗(上行)+ 工单号 `11px` 灰色(下行),`overflow: ellipsis` 防止溢出。
|
||||
- **数量列**:`12px` 加粗绿色,格式 `已装 / 总量`。
|
||||
- **操作按钮**:✏️(编辑)/ 🗑(删除),触摸区域 28×28px,悬停背景 `#eeeeee`。
|
||||
|
||||
**编辑态**:数量文字替换为 52px 宽输入框,操作按钮变为 ✓(保存)/ ✕(取消)。
|
||||
|
||||
**删除确认态**:废弃弹窗方案,改为**行内展开次级条**(高度约 26px),与触发行紧邻,包含`删除` / `取消` 两个小按钮。行背景变淡红,色条变红,视觉上整体标记该条目处于危险操作中。
|
||||
|
||||
**列表头**(sticky):高度约 30px,`已装入本箱` 左对齐,`N 项` 蓝色右对齐,固定在列表顶部,滚动时不消失。
|
||||
|
||||
---
|
||||
|
||||
### 3.4 底部操作区固定化(`_buildManyToOneScanArea` + `_buildManyToOneSubmitArea`)
|
||||
|
||||
**原版**:扫码区、数量输入、确认按钮、完成本箱按钮位于列表下方,随列表内容延伸,装入项目多时操作区不可见。
|
||||
|
||||
**v2**:底部操作区改为**固定底栏(不参与滚动)**,从上至下分三行:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ [✓ 26BW0014 · WO-004 · 50件] │ 扫码条 ~38px
|
||||
│ 数量 [ 50 ] [确认] │ 提交行 ~48px
|
||||
│ [ 完成本箱 ] │ 完成行 ~50px
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**扫码条**:
|
||||
- 等待状态:灰色背景,`📷 请扫描执行卡二维码`,文字灰色。
|
||||
- 已扫码状态:浅绿背景,`✓ 总排号 · 工单号 · N件`,文字绿色,绿色边框。
|
||||
|
||||
**提交行**:
|
||||
- `数量` 标签(`11px`)+ 输入框(`flex:1`,高 34px)+ 确认按钮(高 34px,`padding 0 18px`)。
|
||||
- 未扫码时:输入框禁用,确认按钮灰色不可点。
|
||||
- 已扫码时:输入框激活,确认按钮蓝色可点。
|
||||
|
||||
**完成本箱行**:
|
||||
- 全宽描边按钮(高 36px,圆角 8px),有已装入项时激活,否则灰色。
|
||||
|
||||
---
|
||||
|
||||
### 3.5 通知条(Alert Bars)
|
||||
|
||||
**原版**:每条高度约 36px,字号 13px,内边距 `8px 16px`。
|
||||
|
||||
**v2**:
|
||||
- 高度压缩至约 **30px**,内边距 `6px 12px`,字号 `12px`。
|
||||
- 多条通知可叠加显示(同步条 + 警告条并存,场景6)。
|
||||
- 各通知类型颜色语义不变:橙(同步中)/ 琥珀(警告)/ 蓝(信息)/ 黄(重复箱号)。
|
||||
- 同步条保留旋转动画小图标(`12px`,`0.7s` 线性)。
|
||||
|
||||
---
|
||||
|
||||
### 3.6 状态栏(`StatusBar`)
|
||||
|
||||
**原版**:高度约 38px,背景 `#f0f0f0`,内边距 `12px 16px`。
|
||||
|
||||
**v2**:
|
||||
- 高度压缩至约 **28px**,内边距 `6px 12px`。
|
||||
- 状态点直径从 8px 缩至 7px,文字从 14px 缩至 12px。
|
||||
- 颜色语义不变(蓝/绿/橙/红/琥珀)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 整体布局结构(v2)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐ 44px
|
||||
│ 装箱编号 [⇄ 多码凑箱 P2] │ AppBar
|
||||
├──────────────────────────────────────────┤
|
||||
│ [通知条,可叠加,每条 ~30px] │ 可选
|
||||
├──────────────────────────────────────────┤ 48px
|
||||
│ 箱号 [ 1 ] │ W00009 已有1箱·最大箱1 详情│ header-bar
|
||||
├──────────────────────────────────────────┤
|
||||
│ 已装入本箱(sticky) 3 项 │ ~30px
|
||||
│ 1 ▌ 26BW0011 WO-001 80/80 ✏️ 🗑 │ 40px
|
||||
│ 2 ▌ 26BW0012 WO-002 60/60 ✏️ 🗑 │ 40px
|
||||
│ 3 ▌ 26BW0013 WO-003 40/40 ✏️ 🗑 │ 40px ← 可滚动
|
||||
│ ... │
|
||||
├──────────────────────────────────────────┤
|
||||
│ [✓ 26BW0014 · WO-004 · 50件] │ ~38px 固定
|
||||
│ 数量 [ 50 ] [确认] │ ~48px 固定
|
||||
│ [ 完成本箱 ] │ ~50px 固定
|
||||
├──────────────────────────────────────────┤ 28px
|
||||
│ ● 装箱成功,请继续扫码 │ 状态栏
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 影响范围
|
||||
|
||||
| 方法/组件 | 改动类型 |
|
||||
|-----------|---------|
|
||||
| `_buildManyToOneBody()` | 结构重组(列表区 + 固定底栏) |
|
||||
| `_buildManyToOneBoxNoPanel()` | 重写(合并为 header-bar) |
|
||||
| `_buildManyToOnePackedList()` | 重写(行高、色条、内联删除确认) |
|
||||
| `_buildManyToOneScanArea()` | 移入固定底栏,样式调整 |
|
||||
| `_buildManyToOneSubmitArea()` | 移入固定底栏,高度调整 |
|
||||
| `_buildAppBar()` 多码分支 | 高度和内边距调整 |
|
||||
| `StatusBar` widget | 尺寸参数调整 |
|
||||
| Alert bar 区域 | 内边距和字号调整 |
|
||||
|
||||
业务逻辑(`decideMultiCodePaichanContext`、`_applyAutoFill`、`_onSubmitSuccess`、`_finishManyToOneBox` 等)**不受影响**。
|
||||
|
||||
---
|
||||
|
||||
## 6. 不在本次范围内
|
||||
|
||||
- 单码装箱模式(`BoxingMode.single`)的布局不做调整。
|
||||
- 字体、主题色、品牌色系不做变更。
|
||||
- 横屏/平板适配留作后续专项。
|
||||
1157
lib/pages/accessory_page.dart
Normal file
1157
lib/pages/accessory_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
154
lib/pages/boxing/boxing_api_actions.dart
Normal file
154
lib/pages/boxing/boxing_api_actions.dart
Normal file
@@ -0,0 +1,154 @@
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
import 'package:pad_scanner/services/app_config_service.dart';
|
||||
|
||||
enum BoxingActionErrorKind { missingApiUrl, network, duplicate, backend }
|
||||
|
||||
class BoxingActionResult<T> {
|
||||
final bool success;
|
||||
final T? data;
|
||||
final BoxingActionErrorKind? errorKind;
|
||||
final String? errorMessage;
|
||||
final int? boxNo;
|
||||
|
||||
const BoxingActionResult._({
|
||||
required this.success,
|
||||
this.data,
|
||||
this.errorKind,
|
||||
this.errorMessage,
|
||||
this.boxNo,
|
||||
});
|
||||
|
||||
const BoxingActionResult.ok(T data) : this._(success: true, data: data);
|
||||
|
||||
const BoxingActionResult.error({
|
||||
required BoxingActionErrorKind kind,
|
||||
required String message,
|
||||
int? boxNo,
|
||||
}) : this._(
|
||||
success: false,
|
||||
errorKind: kind,
|
||||
errorMessage: message,
|
||||
boxNo: boxNo,
|
||||
);
|
||||
}
|
||||
|
||||
typedef ApiUrlLoader = Future<String?> Function();
|
||||
|
||||
class BoxingApiActions {
|
||||
static const networkErrorMessage = '网络异常,请检查网络连接';
|
||||
static const missingApiUrlMessage = '未配置 API 地址,请前往设置';
|
||||
|
||||
final ApiService _apiService;
|
||||
final ApiUrlLoader _loadApiUrl;
|
||||
|
||||
BoxingApiActions({required ApiService apiService, ApiUrlLoader? loadApiUrl})
|
||||
: _apiService = apiService,
|
||||
_loadApiUrl =
|
||||
loadApiUrl ?? (() => AppConfigService().getString('api_url'));
|
||||
|
||||
Future<BoxingActionResult<BoxInfoResult>> fetchBoxInfo(String zongpai) async {
|
||||
final baseUrl = await _requireBaseUrl();
|
||||
if (baseUrl == null) return _missingApiUrl();
|
||||
|
||||
final result = await _apiService.fetchBoxInfo(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpai,
|
||||
);
|
||||
if (result.success) return BoxingActionResult.ok(result);
|
||||
return BoxingActionResult.error(
|
||||
kind: _kindForMessage(result.errorMessage),
|
||||
message: result.errorMessage ?? '查询失败',
|
||||
);
|
||||
}
|
||||
|
||||
Future<BoxingActionResult<BoxSaveResult>> saveBoxRecord({
|
||||
required String zongpaiNo,
|
||||
required int boxNo,
|
||||
required int quantity,
|
||||
String itemType = 'product',
|
||||
int? accessoryId,
|
||||
}) async {
|
||||
final baseUrl = await _requireBaseUrl();
|
||||
if (baseUrl == null) return _missingApiUrl();
|
||||
|
||||
final result = await _apiService.saveBoxRecord(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
itemType: itemType,
|
||||
accessoryId: accessoryId,
|
||||
);
|
||||
return _saveResult(result, fallbackMessage: '提交失败');
|
||||
}
|
||||
|
||||
Future<BoxingActionResult<BoxSaveResult>> updateBoxRecord({
|
||||
required int boxItemId,
|
||||
required int boxNo,
|
||||
required int quantity,
|
||||
}) async {
|
||||
final baseUrl = await _requireBaseUrl();
|
||||
if (baseUrl == null) return _missingApiUrl();
|
||||
|
||||
final result = await _apiService.updateBoxRecord(
|
||||
baseUrl: baseUrl,
|
||||
boxItemId: boxItemId,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
return _saveResult(result, fallbackMessage: '修改失败');
|
||||
}
|
||||
|
||||
Future<BoxingActionResult<BoxDeleteResult>> deleteBoxRecord({
|
||||
required int boxItemId,
|
||||
}) async {
|
||||
final baseUrl = await _requireBaseUrl();
|
||||
if (baseUrl == null) return _missingApiUrl();
|
||||
|
||||
final result = await _apiService.deleteBoxRecord(
|
||||
baseUrl: baseUrl,
|
||||
boxItemId: boxItemId,
|
||||
);
|
||||
if (result.success) return BoxingActionResult.ok(result);
|
||||
return BoxingActionResult.error(
|
||||
kind: _kindForMessage(result.errorMessage),
|
||||
message: result.errorMessage ?? '删除失败',
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _requireBaseUrl() async {
|
||||
final baseUrl = await _loadApiUrl() ?? '';
|
||||
if (baseUrl.isEmpty) return null;
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
BoxingActionResult<T> _missingApiUrl<T>() {
|
||||
return const BoxingActionResult.error(
|
||||
kind: BoxingActionErrorKind.missingApiUrl,
|
||||
message: missingApiUrlMessage,
|
||||
);
|
||||
}
|
||||
|
||||
BoxingActionResult<BoxSaveResult> _saveResult(
|
||||
BoxSaveResult result, {
|
||||
required String fallbackMessage,
|
||||
}) {
|
||||
if (result.success) return BoxingActionResult.ok(result);
|
||||
if (result.isDuplicate) {
|
||||
return BoxingActionResult.error(
|
||||
kind: BoxingActionErrorKind.duplicate,
|
||||
message: result.errorMessage ?? fallbackMessage,
|
||||
boxNo: result.boxNo,
|
||||
);
|
||||
}
|
||||
return BoxingActionResult.error(
|
||||
kind: _kindForMessage(result.errorMessage),
|
||||
message: result.errorMessage ?? fallbackMessage,
|
||||
);
|
||||
}
|
||||
|
||||
BoxingActionErrorKind _kindForMessage(String? message) {
|
||||
if (message == networkErrorMessage) return BoxingActionErrorKind.network;
|
||||
return BoxingActionErrorKind.backend;
|
||||
}
|
||||
}
|
||||
135
lib/pages/boxing/boxing_box_mutations.dart
Normal file
135
lib/pages/boxing/boxing_box_mutations.dart
Normal file
@@ -0,0 +1,135 @@
|
||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
class BoxMutationResult {
|
||||
final List<BoxDetailData> boxes;
|
||||
final int maxBoxNo;
|
||||
|
||||
const BoxMutationResult({required this.boxes, required this.maxBoxNo});
|
||||
}
|
||||
|
||||
int maxBoxNoFor(List<BoxDetailData> boxes) {
|
||||
return boxes.fold<int>(0, (max, box) => box.boxNo > max ? box.boxNo : max);
|
||||
}
|
||||
|
||||
BoxMutationResult addExistingBoxItem({
|
||||
required List<BoxDetailData> existingBoxes,
|
||||
required int boxNo,
|
||||
required int quantity,
|
||||
required int? boxItemId,
|
||||
required String zongpaiNo,
|
||||
required String? workOrderNo,
|
||||
required int? totalQuantity,
|
||||
}) {
|
||||
final item = BoxItemData(
|
||||
boxItemId: boxItemId,
|
||||
zongpaiNo: zongpaiNo,
|
||||
workOrderNo: workOrderNo,
|
||||
quantity: quantity,
|
||||
totalQuantity: totalQuantity,
|
||||
);
|
||||
final index = existingBoxes.indexWhere((box) => box.boxNo == boxNo);
|
||||
if (index < 0) {
|
||||
final boxes = [
|
||||
...existingBoxes,
|
||||
BoxDetailData(boxNo: boxNo, items: [item]),
|
||||
]..sort((a, b) => a.boxNo.compareTo(b.boxNo));
|
||||
return BoxMutationResult(boxes: boxes, maxBoxNo: maxBoxNoFor(boxes));
|
||||
}
|
||||
|
||||
final boxes = List<BoxDetailData>.from(existingBoxes);
|
||||
final box = boxes[index];
|
||||
boxes[index] = BoxDetailData(boxNo: box.boxNo, items: [...box.items, item]);
|
||||
return BoxMutationResult(boxes: boxes, maxBoxNo: maxBoxNoFor(boxes));
|
||||
}
|
||||
|
||||
BoxMutationResult replaceExistingBoxItem({
|
||||
required List<BoxDetailData> existingBoxes,
|
||||
required CurrentZongpaiBoxData editing,
|
||||
required int boxNo,
|
||||
required int quantity,
|
||||
required String zongpaiNo,
|
||||
required String? workOrderNo,
|
||||
required int? totalQuantity,
|
||||
}) {
|
||||
final updatedItem = BoxItemData(
|
||||
boxItemId: editing.boxItemId,
|
||||
zongpaiNo: zongpaiNo,
|
||||
workOrderNo: workOrderNo,
|
||||
quantity: quantity,
|
||||
totalQuantity: totalQuantity,
|
||||
);
|
||||
return _replaceItemById(
|
||||
existingBoxes: existingBoxes,
|
||||
boxItemId: editing.boxItemId,
|
||||
targetBoxNo: boxNo,
|
||||
updatedItem: updatedItem,
|
||||
);
|
||||
}
|
||||
|
||||
BoxMutationResult replaceExistingPackedItem({
|
||||
required List<BoxDetailData> existingBoxes,
|
||||
required ManyToOnePackedItem item,
|
||||
required int boxNo,
|
||||
required int quantity,
|
||||
}) {
|
||||
final updatedItem = BoxItemData(
|
||||
boxItemId: item.boxItemId,
|
||||
zongpaiNo: item.zongpaiNo,
|
||||
workOrderNo: item.workOrderNo,
|
||||
quantity: quantity,
|
||||
totalQuantity: item.totalQuantity,
|
||||
);
|
||||
return _replaceItemById(
|
||||
existingBoxes: existingBoxes,
|
||||
boxItemId: item.boxItemId,
|
||||
targetBoxNo: boxNo,
|
||||
updatedItem: updatedItem,
|
||||
);
|
||||
}
|
||||
|
||||
BoxMutationResult removeExistingBoxItem({
|
||||
required List<BoxDetailData> existingBoxes,
|
||||
required int boxItemId,
|
||||
}) {
|
||||
final boxes = <BoxDetailData>[];
|
||||
for (final box in existingBoxes) {
|
||||
final items = box.items
|
||||
.where((item) => item.boxItemId != boxItemId)
|
||||
.toList();
|
||||
if (items.isNotEmpty) {
|
||||
boxes.add(BoxDetailData(boxNo: box.boxNo, items: items));
|
||||
}
|
||||
}
|
||||
return BoxMutationResult(boxes: boxes, maxBoxNo: maxBoxNoFor(boxes));
|
||||
}
|
||||
|
||||
BoxMutationResult _replaceItemById({
|
||||
required List<BoxDetailData> existingBoxes,
|
||||
required int boxItemId,
|
||||
required int targetBoxNo,
|
||||
required BoxItemData updatedItem,
|
||||
}) {
|
||||
final boxes = <BoxDetailData>[];
|
||||
for (final box in existingBoxes) {
|
||||
final items = box.items.where((item) {
|
||||
return item.boxItemId != boxItemId;
|
||||
}).toList();
|
||||
if (items.isNotEmpty) {
|
||||
boxes.add(BoxDetailData(boxNo: box.boxNo, items: items));
|
||||
}
|
||||
}
|
||||
|
||||
final targetIndex = boxes.indexWhere((box) => box.boxNo == targetBoxNo);
|
||||
if (targetIndex >= 0) {
|
||||
final target = boxes[targetIndex];
|
||||
boxes[targetIndex] = BoxDetailData(
|
||||
boxNo: target.boxNo,
|
||||
items: [...target.items, updatedItem],
|
||||
);
|
||||
} else {
|
||||
boxes.add(BoxDetailData(boxNo: targetBoxNo, items: [updatedItem]));
|
||||
}
|
||||
boxes.sort((a, b) => a.boxNo.compareTo(b.boxNo));
|
||||
return BoxMutationResult(boxes: boxes, maxBoxNo: maxBoxNoFor(boxes));
|
||||
}
|
||||
101
lib/pages/boxing/boxing_calculations.dart
Normal file
101
lib/pages/boxing/boxing_calculations.dart
Normal file
@@ -0,0 +1,101 @@
|
||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
int packedQuantity(List<CurrentZongpaiBoxData> items) {
|
||||
return items.fold<int>(0, (sum, item) => sum + item.quantity);
|
||||
}
|
||||
|
||||
int remainingQuantity({
|
||||
required int? totalQuantity,
|
||||
required List<CurrentZongpaiBoxData> currentBoxes,
|
||||
}) {
|
||||
return (totalQuantity ?? 0) - packedQuantity(currentBoxes);
|
||||
}
|
||||
|
||||
bool quantityTooHigh({
|
||||
required int remainingQuantity,
|
||||
required String quantityText,
|
||||
}) {
|
||||
if (remainingQuantity <= 0) return false;
|
||||
final quantity = int.tryParse(quantityText);
|
||||
return quantity != null && quantity > remainingQuantity;
|
||||
}
|
||||
|
||||
bool canSubmitBoxing({
|
||||
required bool isSubmitting,
|
||||
required BoxingPhase phase,
|
||||
required String? zongpaiNo,
|
||||
required String boxNoText,
|
||||
required String quantityText,
|
||||
required int remainingQuantity,
|
||||
required bool quantityTooHigh,
|
||||
required bool isDuplicateBoxNo,
|
||||
}) {
|
||||
if (isSubmitting || phase != BoxingPhase.scanned) return false;
|
||||
if (zongpaiNo == null) return false;
|
||||
final boxNo = int.tryParse(boxNoText);
|
||||
final quantity = int.tryParse(quantityText);
|
||||
if (boxNo == null || boxNo <= 0 || quantity == null || quantity <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (remainingQuantity <= 0) return false;
|
||||
if (quantityTooHigh) return false;
|
||||
if (isDuplicateBoxNo) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
int? currentManyToOneBoxNo(String boxNoText) {
|
||||
final boxNo = int.tryParse(boxNoText.trim());
|
||||
if (boxNo == null || boxNo <= 0) return null;
|
||||
return boxNo;
|
||||
}
|
||||
|
||||
bool singleCodeBoxNoIsDuplicate({
|
||||
required String boxNoText,
|
||||
required List<CurrentZongpaiBoxData> currentBoxes,
|
||||
required int? editingBoxItemId,
|
||||
}) {
|
||||
final boxNo = int.tryParse(boxNoText);
|
||||
if (boxNo == null) return false;
|
||||
return currentBoxes.any((box) {
|
||||
if (box.boxNo != boxNo) return false;
|
||||
return editingBoxItemId == null || box.boxItemId != editingBoxItemId;
|
||||
});
|
||||
}
|
||||
|
||||
List<ManyToOnePackedItem> visibleManyToOnePackedItems({
|
||||
required int? boxNo,
|
||||
required List<BoxDetailData> existingBoxes,
|
||||
required List<ManyToOnePackedItem> packedItems,
|
||||
required String? paichanNo,
|
||||
}) {
|
||||
if (boxNo == null) return const [];
|
||||
final byItemId = <int, ManyToOnePackedItem>{};
|
||||
for (final box in existingBoxes.where((box) => box.boxNo == boxNo)) {
|
||||
for (final item in box.items) {
|
||||
final boxItemId = item.boxItemId;
|
||||
if (boxItemId == null) continue;
|
||||
byItemId[boxItemId] = ManyToOnePackedItem(
|
||||
boxItemId: boxItemId,
|
||||
zongpaiNo: item.zongpaiNo,
|
||||
paichanNo: paichanNo,
|
||||
workOrderNo: item.workOrderNo,
|
||||
boxNo: boxNo,
|
||||
quantity: item.quantity,
|
||||
totalQuantity: item.totalQuantity,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (final item in packedItems.where((item) => item.boxNo == boxNo)) {
|
||||
byItemId[item.boxItemId] = item;
|
||||
}
|
||||
return byItemId.values.toList(growable: false);
|
||||
}
|
||||
|
||||
int maxAssignedQuantity({
|
||||
required int? totalQuantity,
|
||||
required int packedQuantity,
|
||||
required int itemQuantity,
|
||||
}) {
|
||||
return (totalQuantity ?? 0) - (packedQuantity - itemQuantity);
|
||||
}
|
||||
56
lib/pages/boxing/boxing_models.dart
Normal file
56
lib/pages/boxing/boxing_models.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
enum BoxingMode {
|
||||
singleCode, // 单码装箱
|
||||
multiCode, // 多码凑箱
|
||||
}
|
||||
|
||||
enum BoxingPhase {
|
||||
waiting, // 等待扫码
|
||||
scanned, // 已扫码,显示信息
|
||||
submitted, // 已提交成功
|
||||
}
|
||||
|
||||
class BoxingPageArguments {
|
||||
final BoxingMode initialMode;
|
||||
final List<String> autoScanCodes;
|
||||
|
||||
const BoxingPageArguments({
|
||||
required this.initialMode,
|
||||
required this.autoScanCodes,
|
||||
});
|
||||
}
|
||||
|
||||
class ManyToOnePackedItem {
|
||||
final int boxItemId;
|
||||
final String zongpaiNo;
|
||||
final String? paichanNo;
|
||||
final String? workOrderNo;
|
||||
final int boxNo;
|
||||
final int quantity;
|
||||
final int? totalQuantity;
|
||||
|
||||
const ManyToOnePackedItem({
|
||||
required this.boxItemId,
|
||||
required this.zongpaiNo,
|
||||
this.paichanNo,
|
||||
required this.workOrderNo,
|
||||
required this.boxNo,
|
||||
required this.quantity,
|
||||
this.totalQuantity,
|
||||
});
|
||||
|
||||
ManyToOnePackedItem copyWith({
|
||||
int? boxNo,
|
||||
int? quantity,
|
||||
int? totalQuantity,
|
||||
}) {
|
||||
return ManyToOnePackedItem(
|
||||
boxItemId: boxItemId,
|
||||
zongpaiNo: zongpaiNo,
|
||||
paichanNo: paichanNo,
|
||||
workOrderNo: workOrderNo,
|
||||
boxNo: boxNo ?? this.boxNo,
|
||||
quantity: quantity ?? this.quantity,
|
||||
totalQuantity: totalQuantity ?? this.totalQuantity,
|
||||
);
|
||||
}
|
||||
}
|
||||
97
lib/pages/boxing/boxing_status_presenter.dart
Normal file
97
lib/pages/boxing/boxing_status_presenter.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
||||
import 'package:pad_scanner/widgets/status_bar.dart';
|
||||
|
||||
class BoxingStatusPresentation {
|
||||
final StatusDotColor dot;
|
||||
final String text;
|
||||
|
||||
const BoxingStatusPresentation({required this.dot, required this.text});
|
||||
}
|
||||
|
||||
BoxingStatusPresentation boxingStatusFor({
|
||||
required bool isAutoProcessing,
|
||||
required bool isSubmitting,
|
||||
required bool isDuplicateBoxNo,
|
||||
required bool quantityTooHigh,
|
||||
required bool isEditingAssigned,
|
||||
required bool isDeletingAssigned,
|
||||
required bool hasPaichanSwitchNotice,
|
||||
required BoxingPhase phase,
|
||||
required bool isMultiCode,
|
||||
required bool hasVisibleManyToOneItems,
|
||||
required int remainingQuantity,
|
||||
required String boxNoText,
|
||||
}) {
|
||||
if (isAutoProcessing) {
|
||||
return const BoxingStatusPresentation(
|
||||
dot: StatusDotColor.orange,
|
||||
text: '正在同步转运数据...',
|
||||
);
|
||||
}
|
||||
if (isSubmitting) {
|
||||
return const BoxingStatusPresentation(
|
||||
dot: StatusDotColor.orange,
|
||||
text: '正在提交…',
|
||||
);
|
||||
}
|
||||
if (isDuplicateBoxNo && phase == BoxingPhase.scanned) {
|
||||
return BoxingStatusPresentation(
|
||||
dot: StatusDotColor.amber,
|
||||
text: '箱号 $boxNoText 已存在,请重新输入',
|
||||
);
|
||||
}
|
||||
if (quantityTooHigh && phase == BoxingPhase.scanned) {
|
||||
return const BoxingStatusPresentation(
|
||||
dot: StatusDotColor.red,
|
||||
text: '超出可装数量上限',
|
||||
);
|
||||
}
|
||||
if (isEditingAssigned) {
|
||||
return const BoxingStatusPresentation(
|
||||
dot: StatusDotColor.amber,
|
||||
text: '正在编辑已分配记录',
|
||||
);
|
||||
}
|
||||
if (isDeletingAssigned) {
|
||||
return const BoxingStatusPresentation(
|
||||
dot: StatusDotColor.red,
|
||||
text: '请确认是否删除该箱记录',
|
||||
);
|
||||
}
|
||||
if (hasPaichanSwitchNotice) {
|
||||
return const BoxingStatusPresentation(
|
||||
dot: StatusDotColor.blue,
|
||||
text: '排产号已切换,箱号已重置',
|
||||
);
|
||||
}
|
||||
|
||||
switch (phase) {
|
||||
case BoxingPhase.waiting:
|
||||
if (isMultiCode && hasVisibleManyToOneItems) {
|
||||
return const BoxingStatusPresentation(
|
||||
dot: StatusDotColor.blue,
|
||||
text: '请继续扫码或完成本箱',
|
||||
);
|
||||
}
|
||||
return const BoxingStatusPresentation(
|
||||
dot: StatusDotColor.blue,
|
||||
text: '等待扫码',
|
||||
);
|
||||
case BoxingPhase.scanned:
|
||||
if (remainingQuantity <= 0) {
|
||||
return const BoxingStatusPresentation(
|
||||
dot: StatusDotColor.red,
|
||||
text: '该总排号已全部装箱完毕',
|
||||
);
|
||||
}
|
||||
return const BoxingStatusPresentation(
|
||||
dot: StatusDotColor.blue,
|
||||
text: '数量已填入,请确认或修改',
|
||||
);
|
||||
case BoxingPhase.submitted:
|
||||
return BoxingStatusPresentation(
|
||||
dot: StatusDotColor.green,
|
||||
text: isMultiCode ? '请扫描下一个总排号' : '装箱成功,可继续扫码',
|
||||
);
|
||||
}
|
||||
}
|
||||
126
lib/pages/boxing/dialogs/cross_paichan_dialog.dart
Normal file
126
lib/pages/boxing/dialogs/cross_paichan_dialog.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
Future<bool> showCrossPaichanDialog({
|
||||
required BuildContext context,
|
||||
required String currentPaicha,
|
||||
required String newPaicha,
|
||||
}) async {
|
||||
final dialogFocus = FocusNode();
|
||||
var selectedIndex = 0; // 0 = "否" (default), 1 = "是"
|
||||
|
||||
final dialogFuture = showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) {
|
||||
void dismissAsNo() => Navigator.of(ctx).pop(false);
|
||||
void confirmSwitch() => Navigator.of(ctx).pop(true);
|
||||
|
||||
return KeyboardListener(
|
||||
focusNode: dialogFocus,
|
||||
onKeyEvent: (event) {
|
||||
if (event is! KeyDownEvent) return;
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
|
||||
setDialogState(() => selectedIndex = 0);
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
setDialogState(() => selectedIndex = 1);
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
selectedIndex == 0 ? dismissAsNo() : confirmSwitch();
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
dismissAsNo();
|
||||
}
|
||||
},
|
||||
child: PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) dismissAsNo();
|
||||
},
|
||||
child: AlertDialog(
|
||||
backgroundColor: Colors.orange.shade50,
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber, color: Colors.orange.shade800),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'跨排产号扫描',
|
||||
style: TextStyle(color: Colors.orange.shade900),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'当前排产号:$currentPaicha\n'
|
||||
'新排产号:$newPaicha\n\n'
|
||||
'是否切换到新的排产号?\n\n'
|
||||
'选择「是」将放弃当前已扫描的所有数据。',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_DialogOptionButton(
|
||||
label: '否',
|
||||
selected: selectedIndex == 0,
|
||||
onTap: dismissAsNo,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_DialogOptionButton(
|
||||
label: '是,切换排产号',
|
||||
selected: selectedIndex == 1,
|
||||
onTap: confirmSwitch,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
dialogFocus.requestFocus();
|
||||
});
|
||||
|
||||
final result = await dialogFuture;
|
||||
dialogFocus.dispose();
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
class _DialogOptionButton extends StatelessWidget {
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _DialogOptionButton({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 40,
|
||||
child: ElevatedButton(
|
||||
onPressed: onTap,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: selected
|
||||
? Colors.blue.shade700
|
||||
: Colors.grey.shade200,
|
||||
foregroundColor: selected ? Colors.white : Colors.black87,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
59
lib/pages/boxing/parts/boxing_box_mutation_part.dart
Normal file
59
lib/pages/boxing/parts/boxing_box_mutation_part.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
part of '../../boxing_page.dart';
|
||||
|
||||
extension _BoxingBoxMutationPart on _BoxingPageState {
|
||||
void _replaceExistingBoxItem(
|
||||
CurrentZongpaiBoxData editing,
|
||||
int boxNo,
|
||||
int quantity,
|
||||
) {
|
||||
final result = box_mutations.replaceExistingBoxItem(
|
||||
existingBoxes: _existingBoxes,
|
||||
editing: editing,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
zongpaiNo: _zongpaiNo!,
|
||||
workOrderNo: _workOrderNo,
|
||||
totalQuantity: _erpQuantity,
|
||||
);
|
||||
_existingBoxes = result.boxes;
|
||||
_maxBoxNo = result.maxBoxNo;
|
||||
}
|
||||
|
||||
void _addExistingBoxItem(int boxNo, int quantity, int? boxItemId) {
|
||||
final result = box_mutations.addExistingBoxItem(
|
||||
existingBoxes: _existingBoxes,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
boxItemId: boxItemId,
|
||||
zongpaiNo: _zongpaiNo!,
|
||||
workOrderNo: _workOrderNo,
|
||||
totalQuantity: _erpQuantity,
|
||||
);
|
||||
_existingBoxes = result.boxes;
|
||||
_maxBoxNo = result.maxBoxNo;
|
||||
}
|
||||
|
||||
void _replaceExistingPackedItem(
|
||||
ManyToOnePackedItem item,
|
||||
int boxNo,
|
||||
int quantity,
|
||||
) {
|
||||
final result = box_mutations.replaceExistingPackedItem(
|
||||
existingBoxes: _existingBoxes,
|
||||
item: item,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
_existingBoxes = result.boxes;
|
||||
_maxBoxNo = result.maxBoxNo;
|
||||
}
|
||||
|
||||
void _removeExistingBoxItem(int boxItemId) {
|
||||
final result = box_mutations.removeExistingBoxItem(
|
||||
existingBoxes: _existingBoxes,
|
||||
boxItemId: boxItemId,
|
||||
);
|
||||
_existingBoxes = result.boxes;
|
||||
_maxBoxNo = result.maxBoxNo;
|
||||
}
|
||||
}
|
||||
112
lib/pages/boxing/parts/boxing_multi_ops_part.dart
Normal file
112
lib/pages/boxing/parts/boxing_multi_ops_part.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member, unnecessary_this
|
||||
|
||||
part of '../../boxing_page.dart';
|
||||
|
||||
extension _BoxingMultiOpsPart on _BoxingPageState {
|
||||
void _finishManyToOneBox() {
|
||||
setState(() {
|
||||
_manyToOnePackedItems.clear();
|
||||
_editingPackedItemId = null;
|
||||
_editingPackedQuantity = '';
|
||||
_deletingPackedItemId = null;
|
||||
_zongpaiNo = null;
|
||||
_phase = BoxingPhase.waiting;
|
||||
_quantityController.clear();
|
||||
_boxNoController.text = (_maxBoxNo + 1).toString();
|
||||
_statusOverrideText = null;
|
||||
_statusOverrideDot = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _startEditPackedItem(ManyToOnePackedItem item) {
|
||||
setState(() {
|
||||
_editingPackedItemId = item.boxItemId;
|
||||
_editingPackedQuantity = item.quantity.toString();
|
||||
_deletingPackedItemId = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelEditPackedItem() {
|
||||
setState(() {
|
||||
_editingPackedItemId = null;
|
||||
_editingPackedQuantity = '';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _savePackedItem(ManyToOnePackedItem item) async {
|
||||
final quantity = int.tryParse(_editingPackedQuantity);
|
||||
final boxNo = int.tryParse(_boxNoController.text.trim());
|
||||
if (quantity == null || quantity <= 0 || boxNo == null || boxNo <= 0) {
|
||||
this._showStatusOverride(
|
||||
'请输入有效箱号和数量',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
final action = await _apiActions.updateBoxRecord(
|
||||
boxItemId: item.boxItemId,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
if (action.success) {
|
||||
setState(() {
|
||||
final index = _manyToOnePackedItems.indexWhere(
|
||||
(packed) => packed.boxItemId == item.boxItemId,
|
||||
);
|
||||
if (index >= 0) {
|
||||
_manyToOnePackedItems[index] = _manyToOnePackedItems[index].copyWith(
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
}
|
||||
this._replaceExistingPackedItem(item, boxNo, quantity);
|
||||
_editingPackedItemId = null;
|
||||
_editingPackedQuantity = '';
|
||||
});
|
||||
this._showStatusOverride(
|
||||
'修改成功',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
} else {
|
||||
this._showActionError(action, fallback: '修改失败');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deletePackedItem(ManyToOnePackedItem item) async {
|
||||
setState(() => _isSubmitting = true);
|
||||
final action = await _apiActions.deleteBoxRecord(boxItemId: item.boxItemId);
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
if (action.success) {
|
||||
_manyToOnePackedItems.removeWhere(
|
||||
(packed) => packed.boxItemId == item.boxItemId,
|
||||
);
|
||||
this._removeExistingBoxItem(item.boxItemId);
|
||||
if (_editingPackedItemId == item.boxItemId) {
|
||||
_editingPackedItemId = null;
|
||||
_editingPackedQuantity = '';
|
||||
}
|
||||
_deletingPackedItemId = null;
|
||||
}
|
||||
});
|
||||
|
||||
if (action.success) {
|
||||
this._showStatusOverride(
|
||||
'删除成功',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
} else {
|
||||
this._showActionError(action, fallback: '删除失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
272
lib/pages/boxing/parts/boxing_scan_part.dart
Normal file
272
lib/pages/boxing/parts/boxing_scan_part.dart
Normal file
@@ -0,0 +1,272 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member, unnecessary_this
|
||||
|
||||
part of '../../boxing_page.dart';
|
||||
|
||||
extension _BoxingScanPart on _BoxingPageState {
|
||||
void _onScan(ScanResult result) {
|
||||
if (_isAutoProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_crossPaichaPending) {
|
||||
_feedbackService.trigger(FeedbackEvent.alreadyCompleted);
|
||||
return;
|
||||
}
|
||||
|
||||
final parsed = CodeParser.parse(result.barcode);
|
||||
|
||||
if (parsed.type != CodeType.zongpaiNo) {
|
||||
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
||||
this._showStatusOverride(
|
||||
'无效码,请重新扫描',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final zongpai = parsed.value;
|
||||
|
||||
// 三种模式都允许后扫入的总排号覆盖当前扫码区;未确认数据不会入库。
|
||||
this._queryBoxInfo(zongpai);
|
||||
}
|
||||
|
||||
Future<bool> _queryBoxInfo(String zongpai) async {
|
||||
final action = await _apiActions.fetchBoxInfo(zongpai);
|
||||
|
||||
if (!mounted) return false;
|
||||
|
||||
if (!action.success) {
|
||||
_feedbackService.trigger(switch (action.errorKind) {
|
||||
BoxingActionErrorKind.network => FeedbackEvent.networkError,
|
||||
BoxingActionErrorKind.missingApiUrl => FeedbackEvent.submitFailure,
|
||||
_ => FeedbackEvent.scanInvalid,
|
||||
});
|
||||
this._showStatusOverride(
|
||||
action.errorMessage ?? '查询失败',
|
||||
this._dotForActionError(action.errorKind),
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
final result = action.data!;
|
||||
// 判断该总牌号是否已全部装箱完毕
|
||||
final packedQuantity = result.currentZongpaiBoxes.fold<int>(
|
||||
0,
|
||||
(sum, item) => sum + item.quantity,
|
||||
);
|
||||
final totalQuantity = result.quantity ?? 0;
|
||||
final alreadyCompleted =
|
||||
totalQuantity > 0 && packedQuantity >= totalQuantity;
|
||||
|
||||
_feedbackService.trigger(
|
||||
alreadyCompleted
|
||||
? FeedbackEvent.alreadyCompleted
|
||||
: FeedbackEvent.scanValid,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_zongpaiNo = zongpai;
|
||||
_paichanNo = result.paichanNo;
|
||||
_workOrderNo = result.workOrderNo;
|
||||
_erpQuantity = result.quantity;
|
||||
_currentZongpaiBoxes = result.currentZongpaiBoxes;
|
||||
_existingBoxes = result.existingBoxes;
|
||||
_maxBoxNo = result.maxBoxNo;
|
||||
_pendingAccessories = result.pendingAccessories;
|
||||
_phase = BoxingPhase.scanned;
|
||||
_isDuplicateBoxNo = false;
|
||||
_editingBox = null;
|
||||
_editingAssignedItemId = null;
|
||||
_editingAssignedQuantity = '';
|
||||
_deletingAssignedItemId = null;
|
||||
_completedJumpBoxNo = null;
|
||||
_statusOverrideText = null;
|
||||
if (_mode == BoxingMode.singleCode) {
|
||||
_paichanSwitchNotice = null;
|
||||
}
|
||||
|
||||
// 根据模式自动填充
|
||||
this._applyAutoFill();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _processAutoScanCodes(List<String> codes) async {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isAutoProcessing = true;
|
||||
_autoWarnings.clear();
|
||||
_statusOverrideText = '正在同步转运数据...';
|
||||
_statusOverrideDot = StatusDotColor.orange;
|
||||
});
|
||||
|
||||
if (_mode == BoxingMode.singleCode) {
|
||||
final code = codes.first;
|
||||
final ok = await this._queryBoxInfo(code);
|
||||
if (!ok && mounted) {
|
||||
setState(() => _autoWarnings.add('总排号 $code 未找到排产号信息,已忽略'));
|
||||
}
|
||||
} else {
|
||||
for (final code in codes) {
|
||||
if (!mounted) return;
|
||||
final queried = await this._queryBoxInfo(code);
|
||||
if (!queried) {
|
||||
if (mounted) {
|
||||
setState(() => _autoWarnings.add('总排号 $code 未找到排产号信息,已忽略'));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!_canSubmit) {
|
||||
if (mounted) {
|
||||
setState(() => _autoWarnings.add('总排号 $code 暂不能装箱,已忽略'));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
final saved = await this._submit();
|
||||
if (!saved && mounted) {
|
||||
setState(() => _autoWarnings.add('总排号 $code 自动装箱失败,请手动处理'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isAutoProcessing = false;
|
||||
_statusOverrideText = null;
|
||||
_statusOverrideDot = null;
|
||||
});
|
||||
_focusQuantityInput();
|
||||
_requestFocus();
|
||||
}
|
||||
|
||||
void _applyAutoFill() {
|
||||
switch (_mode) {
|
||||
case BoxingMode.singleCode:
|
||||
final remaining = _remainingQuantity;
|
||||
if (remaining <= 0) {
|
||||
_boxNoController.clear();
|
||||
_quantityController.clear();
|
||||
_statusOverrideText = '该总排号已全部装箱完毕';
|
||||
_statusOverrideDot = StatusDotColor.red;
|
||||
return;
|
||||
}
|
||||
_boxNoController.text = (_maxBoxNo + 1).toString();
|
||||
_quantityController.text = remaining.toString();
|
||||
_quantityController.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: _quantityController.text.length,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _mode == BoxingMode.singleCode) {
|
||||
_quantityFocusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
case BoxingMode.multiCode:
|
||||
final decision = decideMultiCodePaichanContext(
|
||||
lastPaichanNo: _lastScannedPaichanNo,
|
||||
currentPaichanNo: _paichanNo,
|
||||
currentBoxNoText: _boxNoController.text,
|
||||
maxBoxNo: _maxBoxNo,
|
||||
);
|
||||
if (decision.isSwitched) {
|
||||
_crossPaichaPending = true;
|
||||
_feedbackService.trigger(FeedbackEvent.alreadyCompleted);
|
||||
this._showCrossPaichaDialog();
|
||||
return;
|
||||
} else {
|
||||
_paichanSwitchNotice = null;
|
||||
}
|
||||
final boxNoToApply = decision.boxNoToApply;
|
||||
if (boxNoToApply != null) {
|
||||
_boxNoController.text = boxNoToApply.toString();
|
||||
}
|
||||
_lastScannedPaichanNo = _paichanNo;
|
||||
final remaining = _remainingQuantity;
|
||||
if (remaining <= 0) {
|
||||
_quantityController.clear();
|
||||
if (_manyToOnePackedItems.isEmpty &&
|
||||
_currentZongpaiBoxes.isNotEmpty) {
|
||||
_completedJumpBoxNo = _currentZongpaiBoxes.first.boxNo;
|
||||
}
|
||||
_statusOverrideText = '该总排号已全部装箱完毕';
|
||||
_statusOverrideDot = StatusDotColor.red;
|
||||
return;
|
||||
}
|
||||
_quantityController.text = remaining.toString();
|
||||
_quantityController.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: _quantityController.text.length,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _mode == BoxingMode.multiCode) {
|
||||
_quantityFocusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
_isDuplicateBoxNo = _boxNoIsDuplicate();
|
||||
}
|
||||
|
||||
Future<void> _showCrossPaichaDialog() async {
|
||||
final currentPaicha = _lastScannedPaichanNo ?? '--';
|
||||
final newPaicha = _paichanNo ?? '--';
|
||||
final confirmed = await showCrossPaichanDialog(
|
||||
context: context,
|
||||
currentPaicha: currentPaicha,
|
||||
newPaicha: newPaicha,
|
||||
);
|
||||
if (!mounted) return;
|
||||
confirmed ? this._confirmPaichanSwitch() : this._cancelPaichanSwitch();
|
||||
}
|
||||
|
||||
void _confirmPaichanSwitch() {
|
||||
setState(() {
|
||||
_crossPaichaPending = false;
|
||||
_manyToOnePackedItems.clear();
|
||||
_editingPackedItemId = null;
|
||||
_editingPackedQuantity = '';
|
||||
_deletingPackedItemId = null;
|
||||
_paichanSwitchNotice = '排产号已切换:${_paichanNo ?? "--"}';
|
||||
_boxNoController.text = (_maxBoxNo + 1).toString();
|
||||
_lastScannedPaichanNo = _paichanNo;
|
||||
});
|
||||
_feedbackService.trigger(FeedbackEvent.paichanSwitch);
|
||||
|
||||
// Continue with remaining quantity flow
|
||||
final remaining = _remainingQuantity;
|
||||
if (remaining <= 0) {
|
||||
_quantityController.clear();
|
||||
if (_manyToOnePackedItems.isEmpty && _currentZongpaiBoxes.isNotEmpty) {
|
||||
_completedJumpBoxNo = _currentZongpaiBoxes.first.boxNo;
|
||||
}
|
||||
setState(() {
|
||||
_statusOverrideText = '该总排号已全部装箱完毕';
|
||||
_statusOverrideDot = StatusDotColor.red;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_quantityController.text = remaining.toString();
|
||||
_quantityController.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: _quantityController.text.length,
|
||||
);
|
||||
_isDuplicateBoxNo = _boxNoIsDuplicate();
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _mode == BoxingMode.multiCode) {
|
||||
_quantityFocusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelPaichanSwitch() {
|
||||
setState(() {
|
||||
_crossPaichaPending = false;
|
||||
_zongpaiNo = null;
|
||||
_paichanNo = _lastScannedPaichanNo;
|
||||
_phase = BoxingPhase.waiting;
|
||||
});
|
||||
}
|
||||
}
|
||||
133
lib/pages/boxing/parts/boxing_single_ops_part.dart
Normal file
133
lib/pages/boxing/parts/boxing_single_ops_part.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member, unnecessary_this
|
||||
|
||||
part of '../../boxing_page.dart';
|
||||
|
||||
extension _BoxingSingleOpsPart on _BoxingPageState {
|
||||
void _startEditAssigned(CurrentZongpaiBoxData item) {
|
||||
setState(() {
|
||||
_editingAssignedItemId = item.boxItemId;
|
||||
_editingAssignedQuantity = item.quantity.toString();
|
||||
_deletingAssignedItemId = null;
|
||||
_statusOverrideText = null;
|
||||
_statusOverrideDot = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelEditAssigned() {
|
||||
setState(() {
|
||||
_editingAssignedItemId = null;
|
||||
_editingAssignedQuantity = '';
|
||||
});
|
||||
}
|
||||
|
||||
int _maxAssignedQuantity(CurrentZongpaiBoxData item) {
|
||||
return boxing_calculations.maxAssignedQuantity(
|
||||
totalQuantity: _erpQuantity,
|
||||
packedQuantity: _packedQuantity,
|
||||
itemQuantity: item.quantity,
|
||||
);
|
||||
}
|
||||
|
||||
void _refreshSingleCodeNewInput({bool focusQuantity = true}) {
|
||||
_boxNoController.text = (_maxBoxNo + 1).toString();
|
||||
final remaining = _remainingQuantity;
|
||||
if (remaining > 0) {
|
||||
_quantityController.text = remaining.toString();
|
||||
_quantityController.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: _quantityController.text.length,
|
||||
);
|
||||
if (focusQuantity) {
|
||||
_focusQuantityInput();
|
||||
}
|
||||
} else {
|
||||
_quantityController.clear();
|
||||
}
|
||||
_isDuplicateBoxNo = false;
|
||||
}
|
||||
|
||||
Future<void> _saveAssignedItem(CurrentZongpaiBoxData item) async {
|
||||
final quantity = int.tryParse(_editingAssignedQuantity);
|
||||
if (quantity == null || quantity <= 0) {
|
||||
this._showStatusOverride(
|
||||
'请输入有效数量',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (quantity > this._maxAssignedQuantity(item)) {
|
||||
this._showStatusOverride(
|
||||
'超出可装数量上限',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
final action = await _apiActions.updateBoxRecord(
|
||||
boxItemId: item.boxItemId,
|
||||
boxNo: item.boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
if (action.success) {
|
||||
setState(() {
|
||||
_currentZongpaiBoxes = _currentZongpaiBoxes.map((box) {
|
||||
if (box.boxItemId != item.boxItemId) return box;
|
||||
return CurrentZongpaiBoxData(
|
||||
boxItemId: box.boxItemId,
|
||||
boxNo: box.boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
}).toList();
|
||||
this._replaceExistingBoxItem(item, item.boxNo, quantity);
|
||||
_editingAssignedItemId = null;
|
||||
_editingAssignedQuantity = '';
|
||||
this._refreshSingleCodeNewInput();
|
||||
});
|
||||
this._showStatusOverride(
|
||||
'修改成功',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
} else {
|
||||
this._showActionError(action, fallback: '修改失败');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteAssignedItem(CurrentZongpaiBoxData item) async {
|
||||
setState(() => _isSubmitting = true);
|
||||
final action = await _apiActions.deleteBoxRecord(boxItemId: item.boxItemId);
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
if (action.success) {
|
||||
_currentZongpaiBoxes = _currentZongpaiBoxes
|
||||
.where((box) => box.boxItemId != item.boxItemId)
|
||||
.toList();
|
||||
this._removeExistingBoxItem(item.boxItemId);
|
||||
if (_editingAssignedItemId == item.boxItemId) {
|
||||
_editingAssignedItemId = null;
|
||||
_editingAssignedQuantity = '';
|
||||
}
|
||||
_deletingAssignedItemId = null;
|
||||
this._refreshSingleCodeNewInput();
|
||||
}
|
||||
});
|
||||
|
||||
if (action.success) {
|
||||
this._showStatusOverride(
|
||||
'删除成功',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
} else {
|
||||
this._showActionError(action, fallback: '删除失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
86
lib/pages/boxing/parts/boxing_status_part.dart
Normal file
86
lib/pages/boxing/parts/boxing_status_part.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member, unnecessary_this
|
||||
|
||||
part of '../../boxing_page.dart';
|
||||
|
||||
extension _BoxingStatusPart on _BoxingPageState {
|
||||
StatusDotColor _dotForActionError(BoxingActionErrorKind? kind) {
|
||||
return switch (kind) {
|
||||
BoxingActionErrorKind.network => StatusDotColor.yellow,
|
||||
BoxingActionErrorKind.duplicate => StatusDotColor.amber,
|
||||
_ => StatusDotColor.red,
|
||||
};
|
||||
}
|
||||
|
||||
void _showActionError<T>(
|
||||
BoxingActionResult<T> action, {
|
||||
required String fallback,
|
||||
}) {
|
||||
_feedbackService.trigger(switch (action.errorKind) {
|
||||
BoxingActionErrorKind.network => FeedbackEvent.networkError,
|
||||
BoxingActionErrorKind.duplicate => FeedbackEvent.duplicateBoxNo,
|
||||
_ => FeedbackEvent.submitFailure,
|
||||
});
|
||||
this._showStatusOverride(
|
||||
action.errorMessage ?? fallback,
|
||||
this._dotForActionError(action.errorKind),
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
|
||||
void _showStatusOverride(String text, StatusDotColor dot, Duration duration) {
|
||||
setState(() {
|
||||
_statusOverrideText = text;
|
||||
_statusOverrideDot = dot;
|
||||
});
|
||||
Future.delayed(duration, () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusOverrideText = null;
|
||||
_statusOverrideDot = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _jumpToCompletedBox() {
|
||||
final boxNo = _completedJumpBoxNo;
|
||||
if (boxNo == null) return;
|
||||
setState(() {
|
||||
_boxNoController.text = boxNo.toString();
|
||||
_completedJumpBoxNo = null;
|
||||
_statusOverrideText = null;
|
||||
_statusOverrideDot = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _openDetail() {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BoxingDetailPage(
|
||||
paichanNo: _paichanNo ?? '',
|
||||
existingBoxes: _existingBoxes,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _updateBaseStatus() {
|
||||
final status = boxingStatusFor(
|
||||
isAutoProcessing: _isAutoProcessing,
|
||||
isSubmitting: _isSubmitting,
|
||||
isDuplicateBoxNo: _isDuplicateBoxNo,
|
||||
quantityTooHigh: _quantityTooHigh,
|
||||
isEditingAssigned: _editingAssignedItemId != null,
|
||||
isDeletingAssigned: _deletingAssignedItemId != null,
|
||||
hasPaichanSwitchNotice: _paichanSwitchNotice != null,
|
||||
phase: _phase,
|
||||
isMultiCode: _mode == BoxingMode.multiCode,
|
||||
hasVisibleManyToOneItems: _visibleManyToOnePackedItems.isNotEmpty,
|
||||
remainingQuantity: _remainingQuantity,
|
||||
boxNoText: _boxNoController.text,
|
||||
);
|
||||
_statusDot = status.dot;
|
||||
_statusText = status.text;
|
||||
}
|
||||
}
|
||||
204
lib/pages/boxing/parts/boxing_submit_part.dart
Normal file
204
lib/pages/boxing/parts/boxing_submit_part.dart
Normal file
@@ -0,0 +1,204 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member, unnecessary_this
|
||||
|
||||
part of '../../boxing_page.dart';
|
||||
|
||||
extension _BoxingSubmitPart on _BoxingPageState {
|
||||
Future<bool> _submit() async {
|
||||
if (!_canSubmit) return false;
|
||||
|
||||
final boxNo = int.parse(_boxNoController.text);
|
||||
final quantity = int.parse(_quantityController.text);
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
final editing = _editingBox;
|
||||
final action = editing == null
|
||||
? await _apiActions.saveBoxRecord(
|
||||
zongpaiNo: _zongpaiNo!,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
)
|
||||
: await _apiActions.updateBoxRecord(
|
||||
boxItemId: editing.boxItemId,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
|
||||
if (!mounted) return false;
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
|
||||
if (action.success) {
|
||||
if (editing == null) {
|
||||
this._onSubmitSuccess(boxNo, quantity, action.data!.boxItemId);
|
||||
} else {
|
||||
this._onUpdateSuccess(editing, boxNo, quantity);
|
||||
}
|
||||
return true;
|
||||
} else if (action.errorKind == BoxingActionErrorKind.duplicate) {
|
||||
_feedbackService.trigger(FeedbackEvent.duplicateBoxNo);
|
||||
this._showStatusOverride(
|
||||
'该总排号在箱号 ${action.boxNo ?? ""} 已存在,请重新输入',
|
||||
StatusDotColor.amber,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return false;
|
||||
} else {
|
||||
this._showActionError(action, fallback: '提交失败');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void _onSubmitSuccess(int boxNo, int quantity, int? boxItemId) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||
|
||||
setState(() {
|
||||
if (_mode == BoxingMode.singleCode && boxItemId != null) {
|
||||
_currentZongpaiBoxes = List.from(_currentZongpaiBoxes)
|
||||
..add(
|
||||
CurrentZongpaiBoxData(
|
||||
boxItemId: boxItemId,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
),
|
||||
);
|
||||
}
|
||||
this._addExistingBoxItem(boxNo, quantity, boxItemId);
|
||||
_maxBoxNo = _maxBoxNo > boxNo ? _maxBoxNo : boxNo;
|
||||
});
|
||||
|
||||
switch (_mode) {
|
||||
case BoxingMode.singleCode:
|
||||
final remaining = _remainingQuantity;
|
||||
if (remaining <= 0) {
|
||||
this._showStatusOverride(
|
||||
'装箱成功',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
} else {
|
||||
setState(() {
|
||||
_phase = BoxingPhase.scanned;
|
||||
_boxNoController.text = (_maxBoxNo + 1).toString();
|
||||
_quantityController.text = remaining.toString();
|
||||
_quantityController.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: _quantityController.text.length,
|
||||
);
|
||||
_isDuplicateBoxNo = false;
|
||||
});
|
||||
_focusQuantityInput();
|
||||
this._showStatusOverride(
|
||||
'请继续完成剩余数量装箱',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
}
|
||||
|
||||
case BoxingMode.multiCode:
|
||||
_lastScannedPaichanNo = _paichanNo;
|
||||
setState(() {
|
||||
if (boxItemId != null) {
|
||||
_manyToOnePackedItems.add(
|
||||
ManyToOnePackedItem(
|
||||
boxItemId: boxItemId,
|
||||
zongpaiNo: _zongpaiNo!,
|
||||
paichanNo: _paichanNo,
|
||||
workOrderNo: _workOrderNo,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
totalQuantity: _erpQuantity,
|
||||
),
|
||||
);
|
||||
}
|
||||
_phase = BoxingPhase.waiting;
|
||||
_zongpaiNo = null;
|
||||
_editingPackedItemId = null;
|
||||
_editingPackedQuantity = '';
|
||||
_deletingPackedItemId = null;
|
||||
});
|
||||
this._showStatusOverride(
|
||||
'装箱成功,请继续扫码',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onUpdateSuccess(
|
||||
CurrentZongpaiBoxData editing,
|
||||
int boxNo,
|
||||
int quantity,
|
||||
) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||
|
||||
setState(() {
|
||||
_currentZongpaiBoxes = _currentZongpaiBoxes.map((item) {
|
||||
if (item.boxItemId != editing.boxItemId) return item;
|
||||
return CurrentZongpaiBoxData(
|
||||
boxItemId: item.boxItemId,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
}).toList();
|
||||
this._replaceExistingBoxItem(editing, boxNo, quantity);
|
||||
_editingBox = null;
|
||||
_boxNoController.text = (_maxBoxNo + 1).toString();
|
||||
final remaining = _remainingQuantity;
|
||||
if (remaining > 0) {
|
||||
_quantityController.text = remaining.toString();
|
||||
} else {
|
||||
_quantityController.clear();
|
||||
}
|
||||
_isDuplicateBoxNo = false;
|
||||
});
|
||||
this._showStatusOverride(
|
||||
'修改成功',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submitAccessory(PendingAccessory accessory) async {
|
||||
if (_zongpaiNo == null || _isSubmitting) return;
|
||||
|
||||
final boxNo = int.tryParse(_boxNoController.text);
|
||||
if (boxNo == null || boxNo <= 0) {
|
||||
this._showStatusOverride(
|
||||
'请先输入有效箱号',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
final action = await _apiActions.saveBoxRecord(
|
||||
zongpaiNo: _zongpaiNo!,
|
||||
boxNo: boxNo,
|
||||
quantity: accessory.quantity,
|
||||
itemType: 'accessory',
|
||||
accessoryId: accessory.accessoryId,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
|
||||
if (action.success) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||
// Refresh box info to update pending accessories
|
||||
await this._queryBoxInfo(_zongpaiNo!);
|
||||
if (mounted) {
|
||||
this._showStatusOverride(
|
||||
'${accessory.accessoryType} 已加入箱号 $boxNo',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this._showActionError(action, fallback: '附件装箱失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
178
lib/pages/boxing/widgets/boxing_notice_banners.dart
Normal file
178
lib/pages/boxing/widgets/boxing_notice_banners.dart
Normal file
@@ -0,0 +1,178 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
||||
|
||||
class BoxingNoticeBanners extends StatelessWidget {
|
||||
final bool isAutoProcessing;
|
||||
final List<String> autoWarnings;
|
||||
final String? paichanSwitchNotice;
|
||||
final bool isDuplicateBoxNo;
|
||||
final bool quantityTooHigh;
|
||||
final BoxingPhase phase;
|
||||
final String boxNoText;
|
||||
final int remainingQuantity;
|
||||
final String? zongpaiNo;
|
||||
final int? completedJumpBoxNo;
|
||||
final VoidCallback onJumpToCompletedBox;
|
||||
|
||||
const BoxingNoticeBanners({
|
||||
super.key,
|
||||
required this.isAutoProcessing,
|
||||
required this.autoWarnings,
|
||||
required this.paichanSwitchNotice,
|
||||
required this.isDuplicateBoxNo,
|
||||
required this.quantityTooHigh,
|
||||
required this.phase,
|
||||
required this.boxNoText,
|
||||
required this.remainingQuantity,
|
||||
required this.zongpaiNo,
|
||||
required this.completedJumpBoxNo,
|
||||
required this.onJumpToCompletedBox,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final banners = <Widget>[];
|
||||
if (isAutoProcessing) {
|
||||
banners.add(
|
||||
_NoticeBanner(
|
||||
icon: SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
color: Colors.orange.shade700,
|
||||
),
|
||||
),
|
||||
text: '正在同步转运数据...',
|
||||
background: Colors.orange.shade50,
|
||||
foreground: Colors.orange.shade900,
|
||||
border: Colors.orange.shade100,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (autoWarnings.isNotEmpty) {
|
||||
banners.add(
|
||||
_NoticeBanner(
|
||||
icon: Icon(
|
||||
Icons.warning_amber,
|
||||
size: 14,
|
||||
color: Colors.amber.shade900,
|
||||
),
|
||||
text: autoWarnings.join(';'),
|
||||
background: Colors.amber.shade100,
|
||||
foreground: Colors.amber.shade900,
|
||||
border: Colors.amber.shade200,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (paichanSwitchNotice != null) {
|
||||
banners.add(
|
||||
_NoticeBanner(
|
||||
icon: Icon(Icons.info_outline, size: 14, color: Colors.blue.shade900),
|
||||
text: paichanSwitchNotice!,
|
||||
background: Colors.blue.shade50,
|
||||
foreground: Colors.blue.shade900,
|
||||
border: Colors.blue.shade100,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (isDuplicateBoxNo && phase == BoxingPhase.scanned) {
|
||||
banners.add(
|
||||
_NoticeBanner(
|
||||
icon: Icon(
|
||||
Icons.warning_amber,
|
||||
size: 14,
|
||||
color: Colors.amber.shade900,
|
||||
),
|
||||
text: '箱号 $boxNoText 已存在,请重新输入',
|
||||
background: Colors.amber.shade100,
|
||||
foreground: Colors.amber.shade900,
|
||||
border: Colors.amber.shade200,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (quantityTooHigh && phase == BoxingPhase.scanned) {
|
||||
banners.add(
|
||||
_NoticeBanner(
|
||||
icon: Icon(Icons.close, size: 14, color: Colors.red.shade800),
|
||||
text: '超出可装数量上限(最多可装 $remainingQuantity 件)',
|
||||
background: Colors.red.shade50,
|
||||
foreground: Colors.red.shade800,
|
||||
border: Colors.red.shade100,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (phase == BoxingPhase.scanned &&
|
||||
zongpaiNo != null &&
|
||||
remainingQuantity <= 0) {
|
||||
final jumpBoxNo = completedJumpBoxNo;
|
||||
if (jumpBoxNo != null) {
|
||||
banners.add(
|
||||
GestureDetector(
|
||||
onTap: onJumpToCompletedBox,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: _NoticeBanner(
|
||||
icon: Icon(Icons.warning, size: 14, color: Colors.amber.shade800),
|
||||
text: '$zongpaiNo 已完成装箱。(P4跳转所在箱号)',
|
||||
background: Colors.amber.shade50,
|
||||
foreground: Colors.amber.shade800,
|
||||
border: Colors.amber.shade200,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
banners.add(
|
||||
_NoticeBanner(
|
||||
icon: Icon(Icons.warning, size: 14, color: Colors.red.shade800),
|
||||
text: '$zongpaiNo 已全部装箱完毕,无需操作',
|
||||
background: Colors.red.shade50,
|
||||
foreground: Colors.red.shade800,
|
||||
border: Colors.red.shade100,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Column(mainAxisSize: MainAxisSize.min, children: banners);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoticeBanner extends StatelessWidget {
|
||||
final Widget icon;
|
||||
final String text;
|
||||
final Color background;
|
||||
final Color foreground;
|
||||
final Color border;
|
||||
|
||||
const _NoticeBanner({
|
||||
required this.icon,
|
||||
required this.text,
|
||||
required this.background,
|
||||
required this.foreground,
|
||||
required this.border,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
border: Border(bottom: BorderSide(color: border)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
icon,
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(fontSize: 12, color: foreground),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
161
lib/pages/boxing/widgets/boxing_shared_widgets.dart
Normal file
161
lib/pages/boxing/widgets/boxing_shared_widgets.dart
Normal file
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const boxingHeaderStyle = TextStyle(fontSize: 10, fontWeight: FontWeight.w700);
|
||||
|
||||
class BoxingDetailButton extends StatelessWidget {
|
||||
final bool enabled;
|
||||
final ColorScheme colorScheme;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const BoxingDetailButton({
|
||||
super.key,
|
||||
required this.enabled,
|
||||
required this.colorScheme,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final foreground = enabled ? colorScheme.primary : Colors.grey.shade400;
|
||||
final background = enabled ? Colors.blue.shade50 : Colors.grey.shade100;
|
||||
final border = enabled ? colorScheme.primary : Colors.grey.shade300;
|
||||
|
||||
return SizedBox(
|
||||
height: 34,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: enabled ? onPressed : null,
|
||||
icon: Icon(Icons.receipt_long, size: 15, color: foreground),
|
||||
label: Text(
|
||||
'详情',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: foreground,
|
||||
),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
backgroundColor: background,
|
||||
side: BorderSide(color: border),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BoxingModePill extends StatelessWidget {
|
||||
final bool isSingle;
|
||||
final String label;
|
||||
final bool enabled;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const BoxingModePill({
|
||||
super.key,
|
||||
required this.isSingle,
|
||||
required this.label,
|
||||
required this.enabled,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bgColor = isSingle ? Colors.blue.shade700 : Colors.orange.shade700;
|
||||
|
||||
return Material(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: bgColor,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: enabled ? onTap : null,
|
||||
child: Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.swap_horiz, color: Colors.white, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'P2',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.88),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CompactIconButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color? color;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const CompactIconButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
this.color,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: IconButton(
|
||||
icon: Icon(icon, size: 16),
|
||||
color: color,
|
||||
onPressed: onTap,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
InputDecoration compactInputDecoration({
|
||||
required bool disabled,
|
||||
required Color borderColor,
|
||||
}) {
|
||||
final disabledBorder = OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
);
|
||||
final activeBorder = OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(color: borderColor, width: 1.5),
|
||||
);
|
||||
return InputDecoration(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
border: activeBorder,
|
||||
enabledBorder: activeBorder,
|
||||
focusedBorder: activeBorder,
|
||||
disabledBorder: disabledBorder,
|
||||
filled: disabled,
|
||||
fillColor: Colors.grey.shade100,
|
||||
);
|
||||
}
|
||||
689
lib/pages/boxing/widgets/multi_code_body.dart
Normal file
689
lib/pages/boxing/widgets/multi_code_body.dart
Normal file
@@ -0,0 +1,689 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
||||
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
|
||||
import 'package:pad_scanner/pages/boxing/widgets/multi_packed_row.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
class MultiCodeBody extends StatelessWidget {
|
||||
final bool hasScan;
|
||||
final String? zongpaiNo;
|
||||
final String? paichanNo;
|
||||
final String? workOrderNo;
|
||||
final int? erpQuantity;
|
||||
final int maxBoxNo;
|
||||
final List<BoxDetailData> existingBoxes;
|
||||
final List<ManyToOnePackedItem> visibleItems;
|
||||
final TextEditingController boxNoController;
|
||||
final TextEditingController quantityController;
|
||||
final FocusNode boxNoFocusNode;
|
||||
final FocusNode quantityFocusNode;
|
||||
final bool isSubmitting;
|
||||
final bool quantityTooHigh;
|
||||
final bool canSubmit;
|
||||
final int? editingPackedItemId;
|
||||
final String editingPackedQuantity;
|
||||
final int? deletingPackedItemId;
|
||||
final VoidCallback onOpenDetail;
|
||||
final VoidCallback onSubmitFromKeyboard;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback onQuantityChanged;
|
||||
final VoidCallback onFinishBox;
|
||||
final ValueChanged<String> onEditingPackedQuantityChanged;
|
||||
final ValueChanged<ManyToOnePackedItem> onSavePackedItem;
|
||||
final VoidCallback onCancelEditPackedItem;
|
||||
final ValueChanged<ManyToOnePackedItem> onStartEditPackedItem;
|
||||
final ValueChanged<ManyToOnePackedItem> onStartDeletePackedItem;
|
||||
final ValueChanged<ManyToOnePackedItem> onDeletePackedItem;
|
||||
final VoidCallback onCancelDeletePackedItem;
|
||||
final List<PendingAccessory> pendingAccessories;
|
||||
final ValueChanged<PendingAccessory> onSubmitAccessory;
|
||||
|
||||
const MultiCodeBody({
|
||||
super.key,
|
||||
required this.hasScan,
|
||||
required this.zongpaiNo,
|
||||
required this.paichanNo,
|
||||
required this.workOrderNo,
|
||||
required this.erpQuantity,
|
||||
required this.maxBoxNo,
|
||||
required this.existingBoxes,
|
||||
required this.visibleItems,
|
||||
required this.boxNoController,
|
||||
required this.quantityController,
|
||||
required this.boxNoFocusNode,
|
||||
required this.quantityFocusNode,
|
||||
required this.isSubmitting,
|
||||
required this.quantityTooHigh,
|
||||
required this.canSubmit,
|
||||
required this.editingPackedItemId,
|
||||
required this.editingPackedQuantity,
|
||||
required this.deletingPackedItemId,
|
||||
required this.onOpenDetail,
|
||||
required this.onSubmitFromKeyboard,
|
||||
required this.onSubmit,
|
||||
required this.onQuantityChanged,
|
||||
required this.onFinishBox,
|
||||
required this.onEditingPackedQuantityChanged,
|
||||
required this.onSavePackedItem,
|
||||
required this.onCancelEditPackedItem,
|
||||
required this.onStartEditPackedItem,
|
||||
required this.onStartDeletePackedItem,
|
||||
required this.onDeletePackedItem,
|
||||
required this.onCancelDeletePackedItem,
|
||||
required this.pendingAccessories,
|
||||
required this.onSubmitAccessory,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
_MultiHeaderBar(
|
||||
paichanNo: paichanNo,
|
||||
maxBoxNo: maxBoxNo,
|
||||
existingBoxes: existingBoxes,
|
||||
boxNoController: boxNoController,
|
||||
boxNoFocusNode: boxNoFocusNode,
|
||||
isSubmitting: isSubmitting,
|
||||
onSubmitFromKeyboard: onSubmitFromKeyboard,
|
||||
onOpenDetail: onOpenDetail,
|
||||
),
|
||||
const _MultiListHeader(),
|
||||
Expanded(
|
||||
child: visibleItems.isEmpty && pendingAccessories.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'尚未装入任何物料',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey.shade500,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: visibleItems.length + pendingAccessories.length,
|
||||
itemBuilder: (context, index) {
|
||||
if (index < visibleItems.length) {
|
||||
final item = visibleItems[index];
|
||||
return MultiPackedRow(
|
||||
item: item,
|
||||
editing: editingPackedItemId == item.boxItemId,
|
||||
deleting: deletingPackedItemId == item.boxItemId,
|
||||
isSubmitting: isSubmitting,
|
||||
editingQuantity: editingPackedQuantity,
|
||||
onEditingQuantityChanged:
|
||||
onEditingPackedQuantityChanged,
|
||||
onSave: () => onSavePackedItem(item),
|
||||
onCancelEdit: onCancelEditPackedItem,
|
||||
onStartEdit: () => onStartEditPackedItem(item),
|
||||
onStartDelete: () => onStartDeletePackedItem(item),
|
||||
onDelete: () => onDeletePackedItem(item),
|
||||
onCancelDelete: onCancelDeletePackedItem,
|
||||
);
|
||||
}
|
||||
final accIndex = index - visibleItems.length;
|
||||
final acc = pendingAccessories[accIndex];
|
||||
return _MultiAccessoryRow(
|
||||
accessory: acc,
|
||||
isSubmitting: isSubmitting,
|
||||
onSubmit: () => onSubmitAccessory(acc),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
_MultiBottomArea(
|
||||
hasScan: hasScan,
|
||||
hasItems: visibleItems.isNotEmpty,
|
||||
zongpaiNo: zongpaiNo,
|
||||
workOrderNo: workOrderNo,
|
||||
erpQuantity: erpQuantity,
|
||||
quantityController: quantityController,
|
||||
quantityFocusNode: quantityFocusNode,
|
||||
isSubmitting: isSubmitting,
|
||||
quantityTooHigh: quantityTooHigh,
|
||||
canSubmit: canSubmit,
|
||||
onSubmitFromKeyboard: onSubmitFromKeyboard,
|
||||
onSubmit: onSubmit,
|
||||
onQuantityChanged: onQuantityChanged,
|
||||
onFinishBox: onFinishBox,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MultiHeaderBar extends StatelessWidget {
|
||||
final String? paichanNo;
|
||||
final int maxBoxNo;
|
||||
final List<BoxDetailData> existingBoxes;
|
||||
final TextEditingController boxNoController;
|
||||
final FocusNode boxNoFocusNode;
|
||||
final bool isSubmitting;
|
||||
final VoidCallback onSubmitFromKeyboard;
|
||||
final VoidCallback onOpenDetail;
|
||||
|
||||
const _MultiHeaderBar({
|
||||
required this.paichanNo,
|
||||
required this.maxBoxNo,
|
||||
required this.existingBoxes,
|
||||
required this.boxNoController,
|
||||
required this.boxNoFocusNode,
|
||||
required this.isSubmitting,
|
||||
required this.onSubmitFromKeyboard,
|
||||
required this.onOpenDetail,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasDetail = existingBoxes.isNotEmpty;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'箱号',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
SizedBox(
|
||||
width: 52,
|
||||
height: 32,
|
||||
child: TextField(
|
||||
controller: boxNoController,
|
||||
focusNode: boxNoFocusNode,
|
||||
enabled: !isSubmitting,
|
||||
keyboardType: TextInputType.none,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
textAlign: TextAlign.center,
|
||||
onEditingComplete: () {},
|
||||
onSubmitted: (_) => onSubmitFromKeyboard(),
|
||||
decoration: InputDecoration(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(color: Colors.grey.shade400),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: Colors.blue, width: 1.5),
|
||||
),
|
||||
),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 28,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 10),
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
paichanNo ?? '--',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
'已有 ${existingBoxes.length} 箱 · 最大箱号 $maxBoxNo',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey.shade500),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
BoxingDetailButton(
|
||||
enabled: hasDetail,
|
||||
colorScheme: Theme.of(context).colorScheme,
|
||||
onPressed: onOpenDetail,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MultiListHeader extends StatelessWidget {
|
||||
const _MultiListHeader();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 28,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade400)),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 26,
|
||||
child: Text(
|
||||
'总排号',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 30,
|
||||
child: Text(
|
||||
'工令号',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 23,
|
||||
child: Text(
|
||||
'数量',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 54,
|
||||
child: Text(
|
||||
'操作',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MultiBottomArea extends StatelessWidget {
|
||||
final bool hasScan;
|
||||
final bool hasItems;
|
||||
final String? zongpaiNo;
|
||||
final String? workOrderNo;
|
||||
final int? erpQuantity;
|
||||
final TextEditingController quantityController;
|
||||
final FocusNode quantityFocusNode;
|
||||
final bool isSubmitting;
|
||||
final bool quantityTooHigh;
|
||||
final bool canSubmit;
|
||||
final VoidCallback onSubmitFromKeyboard;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback onQuantityChanged;
|
||||
final VoidCallback onFinishBox;
|
||||
|
||||
const _MultiBottomArea({
|
||||
required this.hasScan,
|
||||
required this.hasItems,
|
||||
required this.zongpaiNo,
|
||||
required this.workOrderNo,
|
||||
required this.erpQuantity,
|
||||
required this.quantityController,
|
||||
required this.quantityFocusNode,
|
||||
required this.isSubmitting,
|
||||
required this.quantityTooHigh,
|
||||
required this.canSubmit,
|
||||
required this.onSubmitFromKeyboard,
|
||||
required this.onSubmit,
|
||||
required this.onQuantityChanged,
|
||||
required this.onFinishBox,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_MultiScanBar(
|
||||
hasScan: hasScan,
|
||||
zongpaiNo: zongpaiNo,
|
||||
workOrderNo: workOrderNo,
|
||||
erpQuantity: erpQuantity,
|
||||
),
|
||||
_MultiSubmitRow(
|
||||
hasScan: hasScan,
|
||||
hasItems: hasItems,
|
||||
quantityController: quantityController,
|
||||
quantityFocusNode: quantityFocusNode,
|
||||
isSubmitting: isSubmitting,
|
||||
quantityTooHigh: quantityTooHigh,
|
||||
canSubmit: canSubmit,
|
||||
onSubmitFromKeyboard: onSubmitFromKeyboard,
|
||||
onSubmit: onSubmit,
|
||||
onQuantityChanged: onQuantityChanged,
|
||||
onFinishBox: onFinishBox,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MultiScanBar extends StatelessWidget {
|
||||
final bool hasScan;
|
||||
final String? zongpaiNo;
|
||||
final String? workOrderNo;
|
||||
final int? erpQuantity;
|
||||
|
||||
const _MultiScanBar({
|
||||
required this.hasScan,
|
||||
required this.zongpaiNo,
|
||||
required this.workOrderNo,
|
||||
required this.erpQuantity,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!hasScan) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
color: Colors.grey.shade50,
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.qr_code_scanner, color: Colors.grey, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'请扫描执行卡二维码',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.green.shade200)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle, color: Colors.green, size: 15),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${zongpaiNo ?? ""} · ${workOrderNo ?? "--"} · ${erpQuantity ?? "--"}件',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.green.shade700,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MultiSubmitRow extends StatelessWidget {
|
||||
final bool hasScan;
|
||||
final bool hasItems;
|
||||
final TextEditingController quantityController;
|
||||
final FocusNode quantityFocusNode;
|
||||
final bool isSubmitting;
|
||||
final bool quantityTooHigh;
|
||||
final bool canSubmit;
|
||||
final VoidCallback onSubmitFromKeyboard;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback onQuantityChanged;
|
||||
final VoidCallback onFinishBox;
|
||||
|
||||
const _MultiSubmitRow({
|
||||
required this.hasScan,
|
||||
required this.hasItems,
|
||||
required this.quantityController,
|
||||
required this.quantityFocusNode,
|
||||
required this.isSubmitting,
|
||||
required this.quantityTooHigh,
|
||||
required this.canSubmit,
|
||||
required this.onSubmitFromKeyboard,
|
||||
required this.onSubmit,
|
||||
required this.onQuantityChanged,
|
||||
required this.onFinishBox,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 7, 12, 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'数量',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: hasScan ? Colors.black54 : Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
SizedBox(
|
||||
width: 52,
|
||||
height: 34,
|
||||
child: TextField(
|
||||
controller: quantityController,
|
||||
focusNode: quantityFocusNode,
|
||||
enabled: hasScan && !isSubmitting,
|
||||
keyboardType: TextInputType.none,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
textAlign: TextAlign.center,
|
||||
onChanged: (_) => onQuantityChanged(),
|
||||
onEditingComplete: () {},
|
||||
onSubmitted: (_) => onSubmitFromKeyboard(),
|
||||
decoration: InputDecoration(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(
|
||||
color: quantityTooHigh ? Colors.red : Colors.grey.shade300,
|
||||
),
|
||||
),
|
||||
disabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
filled: !hasScan,
|
||||
fillColor: Colors.grey.shade100,
|
||||
),
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
height: 34,
|
||||
child: ElevatedButton(
|
||||
onPressed: canSubmit ? onSubmit : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: canSubmit
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade200,
|
||||
foregroundColor: canSubmit
|
||||
? Colors.white
|
||||
: Colors.grey.shade400,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
child: isSubmitting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'确认',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 34,
|
||||
child: OutlinedButton(
|
||||
onPressed: hasItems ? onFinishBox : null,
|
||||
style: OutlinedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
side: BorderSide(
|
||||
color: hasItems
|
||||
? Colors.grey.shade600
|
||||
: Colors.grey.shade300,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'完成本箱',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: hasItems ? Colors.black87 : Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: hasItems
|
||||
? Colors.grey.shade200
|
||||
: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
'P3',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: hasItems
|
||||
? Colors.black54
|
||||
: Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MultiAccessoryRow extends StatelessWidget {
|
||||
final PendingAccessory accessory;
|
||||
final bool isSubmitting;
|
||||
final VoidCallback onSubmit;
|
||||
|
||||
const _MultiAccessoryRow({
|
||||
required this.accessory,
|
||||
required this.isSubmitting,
|
||||
required this.onSubmit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade200)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.build, size: 14, color: Colors.orange.shade600),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
flex: 40,
|
||||
child: Text(
|
||||
accessory.accessoryType,
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 20,
|
||||
child: Text(
|
||||
'\u00d7${accessory.quantity}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 28,
|
||||
child: ElevatedButton(
|
||||
onPressed: isSubmitting ? null : onSubmit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isSubmitting
|
||||
? Colors.grey.shade300
|
||||
: Colors.orange.shade600,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'加入本箱',
|
||||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
239
lib/pages/boxing/widgets/multi_packed_row.dart
Normal file
239
lib/pages/boxing/widgets/multi_packed_row.dart
Normal file
@@ -0,0 +1,239 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
||||
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
|
||||
|
||||
class MultiPackedRow extends StatelessWidget {
|
||||
final ManyToOnePackedItem item;
|
||||
final bool editing;
|
||||
final bool deleting;
|
||||
final bool isSubmitting;
|
||||
final String editingQuantity;
|
||||
final ValueChanged<String> onEditingQuantityChanged;
|
||||
final VoidCallback onSave;
|
||||
final VoidCallback onCancelEdit;
|
||||
final VoidCallback onStartEdit;
|
||||
final VoidCallback onStartDelete;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onCancelDelete;
|
||||
|
||||
const MultiPackedRow({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.editing,
|
||||
required this.deleting,
|
||||
required this.isSubmitting,
|
||||
required this.editingQuantity,
|
||||
required this.onEditingQuantityChanged,
|
||||
required this.onSave,
|
||||
required this.onCancelEdit,
|
||||
required this.onStartEdit,
|
||||
required this.onStartDelete,
|
||||
required this.onDelete,
|
||||
required this.onCancelDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final totalText = item.totalQuantity?.toString() ?? '--';
|
||||
final barColor = editing
|
||||
? Colors.amber
|
||||
: (deleting ? Colors.red : Colors.green);
|
||||
|
||||
final Color bgColor;
|
||||
if (editing) {
|
||||
bgColor = Colors.yellow.shade50;
|
||||
} else if (deleting) {
|
||||
bgColor = Colors.red.shade50;
|
||||
} else {
|
||||
bgColor = Colors.transparent;
|
||||
}
|
||||
|
||||
final rowStyle = TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
constraints: const BoxConstraints(minHeight: 36),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
border: Border(
|
||||
left: BorderSide(color: barColor, width: 3),
|
||||
bottom: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 26,
|
||||
child: Text(
|
||||
item.zongpaiNo,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 30,
|
||||
child: Text(
|
||||
item.workOrderNo ?? '--',
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 23,
|
||||
child: editing
|
||||
? SizedBox(
|
||||
width: 52,
|
||||
height: 26,
|
||||
child: TextField(
|
||||
keyboardType: TextInputType.none,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
textAlign: TextAlign.center,
|
||||
controller:
|
||||
TextEditingController(text: editingQuantity)
|
||||
..selection = TextSelection.collapsed(
|
||||
offset: editingQuantity.length,
|
||||
),
|
||||
onChanged: onEditingQuantityChanged,
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(
|
||||
color: Colors.amber,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'${item.quantity} / $totalText',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: deleting ? Colors.red : Colors.green.shade700,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 54,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (editing) ...[
|
||||
CompactIconButton(
|
||||
icon: Icons.check,
|
||||
color: Colors.green,
|
||||
onTap: isSubmitting ? null : onSave,
|
||||
),
|
||||
CompactIconButton(
|
||||
icon: Icons.close,
|
||||
color: Colors.grey,
|
||||
onTap: isSubmitting ? null : onCancelEdit,
|
||||
),
|
||||
] else ...[
|
||||
CompactIconButton(
|
||||
icon: Icons.edit,
|
||||
color: Colors.grey.shade700,
|
||||
onTap: isSubmitting ? null : onStartEdit,
|
||||
),
|
||||
CompactIconButton(
|
||||
icon: Icons.delete_outline,
|
||||
color: Colors.red,
|
||||
onTap: isSubmitting ? null : onStartDelete,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (deleting)
|
||||
Container(
|
||||
height: 28,
|
||||
padding: const EdgeInsets.only(left: 15, right: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.red.shade200)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'确认删除?',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.red,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: isSubmitting ? null : onDelete,
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
backgroundColor: Colors.red.shade100,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'删除',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
TextButton(
|
||||
onPressed: isSubmitting ? null : onCancelDelete,
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text(
|
||||
'取消',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
231
lib/pages/boxing/widgets/single_assigned_row.dart
Normal file
231
lib/pages/boxing/widgets/single_assigned_row.dart
Normal file
@@ -0,0 +1,231 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
class SingleAssignedRow extends StatelessWidget {
|
||||
final CurrentZongpaiBoxData item;
|
||||
final String? workOrderNo;
|
||||
final bool editing;
|
||||
final bool deleting;
|
||||
final bool isSubmitting;
|
||||
final String editingQuantity;
|
||||
final ValueChanged<String> onEditingQuantityChanged;
|
||||
final VoidCallback onSave;
|
||||
final VoidCallback onCancelEdit;
|
||||
final VoidCallback onStartEdit;
|
||||
final VoidCallback onStartDelete;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onCancelDelete;
|
||||
|
||||
const SingleAssignedRow({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.workOrderNo,
|
||||
required this.editing,
|
||||
required this.deleting,
|
||||
required this.isSubmitting,
|
||||
required this.editingQuantity,
|
||||
required this.onEditingQuantityChanged,
|
||||
required this.onSave,
|
||||
required this.onCancelEdit,
|
||||
required this.onStartEdit,
|
||||
required this.onStartDelete,
|
||||
required this.onDelete,
|
||||
required this.onCancelDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final barColor = editing
|
||||
? Colors.amber
|
||||
: (deleting ? Colors.red : Colors.green);
|
||||
|
||||
final Color bgColor;
|
||||
if (editing) {
|
||||
bgColor = Colors.yellow.shade50;
|
||||
} else if (deleting) {
|
||||
bgColor = Colors.red.shade50;
|
||||
} else {
|
||||
bgColor = Colors.transparent;
|
||||
}
|
||||
|
||||
final rowStyle = TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
constraints: const BoxConstraints(minHeight: 36),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
border: Border(
|
||||
left: BorderSide(color: barColor, width: 3),
|
||||
bottom: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 22,
|
||||
child: Text(
|
||||
'${item.boxNo} 号箱',
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 30,
|
||||
child: Text(
|
||||
workOrderNo ?? '--',
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 23,
|
||||
child: editing
|
||||
? SizedBox(
|
||||
width: 52,
|
||||
height: 28,
|
||||
child: TextField(
|
||||
keyboardType: TextInputType.none,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
textAlign: TextAlign.center,
|
||||
controller:
|
||||
TextEditingController(text: editingQuantity)
|
||||
..selection = TextSelection.collapsed(
|
||||
offset: editingQuantity.length,
|
||||
),
|
||||
onChanged: onEditingQuantityChanged,
|
||||
onSubmitted: (_) => onSave(),
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
borderSide: const BorderSide(
|
||||
color: Colors.amber,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'${item.quantity} 件',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: deleting ? Colors.red : Colors.black54,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 54,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (editing) ...[
|
||||
CompactIconButton(
|
||||
icon: Icons.check,
|
||||
color: Colors.green,
|
||||
onTap: isSubmitting ? null : onSave,
|
||||
),
|
||||
CompactIconButton(
|
||||
icon: Icons.close,
|
||||
color: Colors.grey,
|
||||
onTap: isSubmitting ? null : onCancelEdit,
|
||||
),
|
||||
] else ...[
|
||||
CompactIconButton(
|
||||
icon: Icons.edit,
|
||||
color: Colors.grey.shade700,
|
||||
onTap: isSubmitting ? null : onStartEdit,
|
||||
),
|
||||
CompactIconButton(
|
||||
icon: Icons.delete_outline,
|
||||
color: Colors.red,
|
||||
onTap: isSubmitting ? null : onStartDelete,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (deleting)
|
||||
Container(
|
||||
height: 28,
|
||||
padding: const EdgeInsets.only(left: 15, right: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.red.shade200)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'确认删除 ${item.boxNo} 号箱记录?',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.red),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: isSubmitting ? null : onDelete,
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 2,
|
||||
),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'删除',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
TextButton(
|
||||
onPressed: isSubmitting ? null : onCancelDelete,
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 2,
|
||||
),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text(
|
||||
'取消',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
699
lib/pages/boxing/widgets/single_code_body.dart
Normal file
699
lib/pages/boxing/widgets/single_code_body.dart
Normal file
@@ -0,0 +1,699 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
|
||||
import 'package:pad_scanner/pages/boxing/widgets/single_assigned_row.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
class SingleCodeBody extends StatelessWidget {
|
||||
final bool isWaiting;
|
||||
final bool isFinished;
|
||||
final String? zongpaiNo;
|
||||
final String? paichanNo;
|
||||
final String? workOrderNo;
|
||||
final int? erpQuantity;
|
||||
final int packedQuantity;
|
||||
final int maxBoxNo;
|
||||
final List<CurrentZongpaiBoxData> assignedItems;
|
||||
final List<BoxDetailData> existingBoxes;
|
||||
final TextEditingController boxNoController;
|
||||
final TextEditingController quantityController;
|
||||
final FocusNode boxNoFocusNode;
|
||||
final FocusNode quantityFocusNode;
|
||||
final bool isSubmitting;
|
||||
final bool isDuplicateBoxNo;
|
||||
final bool quantityTooHigh;
|
||||
final bool canSubmit;
|
||||
final int? editingAssignedItemId;
|
||||
final String editingAssignedQuantity;
|
||||
final int? deletingAssignedItemId;
|
||||
final VoidCallback onOpenDetail;
|
||||
final VoidCallback onCheckDuplicateBoxNo;
|
||||
final VoidCallback onSubmitFromKeyboard;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback onQuantityChanged;
|
||||
final ValueChanged<String> onEditingAssignedQuantityChanged;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onSaveAssignedItem;
|
||||
final VoidCallback onCancelEditAssigned;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onStartEditAssigned;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onStartDeleteAssigned;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onDeleteAssignedItem;
|
||||
final VoidCallback onCancelDeleteAssigned;
|
||||
final List<PendingAccessory> pendingAccessories;
|
||||
final ValueChanged<PendingAccessory> onSubmitAccessory;
|
||||
|
||||
const SingleCodeBody({
|
||||
super.key,
|
||||
required this.isWaiting,
|
||||
required this.isFinished,
|
||||
required this.zongpaiNo,
|
||||
required this.paichanNo,
|
||||
required this.workOrderNo,
|
||||
required this.erpQuantity,
|
||||
required this.packedQuantity,
|
||||
required this.maxBoxNo,
|
||||
required this.assignedItems,
|
||||
required this.existingBoxes,
|
||||
required this.boxNoController,
|
||||
required this.quantityController,
|
||||
required this.boxNoFocusNode,
|
||||
required this.quantityFocusNode,
|
||||
required this.isSubmitting,
|
||||
required this.isDuplicateBoxNo,
|
||||
required this.quantityTooHigh,
|
||||
required this.canSubmit,
|
||||
required this.editingAssignedItemId,
|
||||
required this.editingAssignedQuantity,
|
||||
required this.deletingAssignedItemId,
|
||||
required this.onOpenDetail,
|
||||
required this.onCheckDuplicateBoxNo,
|
||||
required this.onSubmitFromKeyboard,
|
||||
required this.onSubmit,
|
||||
required this.onQuantityChanged,
|
||||
required this.onEditingAssignedQuantityChanged,
|
||||
required this.onSaveAssignedItem,
|
||||
required this.onCancelEditAssigned,
|
||||
required this.onStartEditAssigned,
|
||||
required this.onStartDeleteAssigned,
|
||||
required this.onDeleteAssignedItem,
|
||||
required this.onCancelDeleteAssigned,
|
||||
required this.pendingAccessories,
|
||||
required this.onSubmitAccessory,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_SingleInfoBar(
|
||||
isWaiting: isWaiting,
|
||||
paichanNo: paichanNo,
|
||||
existingBoxes: existingBoxes,
|
||||
maxBoxNo: maxBoxNo,
|
||||
colorScheme: colorScheme,
|
||||
onOpenDetail: onOpenDetail,
|
||||
),
|
||||
_SingleScanBar(
|
||||
isWaiting: isWaiting,
|
||||
zongpaiNo: zongpaiNo,
|
||||
workOrderNo: workOrderNo,
|
||||
erpQuantity: erpQuantity,
|
||||
packedQuantity: packedQuantity,
|
||||
),
|
||||
_SingleAssignedList(
|
||||
isWaiting: isWaiting,
|
||||
isFinished: isFinished,
|
||||
items: assignedItems,
|
||||
workOrderNo: workOrderNo,
|
||||
isSubmitting: isSubmitting,
|
||||
editingAssignedItemId: editingAssignedItemId,
|
||||
editingAssignedQuantity: editingAssignedQuantity,
|
||||
deletingAssignedItemId: deletingAssignedItemId,
|
||||
onEditingAssignedQuantityChanged: onEditingAssignedQuantityChanged,
|
||||
onSaveAssignedItem: onSaveAssignedItem,
|
||||
onCancelEditAssigned: onCancelEditAssigned,
|
||||
onStartEditAssigned: onStartEditAssigned,
|
||||
onStartDeleteAssigned: onStartDeleteAssigned,
|
||||
onDeleteAssignedItem: onDeleteAssignedItem,
|
||||
onCancelDeleteAssigned: onCancelDeleteAssigned,
|
||||
),
|
||||
if (pendingAccessories.isNotEmpty && !isWaiting)
|
||||
_PendingAccessoriesSection(
|
||||
pendingAccessories: pendingAccessories,
|
||||
isSubmitting: isSubmitting,
|
||||
onSubmitAccessory: onSubmitAccessory,
|
||||
),
|
||||
_SingleBottomArea(
|
||||
disabled: isWaiting || isFinished,
|
||||
boxNoController: boxNoController,
|
||||
quantityController: quantityController,
|
||||
boxNoFocusNode: boxNoFocusNode,
|
||||
quantityFocusNode: quantityFocusNode,
|
||||
isSubmitting: isSubmitting,
|
||||
isDuplicateBoxNo: isDuplicateBoxNo,
|
||||
quantityTooHigh: quantityTooHigh,
|
||||
canSubmit: canSubmit,
|
||||
onCheckDuplicateBoxNo: onCheckDuplicateBoxNo,
|
||||
onSubmitFromKeyboard: onSubmitFromKeyboard,
|
||||
onSubmit: onSubmit,
|
||||
onQuantityChanged: onQuantityChanged,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleScanBar extends StatelessWidget {
|
||||
final bool isWaiting;
|
||||
final String? zongpaiNo;
|
||||
final String? workOrderNo;
|
||||
final int? erpQuantity;
|
||||
final int packedQuantity;
|
||||
|
||||
const _SingleScanBar({
|
||||
required this.isWaiting,
|
||||
required this.zongpaiNo,
|
||||
required this.workOrderNo,
|
||||
required this.erpQuantity,
|
||||
required this.packedQuantity,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isWaiting) {
|
||||
return Container(
|
||||
height: 35,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.qr_code_scanner, color: Colors.grey, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'请扫描执行卡二维码',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
height: 35,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.green.shade200)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle, color: Colors.green, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
zongpaiNo ?? '',
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${workOrderNo ?? "--"} / ${erpQuantity ?? 0}件 / 已装$packedQuantity',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleInfoBar extends StatelessWidget {
|
||||
final bool isWaiting;
|
||||
final String? paichanNo;
|
||||
final List<BoxDetailData> existingBoxes;
|
||||
final int maxBoxNo;
|
||||
final ColorScheme colorScheme;
|
||||
final VoidCallback onOpenDetail;
|
||||
|
||||
const _SingleInfoBar({
|
||||
required this.isWaiting,
|
||||
required this.paichanNo,
|
||||
required this.existingBoxes,
|
||||
required this.maxBoxNo,
|
||||
required this.colorScheme,
|
||||
required this.onOpenDetail,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final detailEnabled = !isWaiting && existingBoxes.isNotEmpty;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
isWaiting ? '排产号 --' : (paichanNo ?? '--'),
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isWaiting ? Colors.grey.shade400 : Colors.black87,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
isWaiting ? '已有 -- 箱' : '已有 ${existingBoxes.length} 箱',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isWaiting
|
||||
? Colors.grey.shade400
|
||||
: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
isWaiting ? '最大箱号 --' : '最大箱号 $maxBoxNo',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isWaiting
|
||||
? Colors.grey.shade400
|
||||
: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
BoxingDetailButton(
|
||||
enabled: detailEnabled,
|
||||
colorScheme: colorScheme,
|
||||
onPressed: onOpenDetail,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleAssignedList extends StatelessWidget {
|
||||
final bool isWaiting;
|
||||
final bool isFinished;
|
||||
final List<CurrentZongpaiBoxData> items;
|
||||
final String? workOrderNo;
|
||||
final bool isSubmitting;
|
||||
final int? editingAssignedItemId;
|
||||
final String editingAssignedQuantity;
|
||||
final int? deletingAssignedItemId;
|
||||
final ValueChanged<String> onEditingAssignedQuantityChanged;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onSaveAssignedItem;
|
||||
final VoidCallback onCancelEditAssigned;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onStartEditAssigned;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onStartDeleteAssigned;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onDeleteAssignedItem;
|
||||
final VoidCallback onCancelDeleteAssigned;
|
||||
|
||||
const _SingleAssignedList({
|
||||
required this.isWaiting,
|
||||
required this.isFinished,
|
||||
required this.items,
|
||||
required this.workOrderNo,
|
||||
required this.isSubmitting,
|
||||
required this.editingAssignedItemId,
|
||||
required this.editingAssignedQuantity,
|
||||
required this.deletingAssignedItemId,
|
||||
required this.onEditingAssignedQuantityChanged,
|
||||
required this.onSaveAssignedItem,
|
||||
required this.onCancelEditAssigned,
|
||||
required this.onStartEditAssigned,
|
||||
required this.onStartDeleteAssigned,
|
||||
required this.onDeleteAssignedItem,
|
||||
required this.onCancelDeleteAssigned,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
const _SingleAssignedHeader(),
|
||||
Expanded(
|
||||
child: items.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
isFinished
|
||||
? '该总排号已全部装箱完毕'
|
||||
: (isWaiting ? '扫码后显示已分配箱号记录' : '本次扫码尚未分配箱号'),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isFinished
|
||||
? Colors.green.shade700
|
||||
: Colors.grey.shade500,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return SingleAssignedRow(
|
||||
item: item,
|
||||
workOrderNo: workOrderNo,
|
||||
editing: editingAssignedItemId == item.boxItemId,
|
||||
deleting: deletingAssignedItemId == item.boxItemId,
|
||||
isSubmitting: isSubmitting,
|
||||
editingQuantity: editingAssignedQuantity,
|
||||
onEditingQuantityChanged:
|
||||
onEditingAssignedQuantityChanged,
|
||||
onSave: () => onSaveAssignedItem(item),
|
||||
onCancelEdit: onCancelEditAssigned,
|
||||
onStartEdit: () => onStartEditAssigned(item),
|
||||
onStartDelete: () => onStartDeleteAssigned(item),
|
||||
onDelete: () => onDeleteAssignedItem(item),
|
||||
onCancelDelete: onCancelDeleteAssigned,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleAssignedHeader extends StatelessWidget {
|
||||
const _SingleAssignedHeader();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 28,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade400)),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 22,
|
||||
child: Text(
|
||||
'箱号',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 30,
|
||||
child: Text(
|
||||
'工令号',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 23,
|
||||
child: Text(
|
||||
'数量',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 54,
|
||||
child: Text(
|
||||
'操作',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleBottomArea extends StatelessWidget {
|
||||
final bool disabled;
|
||||
final TextEditingController boxNoController;
|
||||
final TextEditingController quantityController;
|
||||
final FocusNode boxNoFocusNode;
|
||||
final FocusNode quantityFocusNode;
|
||||
final bool isSubmitting;
|
||||
final bool isDuplicateBoxNo;
|
||||
final bool quantityTooHigh;
|
||||
final bool canSubmit;
|
||||
final VoidCallback onCheckDuplicateBoxNo;
|
||||
final VoidCallback onSubmitFromKeyboard;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback onQuantityChanged;
|
||||
|
||||
const _SingleBottomArea({
|
||||
required this.disabled,
|
||||
required this.boxNoController,
|
||||
required this.quantityController,
|
||||
required this.boxNoFocusNode,
|
||||
required this.quantityFocusNode,
|
||||
required this.isSubmitting,
|
||||
required this.isDuplicateBoxNo,
|
||||
required this.quantityTooHigh,
|
||||
required this.canSubmit,
|
||||
required this.onCheckDuplicateBoxNo,
|
||||
required this.onSubmitFromKeyboard,
|
||||
required this.onSubmit,
|
||||
required this.onQuantityChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'箱号',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: disabled ? Colors.grey.shade400 : Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
SizedBox(
|
||||
width: 52,
|
||||
height: 34,
|
||||
child: TextField(
|
||||
controller: boxNoController,
|
||||
focusNode: boxNoFocusNode,
|
||||
enabled: !disabled && !isSubmitting,
|
||||
keyboardType: TextInputType.none,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
textAlign: TextAlign.center,
|
||||
onChanged: (_) => onCheckDuplicateBoxNo(),
|
||||
onSubmitted: (_) => onSubmitFromKeyboard(),
|
||||
decoration: compactInputDecoration(
|
||||
disabled: disabled || isSubmitting,
|
||||
borderColor: isDuplicateBoxNo
|
||||
? Colors.amber
|
||||
: Colors.blue.shade700,
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'数量',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: disabled ? Colors.grey.shade400 : Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
SizedBox(
|
||||
width: 52,
|
||||
height: 34,
|
||||
child: TextField(
|
||||
controller: quantityController,
|
||||
focusNode: quantityFocusNode,
|
||||
enabled: !disabled && !isSubmitting,
|
||||
keyboardType: TextInputType.none,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
textAlign: TextAlign.center,
|
||||
onChanged: (_) => onQuantityChanged(),
|
||||
onSubmitted: (_) => onSubmitFromKeyboard(),
|
||||
decoration: compactInputDecoration(
|
||||
disabled: disabled || isSubmitting,
|
||||
borderColor: quantityTooHigh
|
||||
? Colors.red
|
||||
: Colors.blue.shade700,
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
height: 34,
|
||||
child: ElevatedButton(
|
||||
onPressed: canSubmit ? onSubmit : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: canSubmit
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade300,
|
||||
foregroundColor: canSubmit
|
||||
? Colors.white
|
||||
: Colors.grey.shade600,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
child: isSubmitting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'确认',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingAccessoriesSection extends StatelessWidget {
|
||||
final List<PendingAccessory> pendingAccessories;
|
||||
final bool isSubmitting;
|
||||
final ValueChanged<PendingAccessory> onSubmitAccessory;
|
||||
|
||||
const _PendingAccessoriesSection({
|
||||
required this.pendingAccessories,
|
||||
required this.isSubmitting,
|
||||
required this.onSubmitAccessory,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 28,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.orange.shade200)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.build, size: 14, color: Colors.orange.shade700),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'待装箱附件(${pendingAccessories.length} 项)',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.orange.shade800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
...pendingAccessories.map(
|
||||
(acc) => _AccessoryRow(
|
||||
accessory: acc,
|
||||
isSubmitting: isSubmitting,
|
||||
onSubmit: () => onSubmitAccessory(acc),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AccessoryRow extends StatelessWidget {
|
||||
final PendingAccessory accessory;
|
||||
final bool isSubmitting;
|
||||
final VoidCallback onSubmit;
|
||||
|
||||
const _AccessoryRow({
|
||||
required this.accessory,
|
||||
required this.isSubmitting,
|
||||
required this.onSubmit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade200)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 40,
|
||||
child: Text(
|
||||
accessory.accessoryType,
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 20,
|
||||
child: Text(
|
||||
'\u00d7${accessory.quantity}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 28,
|
||||
child: ElevatedButton(
|
||||
onPressed: isSubmitting ? null : onSubmit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isSubmitting
|
||||
? Colors.grey.shade300
|
||||
: Colors.orange.shade600,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'加入本箱',
|
||||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -103,15 +103,40 @@ class BoxingDetailPage extends StatelessWidget {
|
||||
),
|
||||
child: Column(
|
||||
children: box.items.map((item) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
final isAccessory = item.itemType == 'accessory';
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 4,
|
||||
horizontal: 4,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(vertical: 1),
|
||||
decoration: isAccessory
|
||||
? BoxDecoration(
|
||||
color: Colors.orange.shade50,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
)
|
||||
: null,
|
||||
child: Row(
|
||||
children: [
|
||||
if (isAccessory)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: Icon(
|
||||
Icons.build,
|
||||
size: 14,
|
||||
color: Colors.orange.shade700,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Text(
|
||||
item.zongpaiNo,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: isAccessory
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,11 @@ import 'package:pad_scanner/services/app_config_service.dart';
|
||||
import 'package:pad_scanner/pages/settings_page.dart';
|
||||
import 'package:pad_scanner/pages/registration_page.dart';
|
||||
import 'package:pad_scanner/pages/boxing_page.dart';
|
||||
import 'package:pad_scanner/pages/accessory_page.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
/// Module type enum for each available feature card
|
||||
enum ModuleType { registration, boxing, shelfQuery }
|
||||
enum ModuleType { registration, boxing, accessory, shelfQuery }
|
||||
|
||||
/// Module status for PRD state tracking
|
||||
enum ModuleStatus { online, developing, planning }
|
||||
@@ -39,10 +40,6 @@ class HomePage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
static const _androidKeyCode1 = 8;
|
||||
static const _androidKeyCode2 = 9;
|
||||
static const _androidKeyCode3 = 10;
|
||||
|
||||
final _apiService = ApiService();
|
||||
final _focusNode = FocusNode();
|
||||
bool _isCheckingConnection = false;
|
||||
@@ -68,8 +65,16 @@ class _HomePageState extends State<HomePage> {
|
||||
route: '/boxing',
|
||||
),
|
||||
FeatureCard(
|
||||
type: ModuleType.shelfQuery,
|
||||
type: ModuleType.accessory,
|
||||
shortcut: '3',
|
||||
icon: Icons.build,
|
||||
title: '附件登记',
|
||||
status: ModuleStatus.developing,
|
||||
route: '/accessory',
|
||||
),
|
||||
FeatureCard(
|
||||
type: ModuleType.shelfQuery,
|
||||
shortcut: '4',
|
||||
icon: Icons.search_outlined,
|
||||
title: '货架查询',
|
||||
status: ModuleStatus.planning,
|
||||
@@ -105,16 +110,10 @@ class _HomePageState extends State<HomePage> {
|
||||
|
||||
FeatureCard? _moduleFromShortcut(KeyEvent event) {
|
||||
final shortcut = _shortcutNumberFromKeyEvent(event);
|
||||
if (shortcut == 1) {
|
||||
return _modules[0];
|
||||
if (shortcut == null || shortcut < 1 || shortcut > _modules.length) {
|
||||
return null;
|
||||
}
|
||||
if (shortcut == 2) {
|
||||
return _modules[1];
|
||||
}
|
||||
if (shortcut == 3) {
|
||||
return _modules[2];
|
||||
}
|
||||
return null;
|
||||
return _modules[shortcut - 1];
|
||||
}
|
||||
|
||||
int? _shortcutNumberFromKeyEvent(KeyEvent event) {
|
||||
@@ -134,22 +133,10 @@ class _HomePageState extends State<HomePage> {
|
||||
event.character == '3') {
|
||||
return 3;
|
||||
}
|
||||
|
||||
// Android keyCode fallback for scanner devices: 1/2/3 => 8/9/10.
|
||||
final androidKeyCode = _androidKeyCodeFromLogicalKey(key);
|
||||
return switch (androidKeyCode) {
|
||||
_androidKeyCode1 => 1,
|
||||
_androidKeyCode2 => 2,
|
||||
_androidKeyCode3 => 3,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
int? _androidKeyCodeFromLogicalKey(LogicalKeyboardKey key) {
|
||||
final keyId = key.keyId;
|
||||
const androidPlane = LogicalKeyboardKey.androidPlane;
|
||||
if (keyId >= androidPlane && keyId < androidPlane + 0x100000000) {
|
||||
return keyId - androidPlane;
|
||||
if (key == LogicalKeyboardKey.digit4 ||
|
||||
key == LogicalKeyboardKey.numpad4 ||
|
||||
event.character == '4') {
|
||||
return 4;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -202,6 +189,9 @@ class _HomePageState extends State<HomePage> {
|
||||
case ModuleType.boxing:
|
||||
targetPage = const BoxingPage();
|
||||
break;
|
||||
case ModuleType.accessory:
|
||||
targetPage = const AccessoryPage();
|
||||
break;
|
||||
case ModuleType.shelfQuery:
|
||||
return; // 规划中,不导航
|
||||
}
|
||||
|
||||
333
lib/pages/registration/dialogs/registration_dialogs_part.dart
Normal file
333
lib/pages/registration/dialogs/registration_dialogs_part.dart
Normal file
@@ -0,0 +1,333 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
part of '../../registration_page.dart';
|
||||
|
||||
extension _RegistrationDialogsPart on _RegistrationPageState {
|
||||
void _showLocationSwitchDialog(
|
||||
String newLocationCode,
|
||||
CodeType newLocationType,
|
||||
) {
|
||||
final count = _zongpaiNos.length;
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: Colors.orange.shade50,
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber, color: Colors.orange.shade800),
|
||||
const SizedBox(width: 8),
|
||||
Text('切换货架号', style: TextStyle(color: Colors.orange.shade900)),
|
||||
],
|
||||
),
|
||||
content: Text(
|
||||
'当前已有 $count 条待上架数据,切换货架号不会清除已扫描的总排号。',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
_feedbackService.trigger(FeedbackEvent.scanValid);
|
||||
setState(() {
|
||||
_locationCode = newLocationCode;
|
||||
_locationType = newLocationType;
|
||||
_clearStatusOverride();
|
||||
});
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.orange.shade700,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('确认切换'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDuplicateDialog(String zongpaiNo, Map<String, dynamic>? info) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: Colors.red.shade50,
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning, color: Colors.red),
|
||||
SizedBox(width: 8),
|
||||
Text('重复上架', style: TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('总排号:$zongpaiNo'),
|
||||
const SizedBox(height: 4),
|
||||
Text('已登记货位:${info?["location_code"] ?? "未知"}'),
|
||||
const SizedBox(height: 4),
|
||||
Text('登记时间:${info?["registered_at"] ?? "未知"}'),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'请核查实物,确认是否操作错误。',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
_feedbackService.stopAlert();
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showLocationConflictDialog({
|
||||
required List<PaichaOverviewItem> onShelfItems,
|
||||
required List<PaichaOverviewItem> transferredItems,
|
||||
}) {
|
||||
final contentParts = <Widget>[];
|
||||
|
||||
if (onShelfItems.isNotEmpty) {
|
||||
contentParts.add(
|
||||
Text(
|
||||
'重复上架',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.red.shade700,
|
||||
),
|
||||
),
|
||||
);
|
||||
for (final item in onShelfItems) {
|
||||
contentParts.addAll([
|
||||
Text('总排号:${item.zongpaiNo}'),
|
||||
Text('已登记货位:${item.locationCode ?? "未知"}'),
|
||||
const SizedBox(height: 6),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (transferredItems.isNotEmpty) {
|
||||
if (onShelfItems.isNotEmpty) {
|
||||
contentParts.add(const Divider());
|
||||
contentParts.add(const SizedBox(height: 4));
|
||||
}
|
||||
contentParts.add(
|
||||
Text(
|
||||
'货物已转运',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.orange.shade700,
|
||||
),
|
||||
),
|
||||
);
|
||||
for (final item in transferredItems) {
|
||||
contentParts.addAll([
|
||||
Text('总排号:${item.zongpaiNo}'),
|
||||
Text('转运货位:${item.locationCode ?? "未知"}'),
|
||||
const SizedBox(height: 6),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
contentParts.add(const SizedBox(height: 4));
|
||||
contentParts.add(
|
||||
const Text(
|
||||
'请核查实物,确认是否操作错误。',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: Colors.red.shade50,
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning, color: Colors.red),
|
||||
SizedBox(width: 8),
|
||||
Text('操作冲突', style: TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: contentParts,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
_feedbackService.stopAlert();
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showCrossPaichaDialog(String newZongpaiNo) async {
|
||||
final currentPaicha = _overview?.paichaNo ?? '--';
|
||||
final shouldSwitch = await _confirmCrossPaichaSwitch(
|
||||
newZongpaiNo: newZongpaiNo,
|
||||
currentPaicha: currentPaicha,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (shouldSwitch) {
|
||||
_switchToNewPaicha(newZongpaiNo);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_crossPaichaPending = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> _confirmCrossPaichaSwitch({
|
||||
required String newZongpaiNo,
|
||||
required String currentPaicha,
|
||||
}) async {
|
||||
final dialogFocus = FocusNode();
|
||||
var dismissed = false;
|
||||
var selectedIndex = 0; // 0 = "否" (default), 1 = "是"
|
||||
|
||||
void dismissAsNo() {
|
||||
if (dismissed) return;
|
||||
dismissed = true;
|
||||
Navigator.of(context).pop(false);
|
||||
}
|
||||
|
||||
void confirmSwitch() {
|
||||
if (dismissed) return;
|
||||
dismissed = true;
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
final dialogFuture = showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) {
|
||||
return KeyboardListener(
|
||||
focusNode: dialogFocus,
|
||||
onKeyEvent: (event) {
|
||||
if (event is! KeyDownEvent) return;
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
|
||||
setDialogState(() => selectedIndex = 0);
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
setDialogState(() => selectedIndex = 1);
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
selectedIndex == 0 ? dismissAsNo() : confirmSwitch();
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
dismissAsNo();
|
||||
}
|
||||
},
|
||||
child: PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) dismissAsNo();
|
||||
},
|
||||
child: AlertDialog(
|
||||
backgroundColor: Colors.orange.shade50,
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber, color: Colors.orange.shade800),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'跨排产号扫描',
|
||||
style: TextStyle(color: Colors.orange.shade900),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'总排号 $newZongpaiNo 不属于当前排产号($currentPaicha),'
|
||||
'是否切换到新的排产号?\n\n'
|
||||
'选择「是」将放弃当前已扫描的所有数据。',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_dialogOptionBtn(
|
||||
label: '否',
|
||||
selected: selectedIndex == 0,
|
||||
onTap: dismissAsNo,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_dialogOptionBtn(
|
||||
label: '是,切换排产号',
|
||||
selected: selectedIndex == 1,
|
||||
onTap: confirmSwitch,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
dialogFocus.requestFocus();
|
||||
});
|
||||
final shouldSwitch = await dialogFuture;
|
||||
return shouldSwitch ?? false;
|
||||
}
|
||||
|
||||
Widget _dialogOptionBtn({
|
||||
required String label,
|
||||
required bool selected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 40,
|
||||
child: ElevatedButton(
|
||||
onPressed: onTap,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: selected
|
||||
? Colors.blue.shade700
|
||||
: Colors.grey.shade200,
|
||||
foregroundColor: selected ? Colors.white : Colors.black87,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _switchToNewPaicha(String newZongpaiNo) {
|
||||
_feedbackService.trigger(FeedbackEvent.paichanSwitch);
|
||||
setState(() {
|
||||
_crossPaichaPending = false;
|
||||
_zongpaiNos.clear();
|
||||
_zongpaiNos.add(newZongpaiNo);
|
||||
_overview = null;
|
||||
_overviewNotFound = false;
|
||||
_overviewError = null;
|
||||
_clearStatusOverride();
|
||||
});
|
||||
_loadOverview(newZongpaiNo);
|
||||
}
|
||||
}
|
||||
21
lib/pages/registration/parts/registration_mode_part.dart
Normal file
21
lib/pages/registration/parts/registration_mode_part.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
part of '../../registration_page.dart';
|
||||
|
||||
extension _RegistrationModePart on _RegistrationPageState {
|
||||
void _cycleMode() {
|
||||
_feedbackService.trigger(FeedbackEvent.modeSwitch);
|
||||
setState(() {
|
||||
switch (_mode) {
|
||||
case RegistrationMode.singleCode:
|
||||
_mode = RegistrationMode.multiCode;
|
||||
case RegistrationMode.multiCode:
|
||||
_mode = RegistrationMode.singleCode;
|
||||
// 切换到单码时,只保留最后一个总排号
|
||||
if (_zongpaiNos.length > 1) {
|
||||
_zongpaiNos.removeRange(0, _zongpaiNos.length - 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
80
lib/pages/registration/parts/registration_overview_part.dart
Normal file
80
lib/pages/registration/parts/registration_overview_part.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
part of '../../registration_page.dart';
|
||||
|
||||
extension _RegistrationOverviewPart on _RegistrationPageState {
|
||||
/// Find all scanned items that are already on shelf.
|
||||
List<PaichaOverviewItem> _findOnShelfItemsInScanned() {
|
||||
return registration_calculations.findOnShelfItemsInScanned(
|
||||
overview: _overview,
|
||||
zongpaiNos: _zongpaiNos,
|
||||
);
|
||||
}
|
||||
|
||||
/// Find all scanned items that are already transferred.
|
||||
List<PaichaOverviewItem> _findTransferredItemsInScanned() {
|
||||
return registration_calculations.findTransferredItemsInScanned(
|
||||
overview: _overview,
|
||||
zongpaiNos: _zongpaiNos,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _baseUrl() async {
|
||||
final configService = AppConfigService();
|
||||
final baseUrl = await configService.getString('api_url') ?? '';
|
||||
if (baseUrl.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
Future<void> _loadOverview(String zongpaiNo, {bool force = false}) async {
|
||||
if (!force && _overviewZongpaiNo == zongpaiNo && _overview != null) {
|
||||
return;
|
||||
}
|
||||
final requestId = ++_overviewRequestId;
|
||||
setState(() {
|
||||
_overviewZongpaiNo = zongpaiNo;
|
||||
_overviewLoading = true;
|
||||
_overviewNotFound = false;
|
||||
_overviewError = null;
|
||||
});
|
||||
|
||||
final baseUrl = await _baseUrl();
|
||||
if (!mounted || requestId != _overviewRequestId) return;
|
||||
if (baseUrl == null) {
|
||||
setState(() {
|
||||
_overviewLoading = false;
|
||||
_overviewError = '未配置 API 地址,请前往设置';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await _apiService.fetchPaichaOverview(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
);
|
||||
if (!mounted || requestId != _overviewRequestId) return;
|
||||
|
||||
setState(() {
|
||||
_overviewLoading = false;
|
||||
if (result.success) {
|
||||
_overview = result;
|
||||
_overviewNotFound = false;
|
||||
_overviewError = null;
|
||||
} else if (result.notFound) {
|
||||
_overview = null;
|
||||
_overviewNotFound = true;
|
||||
_overviewError = null;
|
||||
} else {
|
||||
_overviewError = result.errorMessage ?? '加载失败,点击重试';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _refreshCurrentOverview() async {
|
||||
final zongpaiNo = _overviewZongpaiNo;
|
||||
if (zongpaiNo == null) return;
|
||||
await _loadOverview(zongpaiNo, force: true);
|
||||
}
|
||||
}
|
||||
128
lib/pages/registration/parts/registration_scan_part.dart
Normal file
128
lib/pages/registration/parts/registration_scan_part.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
part of '../../registration_page.dart';
|
||||
|
||||
extension _RegistrationScanPart on _RegistrationPageState {
|
||||
void _startScanListening() {
|
||||
_scanSubscription ??= _scannerService.scanResults.listen(_onScan);
|
||||
}
|
||||
|
||||
Future<void> _stopScanListening() async {
|
||||
final subscription = _scanSubscription;
|
||||
_scanSubscription = null;
|
||||
await subscription?.cancel();
|
||||
}
|
||||
|
||||
void _restartScanListening() {
|
||||
_scanSubscription?.cancel();
|
||||
_scanSubscription = null;
|
||||
_startScanListening();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onKeyEvent(KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return;
|
||||
if (_isLockToggleKey(event)) {
|
||||
_cycleMode();
|
||||
return;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
_submit();
|
||||
}
|
||||
}
|
||||
|
||||
bool _isLockToggleKey(KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
return key == LogicalKeyboardKey.select ||
|
||||
key == LogicalKeyboardKey.gameButtonRight1;
|
||||
}
|
||||
|
||||
void _onScan(ScanResult result) {
|
||||
final parsed = CodeParser.parse(result.barcode);
|
||||
switch (parsed.type) {
|
||||
case CodeType.zongpaiNo:
|
||||
_handleZongpaiScan(parsed.value);
|
||||
case CodeType.locationNormal:
|
||||
case CodeType.locationTransit:
|
||||
_handleLocationScan(parsed.value, parsed.type);
|
||||
case CodeType.invalid:
|
||||
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
||||
_showStatusOverride(
|
||||
'无效码:${result.barcode}',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleLocationScan(String locationCode, CodeType locationType) {
|
||||
// In singleCode mode or no existing data or no previous shelf, switch directly
|
||||
if (_mode == RegistrationMode.singleCode ||
|
||||
_zongpaiNos.isEmpty ||
|
||||
_locationCode == null) {
|
||||
_feedbackService.trigger(FeedbackEvent.scanValid);
|
||||
setState(() {
|
||||
_locationCode = locationCode;
|
||||
_locationType = locationType;
|
||||
_clearStatusOverride();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// In multiCode mode with existing data, confirm before switching
|
||||
_showLocationSwitchDialog(locationCode, locationType);
|
||||
}
|
||||
|
||||
void _handleZongpaiScan(String zongpaiNo) {
|
||||
// If cross-paicha dialog is showing, just vibrate and discard
|
||||
if (_crossPaichaPending) {
|
||||
_triggerDoubleVibration();
|
||||
return;
|
||||
}
|
||||
|
||||
// In multiCode mode with existing data, check for cross-paicha scan
|
||||
if (_mode == RegistrationMode.multiCode &&
|
||||
_zongpaiNos.isNotEmpty &&
|
||||
_overview?.success == true &&
|
||||
!_overview!.items.any((item) => item.zongpaiNo == zongpaiNo)) {
|
||||
_crossPaichaPending = true;
|
||||
_triggerDoubleVibration();
|
||||
_showCrossPaichaDialog(zongpaiNo);
|
||||
return;
|
||||
}
|
||||
|
||||
final shouldRefresh = _overviewZongpaiNo != zongpaiNo;
|
||||
_feedbackService.trigger(FeedbackEvent.scanValid);
|
||||
setState(() {
|
||||
if (_mode == RegistrationMode.multiCode) {
|
||||
if (!_zongpaiNos.contains(zongpaiNo)) {
|
||||
_zongpaiNos.add(zongpaiNo);
|
||||
}
|
||||
} else {
|
||||
if (_zongpaiNos.isEmpty) {
|
||||
_zongpaiNos.add(zongpaiNo);
|
||||
} else {
|
||||
_zongpaiNos[0] = zongpaiNo;
|
||||
}
|
||||
}
|
||||
_clearStatusOverride();
|
||||
});
|
||||
if (shouldRefresh) {
|
||||
_loadOverview(zongpaiNo);
|
||||
}
|
||||
}
|
||||
|
||||
void _triggerDoubleVibration() {
|
||||
Vibration.vibrate(pattern: [0, 200, 100, 200]);
|
||||
}
|
||||
|
||||
void _removeZongpai(String zongpaiNo) {
|
||||
setState(() {
|
||||
_zongpaiNos.remove(zongpaiNo);
|
||||
});
|
||||
}
|
||||
}
|
||||
55
lib/pages/registration/parts/registration_status_part.dart
Normal file
55
lib/pages/registration/parts/registration_status_part.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
part of '../../registration_page.dart';
|
||||
|
||||
extension _RegistrationStatusPart on _RegistrationPageState {
|
||||
void _clearStatusOverride() {
|
||||
_statusOverrideText = null;
|
||||
_statusOverrideDot = null;
|
||||
}
|
||||
|
||||
void _showStatusOverride(String text, StatusDotColor dot, Duration duration) {
|
||||
setState(() {
|
||||
_statusOverrideText = text;
|
||||
_statusOverrideDot = dot;
|
||||
});
|
||||
Future.delayed(duration, () {
|
||||
if (mounted) setState(() => _clearStatusOverride());
|
||||
});
|
||||
}
|
||||
|
||||
void _updateBaseStatus() {
|
||||
if (_isSubmitting) {
|
||||
_statusDot = StatusDotColor.orange;
|
||||
_statusText = '正在提交…';
|
||||
return;
|
||||
}
|
||||
if (_mode == RegistrationMode.multiCode &&
|
||||
_locationCode != null &&
|
||||
_zongpaiNos.isEmpty) {
|
||||
_statusDot = StatusDotColor.blue;
|
||||
_statusText = '多码上架模式,请扫描下一张执行卡';
|
||||
return;
|
||||
}
|
||||
final hasZongpai = _zongpaiNos.isNotEmpty;
|
||||
final hasLocation = _locationCode != null;
|
||||
if (hasZongpai && hasLocation) {
|
||||
_statusDot = StatusDotColor.blue;
|
||||
_statusText = '请确认信息并提交';
|
||||
} else if (hasZongpai) {
|
||||
_statusDot = StatusDotColor.blue;
|
||||
_statusText = '请扫描目标货位号';
|
||||
} else if (hasLocation) {
|
||||
_statusDot = StatusDotColor.blue;
|
||||
_statusText = '请扫描执行卡';
|
||||
} else {
|
||||
_statusDot = StatusDotColor.blue;
|
||||
_statusText = '等待扫描总排号或货位号…';
|
||||
}
|
||||
}
|
||||
|
||||
bool get _canSubmit =>
|
||||
_zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting;
|
||||
|
||||
bool get _isTransitTarget => isTransitTarget(_locationType, _locationCode);
|
||||
}
|
||||
317
lib/pages/registration/parts/registration_submit_part.dart
Normal file
317
lib/pages/registration/parts/registration_submit_part.dart
Normal file
@@ -0,0 +1,317 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
part of '../../registration_page.dart';
|
||||
|
||||
extension _RegistrationSubmitPart on _RegistrationPageState {
|
||||
Future<void> _submit() async {
|
||||
if (!_canSubmit) return;
|
||||
|
||||
final baseUrl = await _baseUrl();
|
||||
if (baseUrl == null) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showStatusOverride(
|
||||
'未配置 API 地址,请前往设置',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
// For transit target: block if any scanned item is already transferred
|
||||
// (on_shelf items are allowed — transit registration acts as off-shelf)
|
||||
if (_isTransitTarget) {
|
||||
final transferredItems = _findTransferredItemsInScanned();
|
||||
if (transferredItems.isNotEmpty) {
|
||||
setState(() => _isSubmitting = false);
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showLocationConflictDialog(
|
||||
onShelfItems: [],
|
||||
transferredItems: transferredItems,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_mode == RegistrationMode.multiCode && _zongpaiNos.length > 1) {
|
||||
if (_isTransitTarget && !await _ensureBatchSamePaicha(baseUrl)) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isSubmitting = false);
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showStatusOverride(
|
||||
batchPaichaMismatchMessage,
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Pre-check: block entire batch if any item already has a location binding
|
||||
if (!_isTransitTarget) {
|
||||
final onShelfItems = _findOnShelfItemsInScanned();
|
||||
final transferredItems = _findTransferredItemsInScanned();
|
||||
if (onShelfItems.isNotEmpty || transferredItems.isNotEmpty) {
|
||||
setState(() => _isSubmitting = false);
|
||||
_feedbackService.trigger(FeedbackEvent.duplicateError);
|
||||
_showLocationConflictDialog(
|
||||
onShelfItems: onShelfItems,
|
||||
transferredItems: transferredItems,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await _submitBatch(baseUrl);
|
||||
} else {
|
||||
await _submitOne(baseUrl, _zongpaiNos.first);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _ensureBatchSamePaicha(String baseUrl) async {
|
||||
final paichaByCode = <String, String>{};
|
||||
final cachedOverview = _overview;
|
||||
if (cachedOverview?.success == true && cachedOverview!.paichaNo != null) {
|
||||
for (final item in cachedOverview.items) {
|
||||
paichaByCode[item.zongpaiNo] = cachedOverview.paichaNo!;
|
||||
}
|
||||
}
|
||||
|
||||
for (final zongpaiNo in _zongpaiNos) {
|
||||
if (paichaByCode.containsKey(zongpaiNo)) continue;
|
||||
final result = await _apiService.fetchPaichaOverview(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
);
|
||||
if (!result.success || result.paichaNo == null) {
|
||||
return false;
|
||||
}
|
||||
for (final item in result.items) {
|
||||
paichaByCode[item.zongpaiNo] = result.paichaNo!;
|
||||
}
|
||||
}
|
||||
|
||||
return allSamePaicha(_zongpaiNos.map((code) => paichaByCode[code]));
|
||||
}
|
||||
|
||||
Future<void> _submitOne(String baseUrl, String zongpaiNo) async {
|
||||
final result = await _apiService.registerLocation(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
locationCode: _locationCode!,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
|
||||
if (result.success) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||
if (_isTransitTarget) {
|
||||
setState(() => _isSubmitting = false);
|
||||
await _navigateToBoxing(
|
||||
mode: BoxingMode.singleCode,
|
||||
codes: [zongpaiNo],
|
||||
codesToClear: [zongpaiNo],
|
||||
clearLocation: _mode == RegistrationMode.singleCode,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_zongpaiNos.remove(zongpaiNo);
|
||||
if (_mode == RegistrationMode.singleCode) {
|
||||
_locationCode = null;
|
||||
_locationType = null;
|
||||
}
|
||||
});
|
||||
final msg = result.isOffShelfSuccess
|
||||
? '下架成功'
|
||||
: (_mode == RegistrationMode.multiCode ? '多码上架模式,请扫描下一张执行卡' : '上架成功');
|
||||
_showStatusOverride(
|
||||
msg,
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
await _refreshCurrentOverview();
|
||||
} else if (result.isDuplicate) {
|
||||
_feedbackService.trigger(FeedbackEvent.duplicateError);
|
||||
_showDuplicateDialog(zongpaiNo, result.duplicateInfo);
|
||||
} else if (result.isAlreadyOffShelf) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showStatusOverride(
|
||||
result.errorMessage ?? '该总排号已下架至转运区域,不可重新上架',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
} else {
|
||||
final isNetwork = result.errorMessage == '网络异常,请检查网络连接';
|
||||
_feedbackService.trigger(
|
||||
isNetwork ? FeedbackEvent.networkError : FeedbackEvent.submitFailure,
|
||||
);
|
||||
_showStatusOverride(
|
||||
result.errorMessage ?? '提交失败',
|
||||
isNetwork ? StatusDotColor.yellow : StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submitBatch(String baseUrl) async {
|
||||
int successCount = 0;
|
||||
int offShelfCount = 0;
|
||||
final failed = <String>[];
|
||||
final toRemove = <String>[];
|
||||
final transitCodes = <String>[];
|
||||
|
||||
for (final zongpaiNo in List.of(_zongpaiNos)) {
|
||||
final result = await _apiService.registerLocation(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
locationCode: _locationCode!,
|
||||
);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
if (result.isOffShelfSuccess) {
|
||||
offShelfCount++;
|
||||
}
|
||||
toRemove.add(zongpaiNo);
|
||||
if (_isTransitTarget) {
|
||||
transitCodes.add(zongpaiNo);
|
||||
}
|
||||
} else {
|
||||
failed.add(zongpaiNo);
|
||||
if (result.isDuplicate) {
|
||||
if (_isTransitTarget && transitCodes.isNotEmpty) {
|
||||
setState(() {
|
||||
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
||||
_isSubmitting = false;
|
||||
});
|
||||
await _navigateToBoxing(
|
||||
mode: BoxingMode.multiCode,
|
||||
codes: transitCodes,
|
||||
codesToClear: toRemove,
|
||||
clearLocation: false,
|
||||
returnMessage: '成功 $successCount 条,失败 ${failed.length} 条',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
||||
_isSubmitting = false;
|
||||
});
|
||||
_feedbackService.trigger(FeedbackEvent.duplicateError);
|
||||
_showDuplicateDialog(zongpaiNo, result.duplicateInfo);
|
||||
return;
|
||||
}
|
||||
if (result.isAlreadyOffShelf) {
|
||||
if (_isTransitTarget && transitCodes.isNotEmpty) {
|
||||
setState(() {
|
||||
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
||||
_isSubmitting = false;
|
||||
});
|
||||
await _navigateToBoxing(
|
||||
mode: BoxingMode.multiCode,
|
||||
codes: transitCodes,
|
||||
codesToClear: toRemove,
|
||||
clearLocation: false,
|
||||
returnMessage: '成功 $successCount 条,失败 ${failed.length} 条',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
||||
_isSubmitting = false;
|
||||
});
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showStatusOverride(
|
||||
result.errorMessage ?? '该总排号已下架至转运区域,不可重新上架',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
||||
_isSubmitting = false;
|
||||
});
|
||||
|
||||
if (_isTransitTarget && transitCodes.isNotEmpty) {
|
||||
await _navigateToBoxing(
|
||||
mode: BoxingMode.multiCode,
|
||||
codes: transitCodes,
|
||||
codesToClear: toRemove,
|
||||
clearLocation: failed.isEmpty,
|
||||
returnMessage: failed.isEmpty
|
||||
? null
|
||||
: '成功 $successCount 条,失败 ${failed.length} 条',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (failed.isEmpty) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||
final msg = offShelfCount == successCount
|
||||
? '批量下架成功($successCount 条)'
|
||||
: '批量上架成功($successCount 条)';
|
||||
_showStatusOverride(
|
||||
msg,
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
await _refreshCurrentOverview();
|
||||
} else {
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showStatusOverride(
|
||||
'成功 $successCount 条,失败 ${failed.length} 条',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _navigateToBoxing({
|
||||
required BoxingMode mode,
|
||||
required List<String> codes,
|
||||
required List<String> codesToClear,
|
||||
required bool clearLocation,
|
||||
String? returnMessage,
|
||||
}) async {
|
||||
await _refreshCurrentOverview();
|
||||
if (!mounted) return;
|
||||
final navigator = Navigator.of(context);
|
||||
await _stopScanListening();
|
||||
await navigator.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BoxingPage(
|
||||
arguments: BoxingPageArguments(
|
||||
initialMode: mode,
|
||||
autoScanCodes: codes,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 120));
|
||||
if (!mounted) return;
|
||||
_restartScanListening();
|
||||
setState(() {
|
||||
_zongpaiNos.removeWhere((item) => codesToClear.contains(item));
|
||||
if (clearLocation) {
|
||||
_locationCode = null;
|
||||
_locationType = null;
|
||||
}
|
||||
});
|
||||
await _refreshCurrentOverview();
|
||||
if (!mounted) return;
|
||||
if (returnMessage != null) {
|
||||
_showStatusOverride(
|
||||
returnMessage,
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
163
lib/pages/registration/registration_calculations.dart
Normal file
163
lib/pages/registration/registration_calculations.dart
Normal file
@@ -0,0 +1,163 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
class RegistrationOverviewStats {
|
||||
final int totalCount;
|
||||
final int shelvedCount;
|
||||
final int transferredCount;
|
||||
|
||||
const RegistrationOverviewStats({
|
||||
required this.totalCount,
|
||||
required this.shelvedCount,
|
||||
required this.transferredCount,
|
||||
});
|
||||
}
|
||||
|
||||
class RegistrationOverviewSections {
|
||||
final List<PaichaOverviewItem> scanned;
|
||||
final List<PaichaOverviewItem> unscanned;
|
||||
|
||||
const RegistrationOverviewSections({
|
||||
required this.scanned,
|
||||
required this.unscanned,
|
||||
});
|
||||
|
||||
bool get hasDivider => scanned.isNotEmpty && unscanned.isNotEmpty;
|
||||
}
|
||||
|
||||
RegistrationOverviewStats overviewStats(PaichaOverviewResult? overview) {
|
||||
if (overview?.success != true) {
|
||||
return const RegistrationOverviewStats(
|
||||
totalCount: 0,
|
||||
shelvedCount: 0,
|
||||
transferredCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
var shelvedCount = 0;
|
||||
var transferredCount = 0;
|
||||
for (final item in overview!.items) {
|
||||
switch (item.status) {
|
||||
case 'on_shelf':
|
||||
shelvedCount++;
|
||||
case 'transferred':
|
||||
transferredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return RegistrationOverviewStats(
|
||||
totalCount: overview.totalCount,
|
||||
shelvedCount: shelvedCount,
|
||||
transferredCount: transferredCount,
|
||||
);
|
||||
}
|
||||
|
||||
RegistrationOverviewSections splitOverviewItems({
|
||||
required PaichaOverviewResult overview,
|
||||
required Iterable<String> zongpaiNos,
|
||||
}) {
|
||||
final scannedSet = zongpaiNos.toSet();
|
||||
final scanned = <PaichaOverviewItem>[];
|
||||
final unscanned = <PaichaOverviewItem>[];
|
||||
for (final item in overview.items) {
|
||||
if (scannedSet.contains(item.zongpaiNo)) {
|
||||
scanned.add(item);
|
||||
} else {
|
||||
unscanned.add(item);
|
||||
}
|
||||
}
|
||||
return RegistrationOverviewSections(scanned: scanned, unscanned: unscanned);
|
||||
}
|
||||
|
||||
List<PaichaOverviewItem> findOnShelfItemsInScanned({
|
||||
required PaichaOverviewResult? overview,
|
||||
required Iterable<String> zongpaiNos,
|
||||
}) {
|
||||
return _findScannedItemsByStatus(
|
||||
overview: overview,
|
||||
zongpaiNos: zongpaiNos,
|
||||
status: 'on_shelf',
|
||||
);
|
||||
}
|
||||
|
||||
List<PaichaOverviewItem> findTransferredItemsInScanned({
|
||||
required PaichaOverviewResult? overview,
|
||||
required Iterable<String> zongpaiNos,
|
||||
}) {
|
||||
return _findScannedItemsByStatus(
|
||||
overview: overview,
|
||||
zongpaiNos: zongpaiNos,
|
||||
status: 'transferred',
|
||||
);
|
||||
}
|
||||
|
||||
bool hasAnyLocatedInScanned({
|
||||
required PaichaOverviewResult? overview,
|
||||
required Iterable<String> zongpaiNos,
|
||||
}) {
|
||||
if (overview?.success != true) return false;
|
||||
final scannedSet = zongpaiNos.toSet();
|
||||
return overview!.items.any(
|
||||
(item) =>
|
||||
scannedSet.contains(item.zongpaiNo) &&
|
||||
(item.status == 'on_shelf' || item.status == 'transferred'),
|
||||
);
|
||||
}
|
||||
|
||||
Color registrationBarColor({
|
||||
required String status,
|
||||
required bool isScanned,
|
||||
required bool isTransitTarget,
|
||||
}) {
|
||||
if (isScanned) {
|
||||
final isConflict = isTransitTarget
|
||||
? status == 'transferred'
|
||||
: (status == 'on_shelf' || status == 'transferred');
|
||||
return isConflict ? Colors.red : const Color(0xFF43A047);
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'on_shelf':
|
||||
return const Color(0xFF2196F3);
|
||||
case 'transferred':
|
||||
return const Color(0xFFFF9800);
|
||||
default:
|
||||
return Colors.grey.shade400;
|
||||
}
|
||||
}
|
||||
|
||||
Color registrationRowBgColor({
|
||||
required String status,
|
||||
required bool isScanned,
|
||||
required bool isTransitTarget,
|
||||
}) {
|
||||
if (isScanned) {
|
||||
final isConflict = isTransitTarget
|
||||
? status == 'transferred'
|
||||
: (status == 'on_shelf' || status == 'transferred');
|
||||
return isConflict ? Colors.red.shade50 : const Color(0xFFF1F8E9);
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'on_shelf':
|
||||
return const Color(0xFFE3F2FD);
|
||||
case 'transferred':
|
||||
return const Color(0xFFFFF3E0);
|
||||
default:
|
||||
return const Color(0xFFF5F5F5);
|
||||
}
|
||||
}
|
||||
|
||||
List<PaichaOverviewItem> _findScannedItemsByStatus({
|
||||
required PaichaOverviewResult? overview,
|
||||
required Iterable<String> zongpaiNos,
|
||||
required String status,
|
||||
}) {
|
||||
if (overview?.success != true) return [];
|
||||
final scannedSet = zongpaiNos.toSet();
|
||||
return overview!.items
|
||||
.where(
|
||||
(item) => scannedSet.contains(item.zongpaiNo) && item.status == status,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
453
lib/pages/registration/widgets/registration_widgets_part.dart
Normal file
453
lib/pages/registration/widgets/registration_widgets_part.dart
Normal file
@@ -0,0 +1,453 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
part of '../../registration_page.dart';
|
||||
|
||||
extension _RegistrationWidgetsPart on _RegistrationPageState {
|
||||
String _locationLabel(CodeType? type) {
|
||||
if (type == CodeType.locationTransit) return '转运区域';
|
||||
return '普通货架';
|
||||
}
|
||||
|
||||
Color _locationLabelColor(CodeType? type) {
|
||||
if (type == CodeType.locationTransit) return Colors.orange;
|
||||
return Colors.blue;
|
||||
}
|
||||
|
||||
Widget _buildModePill() {
|
||||
return BoxingModePill(
|
||||
isSingle: _mode == RegistrationMode.singleCode,
|
||||
label: _mode == RegistrationMode.singleCode ? '单码上架' : '多码上架',
|
||||
enabled: !_isSubmitting,
|
||||
onTap: _cycleMode,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLocationChip() {
|
||||
final color = _locationLabelColor(_locationType);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
_locationLabel(_locationType),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// === Paicha Header ===
|
||||
|
||||
Widget _buildPaichaHeader() {
|
||||
final overview = _overview;
|
||||
final hasData = overview?.success == true;
|
||||
final hasLoc = _locationCode != null;
|
||||
final stats = registration_calculations.overviewStats(overview);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
hasData ? (overview!.paichaNo ?? '--') : '--',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: hasData ? Colors.black87 : Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (hasLoc)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_locationCode!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
_buildLocationChip(),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildStatTag(
|
||||
'共 ${stats.totalCount}',
|
||||
const Color(0xFFE3F2FD),
|
||||
const Color(0xFF1565C0),
|
||||
hasData,
|
||||
),
|
||||
_buildStatTag(
|
||||
'上架 ${stats.shelvedCount}',
|
||||
const Color(0xFFE8F5E9),
|
||||
const Color(0xFF2E7D32),
|
||||
hasData,
|
||||
),
|
||||
_buildStatTag(
|
||||
'转运 ${stats.transferredCount}',
|
||||
const Color(0xFFFFF3E0),
|
||||
const Color(0xFFE65100),
|
||||
hasData,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatTag(String text, Color bg, Color fg, bool hasData) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
|
||||
margin: const EdgeInsets.only(left: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: hasData ? bg : const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: hasData ? fg : Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// === Table Head ===
|
||||
|
||||
Widget _buildTableHead() {
|
||||
return Container(
|
||||
height: 28,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade400)),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 22,
|
||||
child: Text(
|
||||
'总排号',
|
||||
textAlign: TextAlign.center,
|
||||
style: _headerStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 18,
|
||||
child: Text(
|
||||
'工令号',
|
||||
textAlign: TextAlign.center,
|
||||
style: _headerStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 12,
|
||||
child: Text('数量', textAlign: TextAlign.center, style: _headerStyle),
|
||||
),
|
||||
Expanded(
|
||||
flex: 28,
|
||||
child: Text(
|
||||
'货位号',
|
||||
textAlign: TextAlign.center,
|
||||
style: _headerStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// === List Body ===
|
||||
|
||||
Widget _buildListBody() {
|
||||
if (_overviewZongpaiNo == null) {
|
||||
return _buildListMessage('扫描总排号后自动显示排产号上架情况');
|
||||
}
|
||||
if (_overviewLoading && _overview == null) {
|
||||
return _buildListMessage('正在加载排产号上架情况…');
|
||||
}
|
||||
if (_overviewNotFound) {
|
||||
return _buildListMessage('暂无排产信息');
|
||||
}
|
||||
if (_overviewError != null && _overview == null) {
|
||||
return _buildListError(_overviewError!);
|
||||
}
|
||||
final overview = _overview;
|
||||
if (overview == null || !overview.success) {
|
||||
return _buildListMessage('扫描总排号后自动显示排产号上架情况');
|
||||
}
|
||||
|
||||
final sections = registration_calculations.splitOverviewItems(
|
||||
overview: overview,
|
||||
zongpaiNos: _zongpaiNos,
|
||||
);
|
||||
final scanned = sections.scanned;
|
||||
final unscanned = sections.unscanned;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (_overviewError != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
color: Colors.red.shade50,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_overviewError!,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.red),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _overviewZongpaiNo == null
|
||||
? null
|
||||
: () => _loadOverview(_overviewZongpaiNo!, force: true),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount:
|
||||
scanned.length +
|
||||
(sections.hasDivider ? 1 : 0) +
|
||||
unscanned.length,
|
||||
itemBuilder: (context, index) {
|
||||
if (index < scanned.length) {
|
||||
return _buildListRow(scanned[index], isScanned: true);
|
||||
}
|
||||
if (index == scanned.length && sections.hasDivider) {
|
||||
return _buildSectionDivider(unscanned.length);
|
||||
}
|
||||
final unscannedIdx =
|
||||
index - scanned.length - (sections.hasDivider ? 1 : 0);
|
||||
return _buildListRow(unscanned[unscannedIdx], isScanned: false);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionDivider(int count) {
|
||||
return Container(
|
||||
height: 24,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade200)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(child: Divider(height: 0)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
'以下 $count 项未操作',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Expanded(child: Divider(height: 0)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListRow(PaichaOverviewItem item, {required bool isScanned}) {
|
||||
final rowStyle = TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: isScanned ? FontWeight.w700 : FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
);
|
||||
|
||||
final barColor = registration_calculations.registrationBarColor(
|
||||
status: item.status,
|
||||
isScanned: isScanned,
|
||||
isTransitTarget: _isTransitTarget,
|
||||
);
|
||||
final bgColor = registration_calculations.registrationRowBgColor(
|
||||
status: item.status,
|
||||
isScanned: isScanned,
|
||||
isTransitTarget: _isTransitTarget,
|
||||
);
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(minHeight: 36),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
border: Border(
|
||||
left: BorderSide(color: barColor, width: 3),
|
||||
bottom: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 22,
|
||||
child: Text(
|
||||
item.zongpaiNo,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 18,
|
||||
child: Text(
|
||||
item.workOrderNo ?? '--',
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 12,
|
||||
child: Text(
|
||||
item.quantity.toString(),
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 28,
|
||||
child: Text(
|
||||
item.locationCode ?? '\u2014',
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle,
|
||||
),
|
||||
),
|
||||
if (isScanned && _mode == RegistrationMode.multiCode) ...[
|
||||
const SizedBox(width: 6),
|
||||
SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: IconButton(
|
||||
icon: Icon(Icons.delete_outline, size: 16),
|
||||
color: Colors.red,
|
||||
onPressed: () => _removeZongpai(item.zongpaiNo),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListMessage(String text) {
|
||||
return Center(
|
||||
child: Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListError(String text) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 13, color: Colors.red),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
TextButton(
|
||||
onPressed: _overviewZongpaiNo == null
|
||||
? null
|
||||
: () => _loadOverview(_overviewZongpaiNo!, force: true),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// === Bottom Bar ===
|
||||
|
||||
Widget _buildBottomBar(ColorScheme colorScheme) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 34,
|
||||
child: ElevatedButton(
|
||||
onPressed: _canSubmit ? _submit : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: registrationSubmitColor(
|
||||
canSubmit: _canSubmit,
|
||||
isTransitTarget: _isTransitTarget,
|
||||
primaryColor: colorScheme.primary,
|
||||
),
|
||||
foregroundColor: _canSubmit ? Colors.white : Colors.grey.shade600,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
child: _isSubmitting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
registrationSubmitLabel(
|
||||
isTransitTarget: _isTransitTarget,
|
||||
isMultiCode: _mode == RegistrationMode.multiCode,
|
||||
zongpaiCount: _zongpaiNos.length,
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _headerStyle = TextStyle(fontSize: 10, fontWeight: FontWeight.w700);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -67,16 +67,31 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
final configService = AppConfigService();
|
||||
final config = await configService.loadConfig();
|
||||
config['api_url'] = url;
|
||||
if (_successPath != null) config['sound_success'] = _successPath;
|
||||
else config.remove('sound_success');
|
||||
if (_failurePath != null) config['sound_failure'] = _failurePath;
|
||||
else config.remove('sound_failure');
|
||||
if (_beepPath != null) config['sound_beep'] = _beepPath;
|
||||
else config.remove('sound_beep');
|
||||
if (_errorPath != null) config['sound_error'] = _errorPath;
|
||||
else config.remove('sound_error');
|
||||
if (_alertPath != null) config['sound_alert'] = _alertPath;
|
||||
else config.remove('sound_alert');
|
||||
if (_successPath != null) {
|
||||
config['sound_success'] = _successPath;
|
||||
} else {
|
||||
config.remove('sound_success');
|
||||
}
|
||||
if (_failurePath != null) {
|
||||
config['sound_failure'] = _failurePath;
|
||||
} else {
|
||||
config.remove('sound_failure');
|
||||
}
|
||||
if (_beepPath != null) {
|
||||
config['sound_beep'] = _beepPath;
|
||||
} else {
|
||||
config.remove('sound_beep');
|
||||
}
|
||||
if (_errorPath != null) {
|
||||
config['sound_error'] = _errorPath;
|
||||
} else {
|
||||
config.remove('sound_error');
|
||||
}
|
||||
if (_alertPath != null) {
|
||||
config['sound_alert'] = _alertPath;
|
||||
} else {
|
||||
config.remove('sound_alert');
|
||||
}
|
||||
await configService.saveConfig(config);
|
||||
setState(() => _saving = false);
|
||||
if (mounted) {
|
||||
|
||||
@@ -118,6 +118,30 @@ class PaichaOverviewResult {
|
||||
|
||||
// === 装箱模块数据类 ===
|
||||
|
||||
/// 待装箱附件
|
||||
class PendingAccessory {
|
||||
final int accessoryId;
|
||||
final String accessoryType;
|
||||
final int quantity;
|
||||
final String? locationCode;
|
||||
|
||||
PendingAccessory({
|
||||
required this.accessoryId,
|
||||
required this.accessoryType,
|
||||
required this.quantity,
|
||||
this.locationCode,
|
||||
});
|
||||
|
||||
factory PendingAccessory.fromJson(Map<String, dynamic> json) {
|
||||
return PendingAccessory(
|
||||
accessoryId: json['accessory_id'] as int,
|
||||
accessoryType: json['accessory_type'] as String,
|
||||
quantity: json['quantity'] as int,
|
||||
locationCode: json['location_code']?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 箱号内单个总排号明细
|
||||
class BoxItemData {
|
||||
final int? boxItemId;
|
||||
@@ -125,6 +149,7 @@ class BoxItemData {
|
||||
final String? workOrderNo;
|
||||
final int quantity;
|
||||
final int? totalQuantity;
|
||||
final String itemType;
|
||||
|
||||
BoxItemData({
|
||||
this.boxItemId,
|
||||
@@ -132,6 +157,7 @@ class BoxItemData {
|
||||
this.workOrderNo,
|
||||
required this.quantity,
|
||||
this.totalQuantity,
|
||||
this.itemType = 'product',
|
||||
});
|
||||
|
||||
factory BoxItemData.fromJson(Map<String, dynamic> json) {
|
||||
@@ -141,6 +167,7 @@ class BoxItemData {
|
||||
workOrderNo: json['work_order_no']?.toString(),
|
||||
quantity: json['quantity'] as int,
|
||||
totalQuantity: json['total_quantity'] as int?,
|
||||
itemType: json['item_type'] as String? ?? 'product',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -167,11 +194,13 @@ class CurrentZongpaiBoxData {
|
||||
final int boxItemId;
|
||||
final int boxNo;
|
||||
final int quantity;
|
||||
final String itemType;
|
||||
|
||||
CurrentZongpaiBoxData({
|
||||
required this.boxItemId,
|
||||
required this.boxNo,
|
||||
required this.quantity,
|
||||
this.itemType = 'product',
|
||||
});
|
||||
|
||||
factory CurrentZongpaiBoxData.fromJson(Map<String, dynamic> json) {
|
||||
@@ -179,6 +208,7 @@ class CurrentZongpaiBoxData {
|
||||
boxItemId: json['box_item_id'] as int,
|
||||
boxNo: json['box_no'] as int,
|
||||
quantity: json['quantity'] as int,
|
||||
itemType: json['item_type'] as String? ?? 'product',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -195,6 +225,7 @@ class BoxInfoResult {
|
||||
final List<BoxDetailData> existingBoxes;
|
||||
final int maxBoxNo;
|
||||
final int suggestedBoxNo;
|
||||
final List<PendingAccessory> pendingAccessories;
|
||||
|
||||
BoxInfoResult({
|
||||
required this.success,
|
||||
@@ -207,6 +238,7 @@ class BoxInfoResult {
|
||||
this.existingBoxes = const [],
|
||||
this.maxBoxNo = 0,
|
||||
this.suggestedBoxNo = 1,
|
||||
this.pendingAccessories = const [],
|
||||
});
|
||||
|
||||
factory BoxInfoResult.ok(Map<String, dynamic> json) {
|
||||
@@ -222,6 +254,11 @@ class BoxInfoResult {
|
||||
)
|
||||
.toList() ??
|
||||
[];
|
||||
final pendingAccessories =
|
||||
(json['pending_accessories'] as List<dynamic>?)
|
||||
?.map((a) => PendingAccessory.fromJson(a as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
return BoxInfoResult(
|
||||
success: true,
|
||||
zongpaiNo: json['zongpai_no'] as String?,
|
||||
@@ -232,6 +269,7 @@ class BoxInfoResult {
|
||||
existingBoxes: boxes,
|
||||
maxBoxNo: json['max_box_no'] as int? ?? 0,
|
||||
suggestedBoxNo: json['suggested_box_no'] as int? ?? 1,
|
||||
pendingAccessories: pendingAccessories,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -299,6 +337,163 @@ class BoxDeleteResult {
|
||||
BoxDeleteResult({required this.success, this.errorMessage});
|
||||
}
|
||||
|
||||
// === 附件模块数据类 ===
|
||||
|
||||
/// 工令号分组(工令号 → 总排号列表)
|
||||
class WorkOrderGroup {
|
||||
final String workOrderNo;
|
||||
final List<String> zongpaiNos;
|
||||
WorkOrderGroup({required this.workOrderNo, required this.zongpaiNos});
|
||||
factory WorkOrderGroup.fromJson(Map<String, dynamic> json) => WorkOrderGroup(
|
||||
workOrderNo: json['work_order_no'] as String,
|
||||
zongpaiNos: (json['zongpai_nos'] as List).map((e) => e as String).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 附件类型预设
|
||||
class AccessoryTypeInfo {
|
||||
final int id;
|
||||
final String name;
|
||||
final int sortOrder;
|
||||
AccessoryTypeInfo({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.sortOrder,
|
||||
});
|
||||
factory AccessoryTypeInfo.fromJson(Map<String, dynamic> json) =>
|
||||
AccessoryTypeInfo(
|
||||
id: json['id'] as int,
|
||||
name: json['name'] as String,
|
||||
sortOrder: json['sort_order'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
/// 附件记录
|
||||
class AccessoryRecord {
|
||||
final int id;
|
||||
final String paichanNo;
|
||||
final String zongpaiNo;
|
||||
final String accessoryType;
|
||||
final int quantity;
|
||||
final String? locationCode;
|
||||
final bool isBoxed;
|
||||
final String createdAt;
|
||||
AccessoryRecord({
|
||||
required this.id,
|
||||
required this.paichanNo,
|
||||
required this.zongpaiNo,
|
||||
required this.accessoryType,
|
||||
required this.quantity,
|
||||
this.locationCode,
|
||||
required this.isBoxed,
|
||||
required this.createdAt,
|
||||
});
|
||||
factory AccessoryRecord.fromJson(Map<String, dynamic> json) =>
|
||||
AccessoryRecord(
|
||||
id: json['id'] as int,
|
||||
paichanNo: json['paichan_no'] as String,
|
||||
zongpaiNo: json['zongpai_no'] as String,
|
||||
accessoryType: json['accessory_type'] as String,
|
||||
quantity: json['quantity'] as int,
|
||||
locationCode: json['location_code'] as String?,
|
||||
isBoxed: json['is_boxed'] as bool,
|
||||
createdAt: json['created_at'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
// === 附件模块结果类 ===
|
||||
|
||||
class WorkOrderQueryResult {
|
||||
final bool success;
|
||||
final String? paichanNo;
|
||||
final List<WorkOrderGroup> workOrders;
|
||||
final String? errorMessage;
|
||||
final String? errorCode;
|
||||
WorkOrderQueryResult({
|
||||
required this.success,
|
||||
this.paichanNo,
|
||||
this.workOrders = const [],
|
||||
this.errorMessage,
|
||||
this.errorCode,
|
||||
});
|
||||
}
|
||||
|
||||
class AccessoryListResult {
|
||||
final bool success;
|
||||
final List<AccessoryRecord> items;
|
||||
final String? errorMessage;
|
||||
final String? errorCode;
|
||||
AccessoryListResult({
|
||||
required this.success,
|
||||
this.items = const [],
|
||||
this.errorMessage,
|
||||
this.errorCode,
|
||||
});
|
||||
}
|
||||
|
||||
class AccessoryCreateResult {
|
||||
final bool success;
|
||||
final AccessoryRecord? record;
|
||||
final String? errorMessage;
|
||||
final String? errorCode;
|
||||
AccessoryCreateResult({
|
||||
required this.success,
|
||||
this.record,
|
||||
this.errorMessage,
|
||||
this.errorCode,
|
||||
});
|
||||
}
|
||||
|
||||
class AccessoryUpdateResult {
|
||||
final bool success;
|
||||
final AccessoryRecord? record;
|
||||
final String? errorMessage;
|
||||
final String? errorCode;
|
||||
AccessoryUpdateResult({
|
||||
required this.success,
|
||||
this.record,
|
||||
this.errorMessage,
|
||||
this.errorCode,
|
||||
});
|
||||
}
|
||||
|
||||
class AccessoryDeleteResult {
|
||||
final bool success;
|
||||
final String? errorMessage;
|
||||
final String? errorCode;
|
||||
AccessoryDeleteResult({
|
||||
required this.success,
|
||||
this.errorMessage,
|
||||
this.errorCode,
|
||||
});
|
||||
}
|
||||
|
||||
class AccessoryTypeListResult {
|
||||
final bool success;
|
||||
final List<AccessoryTypeInfo> types;
|
||||
final String? errorMessage;
|
||||
final String? errorCode;
|
||||
AccessoryTypeListResult({
|
||||
required this.success,
|
||||
this.types = const [],
|
||||
this.errorMessage,
|
||||
this.errorCode,
|
||||
});
|
||||
}
|
||||
|
||||
class AccessoryTypeCreateResult {
|
||||
final bool success;
|
||||
final AccessoryTypeInfo? type;
|
||||
final String? errorMessage;
|
||||
final String? errorCode;
|
||||
AccessoryTypeCreateResult({
|
||||
required this.success,
|
||||
this.type,
|
||||
this.errorMessage,
|
||||
this.errorCode,
|
||||
});
|
||||
}
|
||||
|
||||
class ApiService {
|
||||
final http.Client _client;
|
||||
final Duration timeout;
|
||||
@@ -423,18 +618,25 @@ class ApiService {
|
||||
required String zongpaiNo,
|
||||
required int boxNo,
|
||||
required int quantity,
|
||||
String itemType = 'product',
|
||||
int? accessoryId,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/CargoTrace/box');
|
||||
try {
|
||||
final bodyMap = <String, dynamic>{
|
||||
'zongpai_no': zongpaiNo,
|
||||
'box_no': boxNo,
|
||||
'quantity': quantity,
|
||||
'item_type': itemType,
|
||||
};
|
||||
if (accessoryId != null) {
|
||||
bodyMap['accessory_id'] = accessoryId;
|
||||
}
|
||||
final response = await _client
|
||||
.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'zongpai_no': zongpaiNo,
|
||||
'box_no': boxNo,
|
||||
'quantity': quantity,
|
||||
}),
|
||||
body: jsonEncode(bodyMap),
|
||||
)
|
||||
.timeout(timeout);
|
||||
|
||||
@@ -524,6 +726,403 @@ class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
// === 附件模块 API 方法 ===
|
||||
|
||||
/// 工令号查询 — GET /CargoTrace/accessory/work-orders
|
||||
Future<WorkOrderQueryResult> queryWorkOrders({
|
||||
required String baseUrl,
|
||||
required String paichanNo,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'$baseUrl/CargoTrace/accessory/work-orders',
|
||||
).replace(queryParameters: {'paichan_no': paichanNo});
|
||||
try {
|
||||
final response = await _client
|
||||
.get(uri, headers: {'Content-Type': 'application/json'})
|
||||
.timeout(timeout);
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 200:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final workOrders =
|
||||
(body['work_orders'] as List<dynamic>?)
|
||||
?.map(
|
||||
(w) => WorkOrderGroup.fromJson(w as Map<String, dynamic>),
|
||||
)
|
||||
.toList() ??
|
||||
[];
|
||||
return WorkOrderQueryResult(
|
||||
success: true,
|
||||
paichanNo: body['paichan_no'] as String?,
|
||||
workOrders: workOrders,
|
||||
);
|
||||
case 400:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return WorkOrderQueryResult(
|
||||
success: false,
|
||||
errorMessage: body['message']?.toString() ?? '请求参数错误',
|
||||
errorCode: body['error_code']?.toString(),
|
||||
);
|
||||
case 404:
|
||||
return WorkOrderQueryResult(
|
||||
success: false,
|
||||
errorMessage: '未找到该排产号对应的工令号信息',
|
||||
errorCode: 'NOT_FOUND',
|
||||
);
|
||||
default:
|
||||
return WorkOrderQueryResult(
|
||||
success: false,
|
||||
errorMessage: '查询失败 (${response.statusCode})',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return WorkOrderQueryResult(success: false, errorMessage: '网络异常,请检查网络连接');
|
||||
}
|
||||
}
|
||||
|
||||
/// 附件列表查询 — GET /CargoTrace/accessory
|
||||
Future<AccessoryListResult> listAccessories({
|
||||
required String baseUrl,
|
||||
required String paichanNo,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'$baseUrl/CargoTrace/accessory',
|
||||
).replace(queryParameters: {'paichan_no': paichanNo});
|
||||
try {
|
||||
final response = await _client
|
||||
.get(uri, headers: {'Content-Type': 'application/json'})
|
||||
.timeout(timeout);
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 200:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final items =
|
||||
(body['items'] as List<dynamic>?)
|
||||
?.map(
|
||||
(i) => AccessoryRecord.fromJson(i as Map<String, dynamic>),
|
||||
)
|
||||
.toList() ??
|
||||
[];
|
||||
return AccessoryListResult(success: true, items: items);
|
||||
case 400:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return AccessoryListResult(
|
||||
success: false,
|
||||
errorMessage: body['message']?.toString() ?? '请求参数错误',
|
||||
errorCode: body['error_code']?.toString(),
|
||||
);
|
||||
case 404:
|
||||
return AccessoryListResult(
|
||||
success: false,
|
||||
errorMessage: '未找到该排产号信息',
|
||||
errorCode: 'NOT_FOUND',
|
||||
);
|
||||
default:
|
||||
return AccessoryListResult(
|
||||
success: false,
|
||||
errorMessage: '查询失败 (${response.statusCode})',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return AccessoryListResult(success: false, errorMessage: '网络异常,请检查网络连接');
|
||||
}
|
||||
}
|
||||
|
||||
/// 附件创建(上架) — POST /CargoTrace/accessory
|
||||
Future<AccessoryCreateResult> createAccessory({
|
||||
required String baseUrl,
|
||||
required String paichanNo,
|
||||
required String zongpaiNo,
|
||||
required String accessoryType,
|
||||
required int quantity,
|
||||
String? locationCode,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/CargoTrace/accessory');
|
||||
try {
|
||||
final bodyMap = <String, dynamic>{
|
||||
'paichan_no': paichanNo,
|
||||
'zongpai_no': zongpaiNo,
|
||||
'accessory_type': accessoryType,
|
||||
'quantity': quantity,
|
||||
};
|
||||
if (locationCode != null) {
|
||||
bodyMap['location_code'] = locationCode;
|
||||
}
|
||||
final response = await _client
|
||||
.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(bodyMap),
|
||||
)
|
||||
.timeout(timeout);
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 200:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return AccessoryCreateResult(
|
||||
success: true,
|
||||
record: AccessoryRecord.fromJson(body),
|
||||
);
|
||||
case 400:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return AccessoryCreateResult(
|
||||
success: false,
|
||||
errorMessage: body['message']?.toString() ?? '请求参数错误',
|
||||
errorCode: body['error_code']?.toString(),
|
||||
);
|
||||
case 404:
|
||||
return AccessoryCreateResult(
|
||||
success: false,
|
||||
errorMessage: '未找到该排产号对应的工令号信息',
|
||||
errorCode: 'NOT_FOUND',
|
||||
);
|
||||
case 409:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return AccessoryCreateResult(
|
||||
success: false,
|
||||
errorMessage: body['message']?.toString() ?? '冲突',
|
||||
errorCode: body['error_code']?.toString(),
|
||||
);
|
||||
default:
|
||||
return AccessoryCreateResult(
|
||||
success: false,
|
||||
errorMessage: '创建失败 (${response.statusCode})',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return AccessoryCreateResult(
|
||||
success: false,
|
||||
errorMessage: '网络异常,请检查网络连接',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 附件更新 — PATCH /CargoTrace/accessory/{id}
|
||||
Future<AccessoryUpdateResult> updateAccessory({
|
||||
required String baseUrl,
|
||||
required int id,
|
||||
String? accessoryType,
|
||||
int? quantity,
|
||||
String? locationCode,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/CargoTrace/accessory/$id');
|
||||
try {
|
||||
final bodyMap = <String, dynamic>{};
|
||||
if (accessoryType != null) bodyMap['accessory_type'] = accessoryType;
|
||||
if (quantity != null) bodyMap['quantity'] = quantity;
|
||||
if (locationCode != null) bodyMap['location_code'] = locationCode;
|
||||
|
||||
final response = await _client
|
||||
.patch(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(bodyMap),
|
||||
)
|
||||
.timeout(timeout);
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 200:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return AccessoryUpdateResult(
|
||||
success: true,
|
||||
record: AccessoryRecord.fromJson(body),
|
||||
);
|
||||
case 400:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return AccessoryUpdateResult(
|
||||
success: false,
|
||||
errorMessage: body['message']?.toString() ?? '请求参数错误',
|
||||
errorCode: body['error_code']?.toString(),
|
||||
);
|
||||
case 404:
|
||||
return AccessoryUpdateResult(
|
||||
success: false,
|
||||
errorMessage: '指定附件记录不存在',
|
||||
errorCode: 'NOT_FOUND',
|
||||
);
|
||||
default:
|
||||
return AccessoryUpdateResult(
|
||||
success: false,
|
||||
errorMessage: '更新失败 (${response.statusCode})',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return AccessoryUpdateResult(
|
||||
success: false,
|
||||
errorMessage: '网络异常,请检查网络连接',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 附件删除 — DELETE /CargoTrace/accessory/{id}
|
||||
Future<AccessoryDeleteResult> deleteAccessory({
|
||||
required String baseUrl,
|
||||
required int id,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/CargoTrace/accessory/$id');
|
||||
try {
|
||||
final response = await _client
|
||||
.delete(uri, headers: {'Content-Type': 'application/json'})
|
||||
.timeout(timeout);
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 200:
|
||||
return AccessoryDeleteResult(success: true);
|
||||
case 404:
|
||||
return AccessoryDeleteResult(
|
||||
success: false,
|
||||
errorMessage: '指定附件记录不存在',
|
||||
errorCode: 'NOT_FOUND',
|
||||
);
|
||||
default:
|
||||
return AccessoryDeleteResult(
|
||||
success: false,
|
||||
errorMessage: '删除失败 (${response.statusCode})',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return AccessoryDeleteResult(
|
||||
success: false,
|
||||
errorMessage: '网络异常,请检查网络连接',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 附件类型列表 — GET /CargoTrace/accessory-type
|
||||
Future<AccessoryTypeListResult> listAccessoryTypes({
|
||||
required String baseUrl,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/CargoTrace/accessory-type');
|
||||
try {
|
||||
final response = await _client
|
||||
.get(uri, headers: {'Content-Type': 'application/json'})
|
||||
.timeout(timeout);
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 200:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final types =
|
||||
(body['types'] as List<dynamic>?)
|
||||
?.map(
|
||||
(t) =>
|
||||
AccessoryTypeInfo.fromJson(t as Map<String, dynamic>),
|
||||
)
|
||||
.toList() ??
|
||||
[];
|
||||
return AccessoryTypeListResult(success: true, types: types);
|
||||
case 400:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return AccessoryTypeListResult(
|
||||
success: false,
|
||||
errorMessage: body['message']?.toString() ?? '请求参数错误',
|
||||
errorCode: body['error_code']?.toString(),
|
||||
);
|
||||
default:
|
||||
return AccessoryTypeListResult(
|
||||
success: false,
|
||||
errorMessage: '查询失败 (${response.statusCode})',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return AccessoryTypeListResult(
|
||||
success: false,
|
||||
errorMessage: '网络异常,请检查网络连接',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 附件类型创建 — POST /CargoTrace/accessory-type
|
||||
Future<AccessoryTypeCreateResult> createAccessoryType({
|
||||
required String baseUrl,
|
||||
required String name,
|
||||
int sortOrder = 0,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/CargoTrace/accessory-type');
|
||||
try {
|
||||
final response = await _client
|
||||
.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'name': name, 'sort_order': sortOrder}),
|
||||
)
|
||||
.timeout(timeout);
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 200:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return AccessoryTypeCreateResult(
|
||||
success: true,
|
||||
type: AccessoryTypeInfo.fromJson(body),
|
||||
);
|
||||
case 400:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return AccessoryTypeCreateResult(
|
||||
success: false,
|
||||
errorMessage: body['message']?.toString() ?? '请求参数错误',
|
||||
errorCode: body['error_code']?.toString(),
|
||||
);
|
||||
case 409:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return AccessoryTypeCreateResult(
|
||||
success: false,
|
||||
errorMessage: body['message']?.toString() ?? '附件类型已存在',
|
||||
errorCode: body['error_code']?.toString(),
|
||||
);
|
||||
default:
|
||||
return AccessoryTypeCreateResult(
|
||||
success: false,
|
||||
errorMessage: '创建失败 (${response.statusCode})',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return AccessoryTypeCreateResult(
|
||||
success: false,
|
||||
errorMessage: '网络异常,请检查网络连接',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 附件类型删除 — DELETE /CargoTrace/accessory-type/{id}
|
||||
Future<AccessoryDeleteResult> deleteAccessoryType({
|
||||
required String baseUrl,
|
||||
required int id,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/CargoTrace/accessory-type/$id');
|
||||
try {
|
||||
final response = await _client
|
||||
.delete(uri, headers: {'Content-Type': 'application/json'})
|
||||
.timeout(timeout);
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 200:
|
||||
return AccessoryDeleteResult(success: true);
|
||||
case 404:
|
||||
return AccessoryDeleteResult(
|
||||
success: false,
|
||||
errorMessage: '指定附件类型不存在',
|
||||
errorCode: 'NOT_FOUND',
|
||||
);
|
||||
case 409:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return AccessoryDeleteResult(
|
||||
success: false,
|
||||
errorMessage: body['message']?.toString() ?? '该类型下存在附件记录,无法删除',
|
||||
errorCode: body['error_code']?.toString(),
|
||||
);
|
||||
default:
|
||||
return AccessoryDeleteResult(
|
||||
success: false,
|
||||
errorMessage: '删除失败 (${response.statusCode})',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return AccessoryDeleteResult(
|
||||
success: false,
|
||||
errorMessage: '网络异常,请检查网络连接',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test connectivity by making a HEAD request to the base URL.
|
||||
Future<bool> testConnection(String baseUrl) async {
|
||||
try {
|
||||
|
||||
@@ -10,6 +10,7 @@ enum FeedbackEvent {
|
||||
duplicateError, // Duplicate (shelf) → alert loop + continuous vibrate
|
||||
networkError, // Network error → failure sound + short vibrate
|
||||
duplicateBoxNo, // Duplicate box no → failure sound + short vibrate
|
||||
alreadyCompleted, // Zongpai already boxed → beep + double vibrate
|
||||
modeSwitch, // Mode toggle → beep only
|
||||
paichanSwitch, // Paichan changed → beep + short vibrate
|
||||
}
|
||||
@@ -55,6 +56,10 @@ class FeedbackService {
|
||||
_soundService.playFailure();
|
||||
_vibrateShort();
|
||||
|
||||
case FeedbackEvent.alreadyCompleted:
|
||||
_soundService.playBeep();
|
||||
_vibrateDouble();
|
||||
|
||||
case FeedbackEvent.modeSwitch:
|
||||
_soundService.playBeep();
|
||||
|
||||
@@ -82,6 +87,11 @@ class FeedbackService {
|
||||
Vibration.vibrate(pattern: [0, 300, 100, 300, 100, 300]);
|
||||
}
|
||||
|
||||
/// Two short bursts to signal an already-completed item.
|
||||
void _vibrateDouble() {
|
||||
Vibration.vibrate(pattern: [0, 200, 100, 200]);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _soundService.dispose();
|
||||
}
|
||||
|
||||
@@ -10,13 +10,13 @@ bool isTransitTarget(CodeType? locationType, String? locationCode) {
|
||||
|
||||
String registrationSubmitLabel({
|
||||
required bool isTransitTarget,
|
||||
required bool isLocked,
|
||||
required bool isMultiCode,
|
||||
required int zongpaiCount,
|
||||
}) {
|
||||
if (!isTransitTarget) {
|
||||
return isLocked && zongpaiCount > 1 ? '批量上架($zongpaiCount 条)' : '确 认 上 架';
|
||||
return isMultiCode && zongpaiCount > 1 ? '批量上架($zongpaiCount 条)' : '确 认 上 架';
|
||||
}
|
||||
return isLocked && zongpaiCount > 1 ? '批量转运并凑箱($zongpaiCount 条)' : '转运并装箱';
|
||||
return isMultiCode && zongpaiCount > 1 ? '批量转运并凑箱($zongpaiCount 条)' : '转运并装箱';
|
||||
}
|
||||
|
||||
Color registrationSubmitColor({
|
||||
|
||||
@@ -33,23 +33,24 @@ class StatusBar extends StatelessWidget {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
height: 28,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: _resolveDotColor(context),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
style: const TextStyle(fontSize: 11),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
159
test/pages/boxing/boxing_api_actions_test.dart
Normal file
159
test/pages/boxing/boxing_api_actions_test.dart
Normal file
@@ -0,0 +1,159 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:pad_scanner/pages/boxing/boxing_api_actions.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
void main() {
|
||||
group('BoxingApiActions', () {
|
||||
test('returns missingApiUrl when API URL is empty', () async {
|
||||
final actions = BoxingApiActions(
|
||||
apiService: ApiService(
|
||||
client: _MockClient((_) async {
|
||||
fail('ApiService should not be called without a base URL');
|
||||
}),
|
||||
),
|
||||
loadApiUrl: () async => '',
|
||||
);
|
||||
|
||||
final result = await actions.fetchBoxInfo('26B1');
|
||||
|
||||
expect(result.success, isFalse);
|
||||
expect(result.errorKind, BoxingActionErrorKind.missingApiUrl);
|
||||
expect(result.errorMessage, BoxingApiActions.missingApiUrlMessage);
|
||||
});
|
||||
|
||||
test('fetchBoxInfo passes through successful data', () async {
|
||||
final actions = BoxingApiActions(
|
||||
apiService: ApiService(
|
||||
client: _MockClient((request) async {
|
||||
expect(request.url.path, '/CargoTrace/box/info');
|
||||
expect(request.url.queryParameters['zongpai_no'], '26B1');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'zongpai_no': '26B1',
|
||||
'paichan_no': 'W00009',
|
||||
'work_order_no': 'WO001',
|
||||
'quantity': 8,
|
||||
'current_zongpai_boxes': [
|
||||
{'box_item_id': 1, 'box_no': 2, 'quantity': 3},
|
||||
],
|
||||
'existing_boxes': [],
|
||||
'max_box_no': 2,
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
),
|
||||
loadApiUrl: () async => 'http://localhost',
|
||||
);
|
||||
|
||||
final result = await actions.fetchBoxInfo('26B1');
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(result.data?.paichanNo, 'W00009');
|
||||
expect(result.data?.currentZongpaiBoxes.single.boxNo, 2);
|
||||
});
|
||||
|
||||
test('saveBoxRecord maps duplicate and preserves box number', () async {
|
||||
final actions = BoxingApiActions(
|
||||
apiService: ApiService(
|
||||
client: _MockClient((_) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'error_code': 'DUPLICATE_BOX_ITEM',
|
||||
'message': '重复装箱',
|
||||
'box_no': 7,
|
||||
}),
|
||||
409,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
),
|
||||
loadApiUrl: () async => 'http://localhost',
|
||||
);
|
||||
|
||||
final result = await actions.saveBoxRecord(
|
||||
zongpaiNo: '26B1',
|
||||
boxNo: 7,
|
||||
quantity: 2,
|
||||
);
|
||||
|
||||
expect(result.success, isFalse);
|
||||
expect(result.errorKind, BoxingActionErrorKind.duplicate);
|
||||
expect(result.boxNo, 7);
|
||||
});
|
||||
|
||||
test('updateBoxRecord maps network errors', () async {
|
||||
final actions = BoxingApiActions(
|
||||
apiService: ApiService(
|
||||
client: _MockClient((_) async {
|
||||
throw Exception('Connection refused');
|
||||
}),
|
||||
),
|
||||
loadApiUrl: () async => 'http://localhost',
|
||||
);
|
||||
|
||||
final result = await actions.updateBoxRecord(
|
||||
boxItemId: 1,
|
||||
boxNo: 2,
|
||||
quantity: 3,
|
||||
);
|
||||
|
||||
expect(result.success, isFalse);
|
||||
expect(result.errorKind, BoxingActionErrorKind.network);
|
||||
expect(result.errorMessage, BoxingApiActions.networkErrorMessage);
|
||||
});
|
||||
|
||||
test('deleteBoxRecord passes through success and backend errors', () async {
|
||||
var calls = 0;
|
||||
final actions = BoxingApiActions(
|
||||
apiService: ApiService(
|
||||
client: _MockClient((_) async {
|
||||
calls += 1;
|
||||
if (calls == 1) {
|
||||
return http.Response(
|
||||
jsonEncode({'box_item_id': 1, 'deleted': true}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({'message': '指定装箱明细不存在'}),
|
||||
404,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
),
|
||||
loadApiUrl: () async => 'http://localhost',
|
||||
);
|
||||
|
||||
final ok = await actions.deleteBoxRecord(boxItemId: 1);
|
||||
final missing = await actions.deleteBoxRecord(boxItemId: 999);
|
||||
|
||||
expect(ok.success, isTrue);
|
||||
expect(missing.success, isFalse);
|
||||
expect(missing.errorKind, BoxingActionErrorKind.backend);
|
||||
expect(missing.errorMessage, '指定装箱明细不存在');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _MockClient extends http.BaseClient {
|
||||
final Future<http.Response> Function(http.BaseRequest) _handler;
|
||||
|
||||
_MockClient(this._handler);
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
final response = await _handler(request);
|
||||
return http.StreamedResponse(
|
||||
http.ByteStream.fromBytes(response.bodyBytes),
|
||||
response.statusCode,
|
||||
headers: response.headers,
|
||||
reasonPhrase: response.reasonPhrase,
|
||||
);
|
||||
}
|
||||
}
|
||||
88
test/pages/boxing/boxing_box_mutations_test.dart
Normal file
88
test/pages/boxing/boxing_box_mutations_test.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pad_scanner/pages/boxing/boxing_box_mutations.dart';
|
||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
void main() {
|
||||
group('boxing box mutations', () {
|
||||
test('adds item to a new sorted box', () {
|
||||
final result = addExistingBoxItem(
|
||||
existingBoxes: [BoxDetailData(boxNo: 3, items: const [])],
|
||||
boxNo: 2,
|
||||
quantity: 5,
|
||||
boxItemId: 22,
|
||||
zongpaiNo: 'ZP022',
|
||||
workOrderNo: 'WO022',
|
||||
totalQuantity: 5,
|
||||
);
|
||||
|
||||
expect(result.boxes.map((box) => box.boxNo), [2, 3]);
|
||||
expect(result.maxBoxNo, 3);
|
||||
expect(result.boxes.first.items.single.zongpaiNo, 'ZP022');
|
||||
});
|
||||
|
||||
test('replaces single code item and removes empty source box', () {
|
||||
final result = replaceExistingBoxItem(
|
||||
existingBoxes: [
|
||||
BoxDetailData(
|
||||
boxNo: 1,
|
||||
items: [BoxItemData(boxItemId: 1, zongpaiNo: 'ZP001', quantity: 2)],
|
||||
),
|
||||
],
|
||||
editing: CurrentZongpaiBoxData(boxItemId: 1, boxNo: 1, quantity: 2),
|
||||
boxNo: 4,
|
||||
quantity: 3,
|
||||
zongpaiNo: 'ZP001',
|
||||
workOrderNo: 'WO001',
|
||||
totalQuantity: 5,
|
||||
);
|
||||
|
||||
expect(result.boxes.map((box) => box.boxNo), [4]);
|
||||
expect(result.boxes.single.items.single.quantity, 3);
|
||||
expect(result.maxBoxNo, 4);
|
||||
});
|
||||
|
||||
test('moves packed item to a different box', () {
|
||||
final result = replaceExistingPackedItem(
|
||||
existingBoxes: [
|
||||
BoxDetailData(
|
||||
boxNo: 2,
|
||||
items: [BoxItemData(boxItemId: 9, zongpaiNo: 'ZP009', quantity: 1)],
|
||||
),
|
||||
],
|
||||
item: const ManyToOnePackedItem(
|
||||
boxItemId: 9,
|
||||
zongpaiNo: 'ZP009',
|
||||
workOrderNo: 'WO009',
|
||||
boxNo: 2,
|
||||
quantity: 1,
|
||||
totalQuantity: 3,
|
||||
),
|
||||
boxNo: 5,
|
||||
quantity: 2,
|
||||
);
|
||||
|
||||
expect(result.boxes.map((box) => box.boxNo), [5]);
|
||||
expect(result.boxes.single.items.single.quantity, 2);
|
||||
});
|
||||
|
||||
test('removes item and drops empty box', () {
|
||||
final result = removeExistingBoxItem(
|
||||
existingBoxes: [
|
||||
BoxDetailData(
|
||||
boxNo: 1,
|
||||
items: [BoxItemData(boxItemId: 1, zongpaiNo: 'ZP001', quantity: 2)],
|
||||
),
|
||||
BoxDetailData(
|
||||
boxNo: 3,
|
||||
items: [BoxItemData(boxItemId: 2, zongpaiNo: 'ZP002', quantity: 1)],
|
||||
),
|
||||
],
|
||||
boxItemId: 1,
|
||||
);
|
||||
|
||||
expect(result.boxes.map((box) => box.boxNo), [3]);
|
||||
expect(result.maxBoxNo, 3);
|
||||
});
|
||||
});
|
||||
}
|
||||
123
test/pages/boxing/boxing_calculations_test.dart
Normal file
123
test/pages/boxing/boxing_calculations_test.dart
Normal file
@@ -0,0 +1,123 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pad_scanner/pages/boxing/boxing_calculations.dart';
|
||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
void main() {
|
||||
group('boxing calculations', () {
|
||||
final currentBoxes = [
|
||||
CurrentZongpaiBoxData(boxItemId: 1, boxNo: 1, quantity: 4),
|
||||
CurrentZongpaiBoxData(boxItemId: 2, boxNo: 2, quantity: 3),
|
||||
];
|
||||
|
||||
test('calculates packed and remaining quantity', () {
|
||||
expect(packedQuantity(currentBoxes), 7);
|
||||
expect(
|
||||
remainingQuantity(totalQuantity: 10, currentBoxes: currentBoxes),
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
test('detects quantity too high only when remaining is positive', () {
|
||||
expect(quantityTooHigh(remainingQuantity: 3, quantityText: '4'), isTrue);
|
||||
expect(quantityTooHigh(remainingQuantity: 3, quantityText: '3'), isFalse);
|
||||
expect(quantityTooHigh(remainingQuantity: 0, quantityText: '1'), isFalse);
|
||||
});
|
||||
|
||||
test('validates submit conditions', () {
|
||||
expect(
|
||||
canSubmitBoxing(
|
||||
isSubmitting: false,
|
||||
phase: BoxingPhase.scanned,
|
||||
zongpaiNo: 'ZP001',
|
||||
boxNoText: '1',
|
||||
quantityText: '2',
|
||||
remainingQuantity: 3,
|
||||
quantityTooHigh: false,
|
||||
isDuplicateBoxNo: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
expect(
|
||||
canSubmitBoxing(
|
||||
isSubmitting: true,
|
||||
phase: BoxingPhase.scanned,
|
||||
zongpaiNo: 'ZP001',
|
||||
boxNoText: '1',
|
||||
quantityText: '2',
|
||||
remainingQuantity: 3,
|
||||
quantityTooHigh: false,
|
||||
isDuplicateBoxNo: false,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('detects duplicate box number in single code mode', () {
|
||||
expect(
|
||||
singleCodeBoxNoIsDuplicate(
|
||||
boxNoText: '1',
|
||||
currentBoxes: currentBoxes,
|
||||
editingBoxItemId: null,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
singleCodeBoxNoIsDuplicate(
|
||||
boxNoText: '1',
|
||||
currentBoxes: currentBoxes,
|
||||
editingBoxItemId: 1,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('merges visible many-to-one items by box item id', () {
|
||||
final existingBoxes = [
|
||||
BoxDetailData(
|
||||
boxNo: 8,
|
||||
items: [
|
||||
BoxItemData(
|
||||
boxItemId: 11,
|
||||
zongpaiNo: 'ZP011',
|
||||
workOrderNo: 'WO011',
|
||||
quantity: 2,
|
||||
totalQuantity: 6,
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
final packedItems = [
|
||||
const ManyToOnePackedItem(
|
||||
boxItemId: 11,
|
||||
zongpaiNo: 'ZP011',
|
||||
paichanNo: 'PC001',
|
||||
workOrderNo: 'WO011',
|
||||
boxNo: 8,
|
||||
quantity: 3,
|
||||
totalQuantity: 6,
|
||||
),
|
||||
const ManyToOnePackedItem(
|
||||
boxItemId: 12,
|
||||
zongpaiNo: 'ZP012',
|
||||
paichanNo: 'PC001',
|
||||
workOrderNo: 'WO012',
|
||||
boxNo: 8,
|
||||
quantity: 1,
|
||||
totalQuantity: 4,
|
||||
),
|
||||
];
|
||||
|
||||
final visible = visibleManyToOnePackedItems(
|
||||
boxNo: 8,
|
||||
existingBoxes: existingBoxes,
|
||||
packedItems: packedItems,
|
||||
paichanNo: 'PC001',
|
||||
);
|
||||
|
||||
expect(visible.map((item) => item.boxItemId), [11, 12]);
|
||||
expect(visible.first.quantity, 3);
|
||||
});
|
||||
});
|
||||
}
|
||||
81
test/pages/boxing/boxing_status_presenter_test.dart
Normal file
81
test/pages/boxing/boxing_status_presenter_test.dart
Normal file
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
||||
import 'package:pad_scanner/pages/boxing/boxing_status_presenter.dart';
|
||||
import 'package:pad_scanner/widgets/status_bar.dart';
|
||||
|
||||
void main() {
|
||||
BoxingStatusPresentation status({
|
||||
bool isAutoProcessing = false,
|
||||
bool isSubmitting = false,
|
||||
bool isDuplicateBoxNo = false,
|
||||
bool quantityTooHigh = false,
|
||||
bool isEditingAssigned = false,
|
||||
bool isDeletingAssigned = false,
|
||||
bool hasPaichanSwitchNotice = false,
|
||||
BoxingPhase phase = BoxingPhase.waiting,
|
||||
bool isMultiCode = false,
|
||||
bool hasVisibleManyToOneItems = false,
|
||||
int remainingQuantity = 1,
|
||||
String boxNoText = '1',
|
||||
}) {
|
||||
return boxingStatusFor(
|
||||
isAutoProcessing: isAutoProcessing,
|
||||
isSubmitting: isSubmitting,
|
||||
isDuplicateBoxNo: isDuplicateBoxNo,
|
||||
quantityTooHigh: quantityTooHigh,
|
||||
isEditingAssigned: isEditingAssigned,
|
||||
isDeletingAssigned: isDeletingAssigned,
|
||||
hasPaichanSwitchNotice: hasPaichanSwitchNotice,
|
||||
phase: phase,
|
||||
isMultiCode: isMultiCode,
|
||||
hasVisibleManyToOneItems: hasVisibleManyToOneItems,
|
||||
remainingQuantity: remainingQuantity,
|
||||
boxNoText: boxNoText,
|
||||
);
|
||||
}
|
||||
|
||||
group('boxingStatusFor', () {
|
||||
test('reports waiting and multi-code continuation states', () {
|
||||
expect(status().text, '等待扫码');
|
||||
final multi = status(isMultiCode: true, hasVisibleManyToOneItems: true);
|
||||
expect(multi.text, '请继续扫码或完成本箱');
|
||||
});
|
||||
|
||||
test('prioritizes transient and validation states', () {
|
||||
expect(status(isSubmitting: true).text, '正在提交…');
|
||||
expect(
|
||||
status(isDuplicateBoxNo: true, phase: BoxingPhase.scanned).dot,
|
||||
StatusDotColor.amber,
|
||||
);
|
||||
expect(
|
||||
status(quantityTooHigh: true, phase: BoxingPhase.scanned).text,
|
||||
'超出可装数量上限',
|
||||
);
|
||||
});
|
||||
|
||||
test('reports edit delete and switched paichan states', () {
|
||||
expect(status(isEditingAssigned: true).text, '正在编辑已分配记录');
|
||||
expect(status(isDeletingAssigned: true).dot, StatusDotColor.red);
|
||||
expect(status(hasPaichanSwitchNotice: true).text, '排产号已切换,箱号已重置');
|
||||
});
|
||||
|
||||
test('reports scanned completed and submitted states', () {
|
||||
expect(
|
||||
status(phase: BoxingPhase.scanned, remainingQuantity: 0).text,
|
||||
'该总排号已全部装箱完毕',
|
||||
);
|
||||
expect(
|
||||
status(phase: BoxingPhase.scanned, remainingQuantity: 2).text,
|
||||
'数量已填入,请确认或修改',
|
||||
);
|
||||
expect(
|
||||
status(phase: BoxingPhase.submitted, isMultiCode: false).text,
|
||||
'装箱成功,可继续扫码',
|
||||
);
|
||||
expect(
|
||||
status(phase: BoxingPhase.submitted, isMultiCode: true).text,
|
||||
'请扫描下一个总排号',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
95
test/pages/registration/registration_calculations_test.dart
Normal file
95
test/pages/registration/registration_calculations_test.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pad_scanner/pages/registration/registration_calculations.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
void main() {
|
||||
group('registration calculations', () {
|
||||
test('calculates overview stats by item status', () {
|
||||
final stats = overviewStats(
|
||||
_overview([
|
||||
_item('R1', status: 'not_shelved'),
|
||||
_item('R2', status: 'on_shelf'),
|
||||
_item('R3', status: 'transferred'),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(stats.totalCount, 3);
|
||||
expect(stats.shelvedCount, 1);
|
||||
expect(stats.transferredCount, 1);
|
||||
});
|
||||
|
||||
test('splits overview items into scanned and unscanned groups', () {
|
||||
final sections = splitOverviewItems(
|
||||
overview: _overview([_item('R1'), _item('R2'), _item('R3')]),
|
||||
zongpaiNos: ['R1', 'R3'],
|
||||
);
|
||||
|
||||
expect(sections.scanned.map((item) => item.zongpaiNo), ['R1', 'R3']);
|
||||
expect(sections.unscanned.map((item) => item.zongpaiNo), ['R2']);
|
||||
expect(sections.hasDivider, isTrue);
|
||||
});
|
||||
|
||||
test('finds located scanned items', () {
|
||||
final overview = _overview([
|
||||
_item('R1', status: 'on_shelf'),
|
||||
_item('R2', status: 'transferred'),
|
||||
_item('R3', status: 'on_shelf'),
|
||||
]);
|
||||
|
||||
expect(
|
||||
findOnShelfItemsInScanned(
|
||||
overview: overview,
|
||||
zongpaiNos: ['R1', 'R2'],
|
||||
).map((item) => item.zongpaiNo),
|
||||
['R1'],
|
||||
);
|
||||
expect(
|
||||
findTransferredItemsInScanned(
|
||||
overview: overview,
|
||||
zongpaiNos: ['R1', 'R2'],
|
||||
).map((item) => item.zongpaiNo),
|
||||
['R2'],
|
||||
);
|
||||
expect(
|
||||
hasAnyLocatedInScanned(overview: overview, zongpaiNos: ['R1']),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
hasAnyLocatedInScanned(overview: overview, zongpaiNos: ['R4']),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('maps row colors for scanned and unscanned states', () {
|
||||
expect(
|
||||
registrationBarColor(
|
||||
status: 'on_shelf',
|
||||
isScanned: true,
|
||||
isTransitTarget: false,
|
||||
),
|
||||
Colors.red,
|
||||
);
|
||||
expect(
|
||||
registrationRowBgColor(
|
||||
status: 'transferred',
|
||||
isScanned: false,
|
||||
isTransitTarget: false,
|
||||
),
|
||||
const Color(0xFFFFF3E0),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
PaichaOverviewResult _overview(List<PaichaOverviewItem> items) {
|
||||
return PaichaOverviewResult(
|
||||
success: true,
|
||||
totalCount: items.length,
|
||||
items: items,
|
||||
);
|
||||
}
|
||||
|
||||
PaichaOverviewItem _item(String zongpaiNo, {String status = 'not_shelved'}) {
|
||||
return PaichaOverviewItem(zongpaiNo: zongpaiNo, quantity: 1, status: status);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ void main() {
|
||||
expect(
|
||||
registrationSubmitLabel(
|
||||
isTransitTarget: false,
|
||||
isLocked: false,
|
||||
isMultiCode: false,
|
||||
zongpaiCount: 1,
|
||||
),
|
||||
'确 认 上 架',
|
||||
@@ -24,7 +24,7 @@ void main() {
|
||||
expect(
|
||||
registrationSubmitLabel(
|
||||
isTransitTarget: false,
|
||||
isLocked: true,
|
||||
isMultiCode: true,
|
||||
zongpaiCount: 3,
|
||||
),
|
||||
'批量上架(3 条)',
|
||||
@@ -32,7 +32,7 @@ void main() {
|
||||
expect(
|
||||
registrationSubmitLabel(
|
||||
isTransitTarget: true,
|
||||
isLocked: false,
|
||||
isMultiCode: false,
|
||||
zongpaiCount: 1,
|
||||
),
|
||||
'转运并装箱',
|
||||
@@ -40,7 +40,7 @@ void main() {
|
||||
expect(
|
||||
registrationSubmitLabel(
|
||||
isTransitTarget: true,
|
||||
isLocked: true,
|
||||
isMultiCode: true,
|
||||
zongpaiCount: 3,
|
||||
),
|
||||
'批量转运并凑箱(3 条)',
|
||||
|
||||
Reference in New Issue
Block a user