Compare commits
3 Commits
b2f2c536cb
...
073162dc65
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
073162dc65 | ||
|
|
2b5fdf41a9 | ||
|
|
deb038404a |
@@ -1,7 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<application
|
<application
|
||||||
android:label="pad_scanner"
|
android:label="@string/app_name"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<uses-library android:name="android.scanner.library"/>
|
<uses-library android:name="android.scanner.library"/>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 8.3 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 4.4 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 13 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 44 KiB |
4
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#00286F</color>
|
||||||
|
</resources>
|
||||||
4
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">库房追踪</string>
|
||||||
|
</resources>
|
||||||
443
docs/lock-location-redesign-preview.html
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>锁定货位 - 布局重设计预览</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, 'Segoe UI', 'Noto Sans SC', sans-serif; background: #f0f2f5; padding: 20px; }
|
||||||
|
|
||||||
|
h1 { text-align: center; margin-bottom: 24px; color: #333; font-size: 22px; }
|
||||||
|
h2 { color: #555; margin-bottom: 12px; font-size: 16px; }
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
max-width: 1200px; margin: 0 auto;
|
||||||
|
display: grid; grid-template-columns: 1fr 1fr; gap: 32px;
|
||||||
|
}
|
||||||
|
.column { display: flex; flex-direction: column; gap: 24px; }
|
||||||
|
|
||||||
|
.phone {
|
||||||
|
width: 360px; margin: 0 auto;
|
||||||
|
border: 2px solid #333; border-radius: 24px; overflow: hidden;
|
||||||
|
background: #fff; box-shadow: 0 4px 20px rgba(0,0,0,.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── AppBar ── */
|
||||||
|
.appbar {
|
||||||
|
height: 56px; background: #1565C0; color: #fff;
|
||||||
|
display: flex; align-items: center; padding: 0 12px;
|
||||||
|
font-size: 18px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.appbar .back { font-size: 22px; margin-right: 12px; cursor: pointer; }
|
||||||
|
|
||||||
|
/* ── OLD: lock switch in actions ── */
|
||||||
|
.appbar-old .title { flex: 0 0 auto; }
|
||||||
|
.appbar-old .spacer { flex: 1; }
|
||||||
|
.appbar-old .lock-area {
|
||||||
|
display: flex; align-items: center; gap: 6px; font-size: 13px;
|
||||||
|
}
|
||||||
|
.p2-hint {
|
||||||
|
padding: 2px 6px; border-radius: 4px;
|
||||||
|
font-size: 11px; font-weight: 700;
|
||||||
|
}
|
||||||
|
.p2-hint.off { background: #e0e0e0; color: #757575; border: 1px solid #bdbdbd; }
|
||||||
|
.p2-hint.on { background: #fff3e0; color: #e65100; border: 1px solid #ffb74d; }
|
||||||
|
.switch {
|
||||||
|
width: 36px; height: 20px; border-radius: 10px;
|
||||||
|
position: relative; transition: .2s;
|
||||||
|
}
|
||||||
|
.switch.off { background: #bdbdbd; }
|
||||||
|
.switch.on { background: #ff9800; }
|
||||||
|
.switch::after {
|
||||||
|
content: ''; position: absolute; top: 2px;
|
||||||
|
width: 16px; height: 16px; border-radius: 50%;
|
||||||
|
background: #fff; transition: .2s;
|
||||||
|
}
|
||||||
|
.switch.off::after { left: 2px; }
|
||||||
|
.switch.on::after { left: 18px; }
|
||||||
|
|
||||||
|
/* ── NEW: mode header pill (same as boxing page) ── */
|
||||||
|
.appbar-new .title-text { flex: 0 0 auto; }
|
||||||
|
.appbar-new .gap { width: 12px; }
|
||||||
|
|
||||||
|
.mode-pill {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
height: 36px; border-radius: 8px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
gap: 6px; cursor: pointer; transition: background .2s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.mode-pill.blue { background: #1565C0; } /* same blue as boxing single-code */
|
||||||
|
.mode-pill.orange { background: #E65100; } /* same orange as boxing multi-code */
|
||||||
|
|
||||||
|
.mode-pill svg { width: 18px; height: 18px; fill: #fff; flex-shrink: 0; }
|
||||||
|
.mode-pill .label {
|
||||||
|
color: #fff; font-size: 18px; font-weight: 800;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.mode-pill .shortcut {
|
||||||
|
color: rgba(255,255,255,.85); font-size: 12px; font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Body ── */
|
||||||
|
.body { padding: 10px 12px 8px; }
|
||||||
|
.section-label { font-size: 14px; font-weight: 700; margin-bottom: 6px; color: #333; }
|
||||||
|
|
||||||
|
/* ── Input panels ── */
|
||||||
|
.panel {
|
||||||
|
border: 1px solid #bdbdbd; border-radius: 8px; padding: 10px;
|
||||||
|
min-height: 58px; transition: .2s;
|
||||||
|
}
|
||||||
|
.panel.active { border-color: #4caf50; border-width: 2px; }
|
||||||
|
.panel.locked {
|
||||||
|
border-color: #fb8c00; border-width: 2px;
|
||||||
|
background: linear-gradient(135deg, #fff8e1 0%, #fff 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-row { display: flex; align-items: center; }
|
||||||
|
.panel-row .loc { font-size: 18px; font-weight: 700; color: #424242; }
|
||||||
|
.panel-row .sep { color: #bdbdbd; font-size: 20px; padding: 0 8px; }
|
||||||
|
.panel-row .zp { flex: 3; text-align: right; font-size: 18px; font-weight: 700; color: #424242; }
|
||||||
|
|
||||||
|
.loc-chip {
|
||||||
|
display: inline-block; padding: 2px 6px; border-radius: 4px;
|
||||||
|
font-size: 11px; font-weight: 700; margin-left: 6px;
|
||||||
|
}
|
||||||
|
.loc-chip.blue { background: #e3f2fd; color: #1565C0; }
|
||||||
|
|
||||||
|
.lock-row { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
|
||||||
|
.lock-row .loc { flex: 1; font-size: 18px; font-weight: 700; color: #424242; }
|
||||||
|
.lock-icon { color: #fb8c00; font-size: 18px; }
|
||||||
|
|
||||||
|
.chip-wrap { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.zchip {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
background: #f5f5f5; border: 1px solid #e0e0e0; border-radius: 16px;
|
||||||
|
padding: 2px 6px 2px 10px; font-size: 13px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.zchip .x { color: #9e9e9e; cursor: pointer; font-size: 16px; line-height: 1; }
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%; height: 46px; border: none; border-radius: 8px;
|
||||||
|
font-size: 17px; font-weight: 600; color: #fff; margin-top: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.submit-btn.off { background: #e0e0e0; color: #757575; }
|
||||||
|
.submit-btn.on { background: #1565C0; }
|
||||||
|
|
||||||
|
.divider { height: 1px; background: #e0e0e0; margin: 0; }
|
||||||
|
|
||||||
|
.overview { padding: 8px 12px; }
|
||||||
|
.overview-label { font-size: 14px; font-weight: 700; margin-bottom: 6px; }
|
||||||
|
.overview-placeholder {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: #9e9e9e; font-size: 13px; min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
padding: 10px 16px; font-size: 13px; background: #f5f5f5;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #2196F3; }
|
||||||
|
|
||||||
|
/* ── Labels & captions ── */
|
||||||
|
.col-label {
|
||||||
|
text-align: center; padding: 8px 16px; border-radius: 8px;
|
||||||
|
font-weight: 700; font-size: 15px; margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.col-label.old { background: #ffcdd2; color: #c62828; }
|
||||||
|
.col-label.new { background: #c8e6c9; color: #2e7d32; }
|
||||||
|
.caption {
|
||||||
|
text-align: center; color: #888; font-size: 12px;
|
||||||
|
margin-top: 8px; line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reference boxing pill ── */
|
||||||
|
.ref-section {
|
||||||
|
max-width: 760px; margin: 40px auto 0;
|
||||||
|
padding: 20px; background: #fff; border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,.08);
|
||||||
|
}
|
||||||
|
.ref-section h2 { font-size: 18px; color: #333; margin-bottom: 16px; }
|
||||||
|
.ref-row {
|
||||||
|
display: flex; align-items: center; gap: 16px; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.ref-label { font-size: 13px; color: #888; width: 80px; flex-shrink: 0; text-align: right; }
|
||||||
|
|
||||||
|
.ref-pill {
|
||||||
|
height: 36px; border-radius: 8px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
gap: 6px; padding: 0 12px;
|
||||||
|
}
|
||||||
|
.ref-pill svg { width: 18px; height: 18px; fill: #fff; }
|
||||||
|
.ref-pill .lbl { color: #fff; font-size: 18px; font-weight: 800; }
|
||||||
|
.ref-pill .p2 { color: rgba(255,255,255,.85); font-size: 12px; font-weight: 700; }
|
||||||
|
|
||||||
|
.compare-table {
|
||||||
|
width: 100%; border-collapse: collapse; font-size: 14px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.compare-table th {
|
||||||
|
text-align: left; padding: 8px 12px; background: #f5f5f5;
|
||||||
|
border-bottom: 2px solid #ddd;
|
||||||
|
}
|
||||||
|
.compare-table td {
|
||||||
|
padding: 8px 12px; border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>锁定货位 — 布局重设计预览</h1>
|
||||||
|
|
||||||
|
<div class="wrapper">
|
||||||
|
|
||||||
|
<!-- ═══════════ LEFT: OLD ═══════════ -->
|
||||||
|
<div class="column">
|
||||||
|
<div class="col-label old">现有设计</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 style="text-align:center">未锁定</h2>
|
||||||
|
<div class="phone">
|
||||||
|
<div class="appbar appbar-old">
|
||||||
|
<span class="back">←</span>
|
||||||
|
<span class="title">上架登记</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<div class="lock-area">
|
||||||
|
<span>锁定货位</span>
|
||||||
|
<span class="p2-hint off">P2</span>
|
||||||
|
<div class="switch off"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<div class="section-label">登记操作区</div>
|
||||||
|
<div class="panel active">
|
||||||
|
<div class="panel-row">
|
||||||
|
<span class="loc">A-01-02</span>
|
||||||
|
<span class="loc-chip blue">普通货架</span>
|
||||||
|
<span class="sep">|</span>
|
||||||
|
<span class="zp">ZP2024001</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="submit-btn on">提 交</button>
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="overview">
|
||||||
|
<div class="overview-label">排产号货架总览</div>
|
||||||
|
<div class="overview-placeholder">扫描总排号后自动显示排产号上架情况</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-bar"><div class="status-dot"></div>请确认信息并提交</div>
|
||||||
|
</div>
|
||||||
|
<p class="caption">锁定开关挤在 AppBar 右侧,字小、触控区小</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 style="text-align:center">已锁定</h2>
|
||||||
|
<div class="phone">
|
||||||
|
<div class="appbar appbar-old">
|
||||||
|
<span class="back">←</span>
|
||||||
|
<span class="title">上架登记</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<div class="lock-area">
|
||||||
|
<span>锁定货位</span>
|
||||||
|
<span class="p2-hint on">P2</span>
|
||||||
|
<div class="switch on"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<div class="section-label">登记操作区</div>
|
||||||
|
<div class="panel locked">
|
||||||
|
<div class="lock-row">
|
||||||
|
<span class="loc">A-01-02</span>
|
||||||
|
<span class="lock-icon">🔒</span>
|
||||||
|
<span class="loc-chip blue">普通货架</span>
|
||||||
|
</div>
|
||||||
|
<div class="chip-wrap">
|
||||||
|
<span class="zchip">ZP2024001 <span class="x">×</span></span>
|
||||||
|
<span class="zchip">ZP2024002 <span class="x">×</span></span>
|
||||||
|
<span class="zchip">ZP2024003 <span class="x">×</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="submit-btn on">批量提交(3 条)</button>
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="overview">
|
||||||
|
<div class="overview-label">排产号货架总览</div>
|
||||||
|
<div class="overview-placeholder">扫描总排号后自动显示排产号上架情况</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-bar"><div class="status-dot"></div>货位已锁定,请扫描下一张执行卡</div>
|
||||||
|
</div>
|
||||||
|
<p class="caption">锁定状态仅靠边框颜色区分,不够醒目</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════ RIGHT: NEW ═══════════ -->
|
||||||
|
<div class="column">
|
||||||
|
<div class="col-label new">新设计(与装箱编号统一设计语言)</div>
|
||||||
|
|
||||||
|
<!-- NEW — unlocked -->
|
||||||
|
<div>
|
||||||
|
<h2 style="text-align:center">未锁定</h2>
|
||||||
|
<div class="phone">
|
||||||
|
<div class="appbar appbar-new">
|
||||||
|
<span class="back">←</span>
|
||||||
|
<span class="title-text">上架登记</span>
|
||||||
|
<span class="gap"></span>
|
||||||
|
<!-- same pill as boxing page -->
|
||||||
|
<div class="mode-pill blue">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M6.99 11 3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></svg>
|
||||||
|
<span class="label">未锁定</span>
|
||||||
|
<span class="shortcut">P2</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<div class="section-label">登记操作区</div>
|
||||||
|
<div class="panel active">
|
||||||
|
<div class="panel-row">
|
||||||
|
<span class="loc">A-01-02</span>
|
||||||
|
<span class="loc-chip blue">普通货架</span>
|
||||||
|
<span class="sep">|</span>
|
||||||
|
<span class="zp">ZP2024001</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="submit-btn on">提 交</button>
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="overview">
|
||||||
|
<div class="overview-label">排产号货架总览</div>
|
||||||
|
<div class="overview-placeholder">扫描总排号后自动显示排产号上架情况</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-bar"><div class="status-dot"></div>请确认信息并提交</div>
|
||||||
|
</div>
|
||||||
|
<p class="caption">
|
||||||
|
蓝色药丸 = 未锁定(与装箱页"单码装箱"同色)<br>
|
||||||
|
点击或按 P2 切换为锁定
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NEW — locked -->
|
||||||
|
<div>
|
||||||
|
<h2 style="text-align:center">已锁定</h2>
|
||||||
|
<div class="phone">
|
||||||
|
<div class="appbar appbar-new">
|
||||||
|
<span class="back">←</span>
|
||||||
|
<span class="title-text">上架登记</span>
|
||||||
|
<span class="gap"></span>
|
||||||
|
<!-- same pill as boxing page — orange when locked -->
|
||||||
|
<div class="mode-pill orange">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M6.99 11 3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></svg>
|
||||||
|
<span class="label">货位锁定</span>
|
||||||
|
<span class="shortcut">P2</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<div class="section-label">登记操作区</div>
|
||||||
|
<div class="panel locked">
|
||||||
|
<div class="lock-row">
|
||||||
|
<span class="loc">A-01-02</span>
|
||||||
|
<span class="lock-icon">🔒</span>
|
||||||
|
<span class="loc-chip blue">普通货架</span>
|
||||||
|
</div>
|
||||||
|
<div class="chip-wrap">
|
||||||
|
<span class="zchip">ZP2024001 <span class="x">×</span></span>
|
||||||
|
<span class="zchip">ZP2024002 <span class="x">×</span></span>
|
||||||
|
<span class="zchip">ZP2024003 <span class="x">×</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="submit-btn on">批量提交(3 条)</button>
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="overview">
|
||||||
|
<div class="overview-label">排产号货架总览</div>
|
||||||
|
<div class="overview-placeholder">扫描总排号后自动显示排产号上架情况</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-bar"><div class="status-dot"></div>货位已锁定,请扫描下一张执行卡</div>
|
||||||
|
</div>
|
||||||
|
<p class="caption">
|
||||||
|
橙色药丸 = 已锁定(与装箱页"多码凑箱"同色)<br>
|
||||||
|
输入面板下方内容不变,仅切换控件统一风格
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════ Reference comparison ═══════════ -->
|
||||||
|
<div class="ref-section">
|
||||||
|
<h2>设计语言对照</h2>
|
||||||
|
|
||||||
|
<div class="ref-row">
|
||||||
|
<span class="ref-label">装箱编号</span>
|
||||||
|
<div class="ref-pill" style="background:#1565C0;">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M6.99 11 3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></svg>
|
||||||
|
<span class="lbl">单码装箱</span>
|
||||||
|
<span class="p2">P2</span>
|
||||||
|
</div>
|
||||||
|
<div class="ref-pill" style="background:#E65100;">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M6.99 11 3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></svg>
|
||||||
|
<span class="lbl">多码凑箱</span>
|
||||||
|
<span class="p2">P2</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ref-row">
|
||||||
|
<span class="ref-label">上架登记</span>
|
||||||
|
<div class="ref-pill" style="background:#1565C0;">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M6.99 11 3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></svg>
|
||||||
|
<span class="lbl">未锁定</span>
|
||||||
|
<span class="p2">P2</span>
|
||||||
|
</div>
|
||||||
|
<div class="ref-pill" style="background:#E65100;">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M6.99 11 3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></svg>
|
||||||
|
<span class="lbl">货位锁定</span>
|
||||||
|
<span class="p2">P2</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="compare-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>属性</th>
|
||||||
|
<th>装箱编号</th>
|
||||||
|
<th>上架登记(新)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:600;">组件位置</td>
|
||||||
|
<td>AppBar 标题行内(标题 + 药丸)</td>
|
||||||
|
<td>AppBar 标题行内(标题 + 药丸)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:600;">交互方式</td>
|
||||||
|
<td>点击药丸 / 按 P2 切换</td>
|
||||||
|
<td>点击药丸 / 按 P2 切换</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:600;">默认态(蓝色)</td>
|
||||||
|
<td>单码装箱</td>
|
||||||
|
<td>未锁定</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:600;">切换态(橙色)</td>
|
||||||
|
<td>多码凑箱</td>
|
||||||
|
<td>货位锁定</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:600;">药丸结构</td>
|
||||||
|
<td>swap 图标 + 模式名 + P2</td>
|
||||||
|
<td>swap 图标 + 状态名 + P2</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:600;">圆角 / 字重</td>
|
||||||
|
<td>8px / w800</td>
|
||||||
|
<td>8px / w800</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:pad_scanner/services/app_config_service.dart';
|
import 'package:pad_scanner/services/app_config_service.dart';
|
||||||
@@ -16,6 +18,16 @@ enum BoxingMode {
|
|||||||
multiCode, // 多码凑箱
|
multiCode, // 多码凑箱
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class BoxingPageArguments {
|
||||||
|
final BoxingMode initialMode;
|
||||||
|
final List<String> autoScanCodes;
|
||||||
|
|
||||||
|
const BoxingPageArguments({
|
||||||
|
required this.initialMode,
|
||||||
|
required this.autoScanCodes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// === 页面阶段 ===
|
// === 页面阶段 ===
|
||||||
|
|
||||||
enum _Phase {
|
enum _Phase {
|
||||||
@@ -63,16 +75,22 @@ class _ManyToOnePackedItem {
|
|||||||
// === 主页面 ===
|
// === 主页面 ===
|
||||||
|
|
||||||
class BoxingPage extends StatefulWidget {
|
class BoxingPage extends StatefulWidget {
|
||||||
const BoxingPage({super.key});
|
final BoxingPageArguments? arguments;
|
||||||
|
|
||||||
|
const BoxingPage({super.key, this.arguments});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<BoxingPage> createState() => _BoxingPageState();
|
State<BoxingPage> createState() => _BoxingPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BoxingPageState extends State<BoxingPage> {
|
class _BoxingPageState extends State<BoxingPage> {
|
||||||
|
static const _androidKeyCodeButtonR1 = 103;
|
||||||
|
|
||||||
final _scannerService = ScannerService();
|
final _scannerService = ScannerService();
|
||||||
final _apiService = ApiService();
|
final _apiService = ApiService();
|
||||||
final _feedbackService = FeedbackService();
|
final _feedbackService = FeedbackService();
|
||||||
|
final _focusNode = FocusNode();
|
||||||
|
StreamSubscription<ScanResult>? _scanSubscription;
|
||||||
|
|
||||||
// 模式
|
// 模式
|
||||||
BoxingMode _mode = BoxingMode.singleCode;
|
BoxingMode _mode = BoxingMode.singleCode;
|
||||||
@@ -123,17 +141,31 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
String _statusText = '等待扫码';
|
String _statusText = '等待扫码';
|
||||||
String? _statusOverrideText;
|
String? _statusOverrideText;
|
||||||
StatusDotColor? _statusOverrideDot;
|
StatusDotColor? _statusOverrideDot;
|
||||||
|
bool _isAutoProcessing = false;
|
||||||
|
final _autoWarnings = <String>[];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
final arguments = widget.arguments;
|
||||||
|
if (arguments != null) {
|
||||||
|
_mode = arguments.initialMode;
|
||||||
|
}
|
||||||
_boxNoController.addListener(_onBoxNoTextChanged);
|
_boxNoController.addListener(_onBoxNoTextChanged);
|
||||||
_scannerService.scanResults.listen(_onScan);
|
_scanSubscription = _scannerService.scanResults.listen(_onScan);
|
||||||
|
if (arguments != null && arguments.autoScanCodes.isNotEmpty) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_processAutoScanCodes(arguments.autoScanCodes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _requestFocus());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_boxNoController.removeListener(_onBoxNoTextChanged);
|
_boxNoController.removeListener(_onBoxNoTextChanged);
|
||||||
|
_scanSubscription?.cancel();
|
||||||
|
_focusNode.dispose();
|
||||||
_boxNoController.dispose();
|
_boxNoController.dispose();
|
||||||
_quantityController.dispose();
|
_quantityController.dispose();
|
||||||
_boxNoFocusNode.dispose();
|
_boxNoFocusNode.dispose();
|
||||||
@@ -142,6 +174,35 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _requestFocus() {
|
||||||
|
if (mounted) {
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onKeyEvent(KeyEvent event) {
|
||||||
|
if (event is! KeyDownEvent || _isAutoProcessing) return;
|
||||||
|
if (_isModeSwitchKey(event)) {
|
||||||
|
_cycleMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isModeSwitchKey(KeyEvent event) {
|
||||||
|
final key = event.logicalKey;
|
||||||
|
return key == LogicalKeyboardKey.select ||
|
||||||
|
key == LogicalKeyboardKey.gameButtonRight1 ||
|
||||||
|
_androidKeyCodeFromLogicalKey(key) == _androidKeyCodeButtonR1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _androidKeyCodeFromLogicalKey(LogicalKeyboardKey key) {
|
||||||
|
final keyId = key.keyId;
|
||||||
|
const androidPlane = LogicalKeyboardKey.androidPlane;
|
||||||
|
if (keyId >= androidPlane && keyId < androidPlane + 0x100000000) {
|
||||||
|
return keyId - androidPlane;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
void _onBoxNoTextChanged() {
|
void _onBoxNoTextChanged() {
|
||||||
if (!mounted || _mode != BoxingMode.multiCode) return;
|
if (!mounted || _mode != BoxingMode.multiCode) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -206,6 +267,10 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
// === 扫码处理 ===
|
// === 扫码处理 ===
|
||||||
|
|
||||||
void _onScan(ScanResult result) {
|
void _onScan(ScanResult result) {
|
||||||
|
if (_isAutoProcessing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final parsed = CodeParser.parse(result.barcode);
|
final parsed = CodeParser.parse(result.barcode);
|
||||||
|
|
||||||
if (parsed.type != CodeType.zongpaiNo) {
|
if (parsed.type != CodeType.zongpaiNo) {
|
||||||
@@ -224,7 +289,7 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
_queryBoxInfo(zongpai);
|
_queryBoxInfo(zongpai);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _queryBoxInfo(String zongpai) async {
|
Future<bool> _queryBoxInfo(String zongpai) async {
|
||||||
final configService = AppConfigService();
|
final configService = AppConfigService();
|
||||||
final baseUrl = await configService.getString('api_url') ?? '';
|
final baseUrl = await configService.getString('api_url') ?? '';
|
||||||
if (baseUrl.isEmpty) {
|
if (baseUrl.isEmpty) {
|
||||||
@@ -234,7 +299,7 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
StatusDotColor.red,
|
StatusDotColor.red,
|
||||||
const Duration(seconds: 2),
|
const Duration(seconds: 2),
|
||||||
);
|
);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final result = await _apiService.fetchBoxInfo(
|
final result = await _apiService.fetchBoxInfo(
|
||||||
@@ -242,7 +307,7 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
zongpaiNo: zongpai,
|
zongpaiNo: zongpai,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return false;
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
final isNetwork = result.errorMessage == '网络异常,请检查网络连接';
|
final isNetwork = result.errorMessage == '网络异常,请检查网络连接';
|
||||||
@@ -254,7 +319,7 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
isNetwork ? StatusDotColor.yellow : StatusDotColor.red,
|
isNetwork ? StatusDotColor.yellow : StatusDotColor.red,
|
||||||
const Duration(seconds: 2),
|
const Duration(seconds: 2),
|
||||||
);
|
);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_feedbackService.trigger(FeedbackEvent.scanValid);
|
_feedbackService.trigger(FeedbackEvent.scanValid);
|
||||||
@@ -281,6 +346,55 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
// 根据模式自动填充
|
// 根据模式自动填充
|
||||||
_applyAutoFill();
|
_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 _queryBoxInfo(code);
|
||||||
|
if (!ok && mounted) {
|
||||||
|
setState(() => _autoWarnings.add('总排号 $code 未找到排产号信息,已忽略'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (final code in codes) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final queried = await _queryBoxInfo(code);
|
||||||
|
if (!queried) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _autoWarnings.add('总排号 $code 未找到排产号信息,已忽略'));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!_canSubmit) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _autoWarnings.add('总排号 $code 暂不能装箱,已忽略'));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final saved = await _submit();
|
||||||
|
if (!saved && mounted) {
|
||||||
|
setState(() => _autoWarnings.add('总排号 $code 自动装箱失败,请手动处理'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isAutoProcessing = false;
|
||||||
|
_statusOverrideText = null;
|
||||||
|
_statusOverrideDot = null;
|
||||||
|
});
|
||||||
|
_focusQuantityInput();
|
||||||
|
_requestFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _applyAutoFill() {
|
void _applyAutoFill() {
|
||||||
@@ -452,8 +566,8 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
return byItemId.values.toList(growable: false);
|
return byItemId.values.toList(growable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _submit() async {
|
Future<bool> _submit() async {
|
||||||
if (!_canSubmit) return;
|
if (!_canSubmit) return false;
|
||||||
|
|
||||||
final configService = AppConfigService();
|
final configService = AppConfigService();
|
||||||
final baseUrl = await configService.getString('api_url') ?? '';
|
final baseUrl = await configService.getString('api_url') ?? '';
|
||||||
@@ -464,7 +578,7 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
StatusDotColor.red,
|
StatusDotColor.red,
|
||||||
const Duration(seconds: 2),
|
const Duration(seconds: 2),
|
||||||
);
|
);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final boxNo = int.parse(_boxNoController.text);
|
final boxNo = int.parse(_boxNoController.text);
|
||||||
@@ -487,7 +601,7 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
quantity: quantity,
|
quantity: quantity,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return false;
|
||||||
|
|
||||||
setState(() => _isSubmitting = false);
|
setState(() => _isSubmitting = false);
|
||||||
|
|
||||||
@@ -497,6 +611,7 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
} else {
|
} else {
|
||||||
_onUpdateSuccess(editing, boxNo, quantity);
|
_onUpdateSuccess(editing, boxNo, quantity);
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
} else if (result.isDuplicate) {
|
} else if (result.isDuplicate) {
|
||||||
_feedbackService.trigger(FeedbackEvent.duplicateBoxNo);
|
_feedbackService.trigger(FeedbackEvent.duplicateBoxNo);
|
||||||
_showStatusOverride(
|
_showStatusOverride(
|
||||||
@@ -504,6 +619,7 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
StatusDotColor.amber,
|
StatusDotColor.amber,
|
||||||
const Duration(seconds: 2),
|
const Duration(seconds: 2),
|
||||||
);
|
);
|
||||||
|
return false;
|
||||||
} else {
|
} else {
|
||||||
final isNetwork = result.errorMessage == '网络异常,请检查网络连接';
|
final isNetwork = result.errorMessage == '网络异常,请检查网络连接';
|
||||||
_feedbackService.trigger(
|
_feedbackService.trigger(
|
||||||
@@ -514,6 +630,7 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
isNetwork ? StatusDotColor.yellow : StatusDotColor.red,
|
isNetwork ? StatusDotColor.yellow : StatusDotColor.red,
|
||||||
const Duration(seconds: 2),
|
const Duration(seconds: 2),
|
||||||
);
|
);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1103,6 +1220,11 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
// === 状态文字 ===
|
// === 状态文字 ===
|
||||||
|
|
||||||
void _updateBaseStatus() {
|
void _updateBaseStatus() {
|
||||||
|
if (_isAutoProcessing) {
|
||||||
|
_statusDot = StatusDotColor.orange;
|
||||||
|
_statusText = '正在同步转运数据...';
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (_isSubmitting) {
|
if (_isSubmitting) {
|
||||||
_statusDot = StatusDotColor.orange;
|
_statusDot = StatusDotColor.orange;
|
||||||
_statusText = '正在提交…';
|
_statusText = '正在提交…';
|
||||||
@@ -1166,127 +1288,229 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
final effectiveDot = _statusOverrideDot ?? _statusDot;
|
final effectiveDot = _statusOverrideDot ?? _statusDot;
|
||||||
final effectiveText = _statusOverrideText ?? _statusText;
|
final effectiveText = _statusOverrideText ?? _statusText;
|
||||||
|
|
||||||
return Scaffold(
|
return KeyboardListener(
|
||||||
appBar: AppBar(
|
focusNode: _focusNode,
|
||||||
title: const Text('装箱编号'),
|
onKeyEvent: _onKeyEvent,
|
||||||
actions: [
|
child: Scaffold(
|
||||||
Padding(
|
appBar: AppBar(
|
||||||
padding: const EdgeInsets.only(right: 4),
|
title: Row(
|
||||||
child: TextButton.icon(
|
children: [
|
||||||
onPressed: _cycleMode,
|
const Text('装箱编号'),
|
||||||
icon: const Icon(Icons.swap_horiz, size: 18),
|
const SizedBox(width: 12),
|
||||||
label: Text(_modeLabel, style: const TextStyle(fontSize: 13)),
|
Expanded(child: _buildModeHeader()),
|
||||||
style: TextButton.styleFrom(
|
],
|
||||||
foregroundColor: _mode == BoxingMode.multiCode
|
|
||||||
? Colors.orange
|
|
||||||
: colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
titleSpacing: 12,
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
// 重复箱号警告条(保留,因为这是输入区关联的即时反馈)
|
if (_isAutoProcessing)
|
||||||
if (_paichanSwitchNotice != null)
|
Container(
|
||||||
Container(
|
width: double.infinity,
|
||||||
width: double.infinity,
|
padding: const EdgeInsets.symmetric(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
vertical: 8,
|
||||||
color: Colors.blue.shade50,
|
horizontal: 16,
|
||||||
child: Text(
|
),
|
||||||
_paichanSwitchNotice!,
|
color: Colors.orange.shade50,
|
||||||
style: TextStyle(color: Colors.blue.shade900, fontSize: 13),
|
child: Row(
|
||||||
),
|
children: [
|
||||||
),
|
SizedBox(
|
||||||
if (_isDuplicateBoxNo && _phase == _Phase.scanned)
|
width: 16,
|
||||||
Container(
|
height: 16,
|
||||||
width: double.infinity,
|
child: CircularProgressIndicator(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
strokeWidth: 2,
|
||||||
color: Colors.amber.shade100,
|
color: Colors.orange.shade700,
|
||||||
child: Text(
|
),
|
||||||
'箱号 ${_boxNoController.text} 已存在,请重新输入',
|
),
|
||||||
style: TextStyle(color: Colors.amber.shade900, fontSize: 13),
|
const SizedBox(width: 8),
|
||||||
),
|
Text(
|
||||||
),
|
'正在同步转运数据...',
|
||||||
|
style: TextStyle(
|
||||||
Expanded(
|
color: Colors.orange.shade900,
|
||||||
child: _mode == BoxingMode.multiCode
|
fontSize: 13,
|
||||||
? _buildManyToOneBody(colorScheme)
|
|
||||||
: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_buildScanArea(isWaiting),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_buildInfoArea(isWaiting, colorScheme),
|
|
||||||
const Divider(height: 24),
|
|
||||||
if (_mode == BoxingMode.singleCode &&
|
|
||||||
!isWaiting &&
|
|
||||||
_currentZongpaiBoxes.isNotEmpty) ...[
|
|
||||||
_buildAssignedBoxes(),
|
|
||||||
const Divider(height: 24),
|
|
||||||
],
|
|
||||||
_buildInputArea(isWaiting),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 操作按钮(单码装箱 / 多码凑箱)
|
|
||||||
if (showActionButtons)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton(
|
|
||||||
onPressed: _onGoBack,
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
minimumSize: const Size.fromHeight(40),
|
|
||||||
),
|
),
|
||||||
child: const Text('返回'),
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if (_mode == BoxingMode.singleCode &&
|
|
||||||
_editingBox == null) ...[
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
const Expanded(child: SizedBox.shrink()),
|
|
||||||
],
|
],
|
||||||
if (_mode == BoxingMode.singleCode &&
|
),
|
||||||
_editingBox != null) ...[
|
),
|
||||||
const SizedBox(width: 12),
|
if (_autoWarnings.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 8,
|
||||||
|
horizontal: 16,
|
||||||
|
),
|
||||||
|
color: Colors.amber.shade100,
|
||||||
|
child: Text(
|
||||||
|
_autoWarnings.join(';'),
|
||||||
|
style: TextStyle(color: Colors.amber.shade900, fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 重复箱号警告条(保留,因为这是输入区关联的即时反馈)
|
||||||
|
if (_paichanSwitchNotice != null)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 8,
|
||||||
|
horizontal: 16,
|
||||||
|
),
|
||||||
|
color: Colors.blue.shade50,
|
||||||
|
child: Text(
|
||||||
|
_paichanSwitchNotice!,
|
||||||
|
style: TextStyle(color: Colors.blue.shade900, fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isDuplicateBoxNo && _phase == _Phase.scanned)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 8,
|
||||||
|
horizontal: 16,
|
||||||
|
),
|
||||||
|
color: Colors.amber.shade100,
|
||||||
|
child: Text(
|
||||||
|
'箱号 ${_boxNoController.text} 已存在,请重新输入',
|
||||||
|
style: TextStyle(color: Colors.amber.shade900, fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
Expanded(
|
||||||
|
child: _mode == BoxingMode.multiCode
|
||||||
|
? _buildManyToOneBody(colorScheme)
|
||||||
|
: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildScanArea(isWaiting),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildInfoArea(isWaiting, colorScheme),
|
||||||
|
const Divider(height: 24),
|
||||||
|
if (_mode == BoxingMode.singleCode &&
|
||||||
|
!isWaiting &&
|
||||||
|
_currentZongpaiBoxes.isNotEmpty) ...[
|
||||||
|
_buildAssignedBoxes(),
|
||||||
|
const Divider(height: 24),
|
||||||
|
],
|
||||||
|
_buildInputArea(isWaiting),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 操作按钮(单码装箱 / 多码凑箱)
|
||||||
|
if (showActionButtons)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton(
|
child: OutlinedButton(
|
||||||
onPressed: _cancelEditAssigned,
|
onPressed: _onGoBack,
|
||||||
style: ElevatedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
minimumSize: const Size.fromHeight(40),
|
minimumSize: const Size.fromHeight(40),
|
||||||
),
|
),
|
||||||
child: const Text('取消'),
|
child: const Text('返回'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (_mode == BoxingMode.singleCode &&
|
||||||
|
_editingBox == null) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(child: SizedBox.shrink()),
|
||||||
|
],
|
||||||
|
if (_mode == BoxingMode.singleCode &&
|
||||||
|
_editingBox != null) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _cancelEditAssigned,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(40),
|
||||||
|
),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
if (_mode == BoxingMode.multiCode)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
||||||
child: OutlinedButton(
|
|
||||||
onPressed: _visibleManyToOnePackedItems.isEmpty
|
|
||||||
? null
|
|
||||||
: _finishManyToOneBox,
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
minimumSize: const Size.fromHeight(40),
|
|
||||||
),
|
),
|
||||||
child: const Text('完成本箱'),
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// 底部状态栏
|
if (_mode == BoxingMode.multiCode)
|
||||||
StatusBar(dotColor: effectiveDot, text: effectiveText),
|
Padding(
|
||||||
],
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: _visibleManyToOnePackedItems.isEmpty
|
||||||
|
? null
|
||||||
|
: _finishManyToOneBox,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(40),
|
||||||
|
),
|
||||||
|
child: const Text('完成本箱'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 底部状态栏
|
||||||
|
StatusBar(dotColor: effectiveDot, text: effectiveText),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildModeHeader() {
|
||||||
|
final isSingle = _mode == BoxingMode.singleCode;
|
||||||
|
final backgroundColor = isSingle
|
||||||
|
? Colors.blue.shade700
|
||||||
|
: Colors.orange.shade700;
|
||||||
|
final foregroundColor = Colors.white;
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: backgroundColor,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
onTap: _isAutoProcessing ? null : _cycleMode,
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 36,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.swap_horiz, color: foregroundColor, size: 18),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Flexible(
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Text(
|
||||||
|
_modeLabel,
|
||||||
|
maxLines: 1,
|
||||||
|
style: TextStyle(
|
||||||
|
color: foregroundColor,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'P2',
|
||||||
|
style: TextStyle(
|
||||||
|
color: foregroundColor.withValues(alpha: 0.88),
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:pad_scanner/services/app_config_service.dart';
|
import 'package:pad_scanner/services/app_config_service.dart';
|
||||||
import 'package:pad_scanner/pages/settings_page.dart';
|
import 'package:pad_scanner/pages/settings_page.dart';
|
||||||
import 'package:pad_scanner/pages/registration_page.dart';
|
import 'package:pad_scanner/pages/registration_page.dart';
|
||||||
@@ -14,20 +15,18 @@ enum ModuleStatus { online, developing, planning }
|
|||||||
/// Function card data model per PRD §3.3
|
/// Function card data model per PRD §3.3
|
||||||
class FeatureCard {
|
class FeatureCard {
|
||||||
final ModuleType type;
|
final ModuleType type;
|
||||||
|
final String shortcut;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String title;
|
final String title;
|
||||||
final String description;
|
|
||||||
final ModuleStatus status;
|
final ModuleStatus status;
|
||||||
final String? version;
|
|
||||||
final String route;
|
final String route;
|
||||||
|
|
||||||
const FeatureCard({
|
const FeatureCard({
|
||||||
required this.type,
|
required this.type,
|
||||||
|
required this.shortcut,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.description,
|
|
||||||
required this.status,
|
required this.status,
|
||||||
this.version,
|
|
||||||
required this.route,
|
required this.route,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -40,7 +39,12 @@ class HomePage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomePageState extends State<HomePage> {
|
class _HomePageState extends State<HomePage> {
|
||||||
|
static const _androidKeyCode1 = 8;
|
||||||
|
static const _androidKeyCode2 = 9;
|
||||||
|
static const _androidKeyCode3 = 10;
|
||||||
|
|
||||||
final _apiService = ApiService();
|
final _apiService = ApiService();
|
||||||
|
final _focusNode = FocusNode();
|
||||||
bool _isCheckingConnection = false;
|
bool _isCheckingConnection = false;
|
||||||
bool _isConnected = false;
|
bool _isConnected = false;
|
||||||
String _apiAddress = '';
|
String _apiAddress = '';
|
||||||
@@ -49,29 +53,26 @@ class _HomePageState extends State<HomePage> {
|
|||||||
static const _modules = [
|
static const _modules = [
|
||||||
FeatureCard(
|
FeatureCard(
|
||||||
type: ModuleType.registration,
|
type: ModuleType.registration,
|
||||||
|
shortcut: '1',
|
||||||
icon: Icons.local_shipping_outlined,
|
icon: Icons.local_shipping_outlined,
|
||||||
title: '上架登记',
|
title: '上架登记',
|
||||||
description: '扫码绑定总排号与货位',
|
|
||||||
status: ModuleStatus.online,
|
status: ModuleStatus.online,
|
||||||
version: 'v1.0',
|
|
||||||
route: '/registration',
|
route: '/registration',
|
||||||
),
|
),
|
||||||
FeatureCard(
|
FeatureCard(
|
||||||
type: ModuleType.boxing,
|
type: ModuleType.boxing,
|
||||||
|
shortcut: '2',
|
||||||
icon: Icons.view_list_outlined,
|
icon: Icons.view_list_outlined,
|
||||||
title: '装箱编号',
|
title: '装箱编号',
|
||||||
description: '查询箱号、录入装箱明细',
|
|
||||||
status: ModuleStatus.online,
|
status: ModuleStatus.online,
|
||||||
version: 'v1.0',
|
|
||||||
route: '/boxing',
|
route: '/boxing',
|
||||||
),
|
),
|
||||||
FeatureCard(
|
FeatureCard(
|
||||||
type: ModuleType.shelfQuery,
|
type: ModuleType.shelfQuery,
|
||||||
|
shortcut: '3',
|
||||||
icon: Icons.search_outlined,
|
icon: Icons.search_outlined,
|
||||||
title: '货架查询',
|
title: '货架查询',
|
||||||
description: '按合同查询货架库存',
|
|
||||||
status: ModuleStatus.planning,
|
status: ModuleStatus.planning,
|
||||||
version: null,
|
|
||||||
route: '',
|
route: '',
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -80,6 +81,77 @@ class _HomePageState extends State<HomePage> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_checkConnection();
|
_checkConnection();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _requestFocus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_focusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _requestFocus() {
|
||||||
|
if (mounted) {
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onKeyEvent(KeyEvent event) {
|
||||||
|
if (event is! KeyDownEvent) return;
|
||||||
|
final module = _moduleFromShortcut(event);
|
||||||
|
if (module == null) return;
|
||||||
|
_onCardTap(module);
|
||||||
|
}
|
||||||
|
|
||||||
|
FeatureCard? _moduleFromShortcut(KeyEvent event) {
|
||||||
|
final shortcut = _shortcutNumberFromKeyEvent(event);
|
||||||
|
if (shortcut == 1) {
|
||||||
|
return _modules[0];
|
||||||
|
}
|
||||||
|
if (shortcut == 2) {
|
||||||
|
return _modules[1];
|
||||||
|
}
|
||||||
|
if (shortcut == 3) {
|
||||||
|
return _modules[2];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _shortcutNumberFromKeyEvent(KeyEvent event) {
|
||||||
|
final key = event.logicalKey;
|
||||||
|
if (key == LogicalKeyboardKey.digit1 ||
|
||||||
|
key == LogicalKeyboardKey.numpad1 ||
|
||||||
|
event.character == '1') {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (key == LogicalKeyboardKey.digit2 ||
|
||||||
|
key == LogicalKeyboardKey.numpad2 ||
|
||||||
|
event.character == '2') {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if (key == LogicalKeyboardKey.digit3 ||
|
||||||
|
key == LogicalKeyboardKey.numpad3 ||
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkConnection() async {
|
Future<void> _checkConnection() async {
|
||||||
@@ -133,17 +205,22 @@ class _HomePageState extends State<HomePage> {
|
|||||||
case ModuleType.shelfQuery:
|
case ModuleType.shelfQuery:
|
||||||
return; // 规划中,不导航
|
return; // 规划中,不导航
|
||||||
}
|
}
|
||||||
Navigator.push(
|
Navigator.push(context, MaterialPageRoute(builder: (_) => targetPage)).then(
|
||||||
context,
|
(_) {
|
||||||
MaterialPageRoute(builder: (_) => targetPage),
|
_checkConnection();
|
||||||
).then((_) => _checkConnection());
|
_requestFocus();
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToSettings() {
|
void _navigateToSettings() {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (_) => const SettingsPage()),
|
MaterialPageRoute(builder: (_) => const SettingsPage()),
|
||||||
).then((_) => _checkConnection());
|
).then((_) {
|
||||||
|
_checkConnection();
|
||||||
|
_requestFocus();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _getConnectionStatusColor() {
|
Color _getConnectionStatusColor() {
|
||||||
@@ -161,87 +238,70 @@ class _HomePageState extends State<HomePage> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return KeyboardListener(
|
||||||
appBar: AppBar(
|
focusNode: _focusNode,
|
||||||
title: const Text('CargoTrace'),
|
onKeyEvent: _onKeyEvent,
|
||||||
actions: [
|
child: Scaffold(
|
||||||
Padding(
|
appBar: AppBar(
|
||||||
padding: const EdgeInsets.only(right: 8),
|
title: const SizedBox.shrink(),
|
||||||
child: IconButton(
|
toolbarHeight: 44,
|
||||||
icon: const Icon(Icons.settings),
|
actions: [
|
||||||
onPressed: _navigateToSettings,
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.settings),
|
||||||
|
onPressed: _navigateToSettings,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
body: Column(
|
||||||
body: Column(
|
children: [
|
||||||
children: [
|
// Function cards area
|
||||||
// Function cards area
|
Expanded(
|
||||||
Expanded(
|
child: ListView.builder(
|
||||||
child: ListView.builder(
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
itemCount: _modules.length,
|
||||||
itemCount: _modules.length,
|
itemBuilder: (context, index) {
|
||||||
itemBuilder: (context, index) {
|
final card = _modules[index];
|
||||||
final card = _modules[index];
|
return _buildFeatureCard(context, card);
|
||||||
return _buildFeatureCard(context, card);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Bottom connection status bar
|
// Bottom connection status bar
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 8,
|
width: 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _getConnectionStatusColor(),
|
color: _getConnectionStatusColor(),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
const SizedBox(width: 8),
|
Expanded(
|
||||||
Expanded(
|
child: Text(
|
||||||
child: Text(
|
_getConnectionStatusText(),
|
||||||
_getConnectionStatusText(),
|
style: const TextStyle(fontSize: 13),
|
||||||
style: const TextStyle(fontSize: 13),
|
overflow: TextOverflow.ellipsis,
|
||||||
overflow: TextOverflow.ellipsis,
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFeatureCard(BuildContext context, FeatureCard card) {
|
Widget _buildFeatureCard(BuildContext context, FeatureCard card) {
|
||||||
final Color statusBgColor;
|
|
||||||
final TextStyle statusTextStyle;
|
|
||||||
final String statusText;
|
|
||||||
|
|
||||||
switch (card.status) {
|
|
||||||
case ModuleStatus.online:
|
|
||||||
statusBgColor = Colors.green.shade700;
|
|
||||||
statusTextStyle = const TextStyle(color: Colors.white, fontSize: 12);
|
|
||||||
statusText = card.version ?? '已上线';
|
|
||||||
break;
|
|
||||||
case ModuleStatus.developing:
|
|
||||||
statusBgColor = Colors.blue.shade700;
|
|
||||||
statusTextStyle = const TextStyle(color: Colors.white, fontSize: 12);
|
|
||||||
statusText = '开发中';
|
|
||||||
break;
|
|
||||||
case ModuleStatus.planning:
|
|
||||||
statusBgColor = Colors.grey.shade500;
|
|
||||||
statusTextStyle = const TextStyle(color: Colors.white, fontSize: 12);
|
|
||||||
statusText = '规划中';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
final canNavigate = card.status == ModuleStatus.online;
|
final canNavigate = card.status == ModuleStatus.online;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -250,7 +310,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
onTap: () => _onCardTap(card),
|
onTap: () => _onCardTap(card),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: canNavigate ? Colors.white : Colors.grey.shade200,
|
color: canNavigate ? Colors.white : Colors.grey.shade200,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@@ -262,80 +322,49 @@ class _HomePageState extends State<HomePage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
// First row: icon + module name
|
Icon(
|
||||||
Row(
|
card.icon,
|
||||||
children: [
|
size: 22,
|
||||||
Icon(
|
color: canNavigate ? Colors.blue : Colors.grey,
|
||||||
card.icon,
|
|
||||||
size: 22,
|
|
||||||
color: canNavigate ? Colors.blue : Colors.grey,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
card.title,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: canNavigate ? Colors.black87 : Colors.grey,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(width: 8),
|
||||||
// Second row: description
|
|
||||||
Text(
|
Text(
|
||||||
card.description,
|
card.title,
|
||||||
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
style: TextStyle(
|
||||||
),
|
fontSize: 18,
|
||||||
const SizedBox(height: 10),
|
fontWeight: FontWeight.bold,
|
||||||
// Third row: status badge (right aligned)
|
color: canNavigate ? Colors.black87 : Colors.grey,
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 8,
|
|
||||||
vertical: 3,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: statusBgColor,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(statusText, style: statusTextStyle),
|
|
||||||
if (card.status == ModuleStatus.online) ...[
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Container(
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
] else ...[
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Container(
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withValues(alpha: 0.5),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const Spacer(),
|
||||||
|
_buildShortcutHint(card, canNavigate),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildShortcutHint(FeatureCard card, bool canNavigate) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: canNavigate ? Colors.blue.shade50 : Colors.grey.shade300,
|
||||||
|
border: Border.all(
|
||||||
|
color: canNavigate ? Colors.blue.shade200 : Colors.grey.shade400,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'按 ${card.shortcut} 进入',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: canNavigate ? Colors.blue.shade800 : Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import 'package:pad_scanner/services/scanner_service.dart';
|
|||||||
import 'package:pad_scanner/services/code_parser.dart';
|
import 'package:pad_scanner/services/code_parser.dart';
|
||||||
import 'package:pad_scanner/services/api_service.dart';
|
import 'package:pad_scanner/services/api_service.dart';
|
||||||
import 'package:pad_scanner/services/feedback_service.dart';
|
import 'package:pad_scanner/services/feedback_service.dart';
|
||||||
|
import 'package:pad_scanner/services/registration_linking.dart';
|
||||||
|
import 'package:pad_scanner/pages/boxing_page.dart';
|
||||||
import 'package:pad_scanner/widgets/status_bar.dart';
|
import 'package:pad_scanner/widgets/status_bar.dart';
|
||||||
|
|
||||||
class RegistrationPage extends StatefulWidget {
|
class RegistrationPage extends StatefulWidget {
|
||||||
@@ -17,6 +19,8 @@ class RegistrationPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _RegistrationPageState extends State<RegistrationPage> {
|
class _RegistrationPageState extends State<RegistrationPage> {
|
||||||
|
static const _androidKeyCodeButtonR1 = 103;
|
||||||
|
|
||||||
final _scannerService = ScannerService();
|
final _scannerService = ScannerService();
|
||||||
final _apiService = ApiService();
|
final _apiService = ApiService();
|
||||||
final _feedbackService = FeedbackService();
|
final _feedbackService = FeedbackService();
|
||||||
@@ -44,7 +48,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_scanSubscription = _scannerService.scanResults.listen(_onScan);
|
_startScanListening();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_focusNode.requestFocus();
|
_focusNode.requestFocus();
|
||||||
});
|
});
|
||||||
@@ -58,12 +62,54 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
void _onKeyEvent(KeyEvent event) {
|
||||||
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.enter) {
|
if (event is! KeyDownEvent) return;
|
||||||
|
if (_isLockToggleKey(event)) {
|
||||||
|
_toggleLock(!_isLocked);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||||
_submit();
|
_submit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isLockToggleKey(KeyEvent event) {
|
||||||
|
final key = event.logicalKey;
|
||||||
|
return key == LogicalKeyboardKey.select ||
|
||||||
|
key == LogicalKeyboardKey.gameButtonRight1 ||
|
||||||
|
_androidKeyCodeFromLogicalKey(key) == _androidKeyCodeButtonR1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _androidKeyCodeFromLogicalKey(LogicalKeyboardKey key) {
|
||||||
|
final keyId = key.keyId;
|
||||||
|
const androidPlane = LogicalKeyboardKey.androidPlane;
|
||||||
|
if (keyId >= androidPlane && keyId < androidPlane + 0x100000000) {
|
||||||
|
return keyId - androidPlane;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
void _onScan(ScanResult result) {
|
void _onScan(ScanResult result) {
|
||||||
final parsed = CodeParser.parse(result.barcode);
|
final parsed = CodeParser.parse(result.barcode);
|
||||||
switch (parsed.type) {
|
switch (parsed.type) {
|
||||||
@@ -78,7 +124,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
_locationType = parsed.type;
|
_locationType = parsed.type;
|
||||||
_clearStatusOverride();
|
_clearStatusOverride();
|
||||||
});
|
});
|
||||||
}
|
} else {}
|
||||||
case CodeType.invalid:
|
case CodeType.invalid:
|
||||||
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
||||||
_showStatusOverride(
|
_showStatusOverride(
|
||||||
@@ -163,6 +209,8 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
bool get _canSubmit =>
|
bool get _canSubmit =>
|
||||||
_zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting;
|
_zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting;
|
||||||
|
|
||||||
|
bool get _isTransitTarget => isTransitTarget(_locationType, _locationCode);
|
||||||
|
|
||||||
Future<String?> _baseUrl() async {
|
Future<String?> _baseUrl() async {
|
||||||
final configService = AppConfigService();
|
final configService = AppConfigService();
|
||||||
final baseUrl = await configService.getString('api_url') ?? '';
|
final baseUrl = await configService.getString('api_url') ?? '';
|
||||||
@@ -239,12 +287,49 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
setState(() => _isSubmitting = true);
|
setState(() => _isSubmitting = true);
|
||||||
|
|
||||||
if (_isLocked && _zongpaiNos.length > 1) {
|
if (_isLocked && _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;
|
||||||
|
}
|
||||||
await _submitBatch(baseUrl);
|
await _submitBatch(baseUrl);
|
||||||
} else {
|
} else {
|
||||||
await _submitOne(baseUrl, _zongpaiNos.first);
|
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 {
|
Future<void> _submitOne(String baseUrl, String zongpaiNo) async {
|
||||||
final result = await _apiService.registerLocation(
|
final result = await _apiService.registerLocation(
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
@@ -258,6 +343,16 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||||
|
if (_isTransitTarget) {
|
||||||
|
setState(() => _isSubmitting = false);
|
||||||
|
await _navigateToBoxing(
|
||||||
|
mode: BoxingMode.singleCode,
|
||||||
|
codes: [zongpaiNo],
|
||||||
|
codesToClear: [zongpaiNo],
|
||||||
|
clearLocation: !_isLocked,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_zongpaiNos.remove(zongpaiNo);
|
_zongpaiNos.remove(zongpaiNo);
|
||||||
if (!_isLocked) {
|
if (!_isLocked) {
|
||||||
@@ -302,6 +397,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
int offShelfCount = 0;
|
int offShelfCount = 0;
|
||||||
final failed = <String>[];
|
final failed = <String>[];
|
||||||
final toRemove = <String>[];
|
final toRemove = <String>[];
|
||||||
|
final transitCodes = <String>[];
|
||||||
|
|
||||||
for (final zongpaiNo in List.of(_zongpaiNos)) {
|
for (final zongpaiNo in List.of(_zongpaiNos)) {
|
||||||
final result = await _apiService.registerLocation(
|
final result = await _apiService.registerLocation(
|
||||||
@@ -315,9 +411,26 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
offShelfCount++;
|
offShelfCount++;
|
||||||
}
|
}
|
||||||
toRemove.add(zongpaiNo);
|
toRemove.add(zongpaiNo);
|
||||||
|
if (_isTransitTarget) {
|
||||||
|
transitCodes.add(zongpaiNo);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
failed.add(zongpaiNo);
|
failed.add(zongpaiNo);
|
||||||
if (result.isDuplicate) {
|
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(() {
|
setState(() {
|
||||||
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
||||||
_isSubmitting = false;
|
_isSubmitting = false;
|
||||||
@@ -327,6 +440,20 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.isAlreadyOffShelf) {
|
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(() {
|
setState(() {
|
||||||
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
||||||
_isSubmitting = false;
|
_isSubmitting = false;
|
||||||
@@ -349,6 +476,19 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
_isSubmitting = false;
|
_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) {
|
if (failed.isEmpty) {
|
||||||
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||||
final msg = offShelfCount == successCount
|
final msg = offShelfCount == successCount
|
||||||
@@ -370,6 +510,49 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _showDuplicateDialog(String zongpaiNo, Map<String, dynamic>? info) {
|
void _showDuplicateDialog(String zongpaiNo, Map<String, dynamic>? info) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -463,18 +646,14 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
onKeyEvent: _onKeyEvent,
|
onKeyEvent: _onKeyEvent,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('上架登记'),
|
title: Row(
|
||||||
actions: [
|
children: [
|
||||||
Padding(
|
const Text('上架登记'),
|
||||||
padding: const EdgeInsets.only(right: 4),
|
const SizedBox(width: 12),
|
||||||
child: Row(
|
Expanded(child: _buildLockHeader()),
|
||||||
children: [
|
],
|
||||||
const Text('锁定货位', style: TextStyle(fontSize: 13)),
|
),
|
||||||
Switch(value: _isLocked, onChanged: _toggleLock),
|
titleSpacing: 12,
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -498,9 +677,11 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: _canSubmit ? _submit : null,
|
onPressed: _canSubmit ? _submit : null,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: _canSubmit
|
backgroundColor: registrationSubmitColor(
|
||||||
? colorScheme.primary
|
canSubmit: _canSubmit,
|
||||||
: Colors.grey.shade300,
|
isTransitTarget: _isTransitTarget,
|
||||||
|
primaryColor: colorScheme.primary,
|
||||||
|
),
|
||||||
foregroundColor: _canSubmit
|
foregroundColor: _canSubmit
|
||||||
? Colors.white
|
? Colors.white
|
||||||
: Colors.grey.shade600,
|
: Colors.grey.shade600,
|
||||||
@@ -518,9 +699,11 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Text(
|
: Text(
|
||||||
_isLocked && _zongpaiNos.length > 1
|
registrationSubmitLabel(
|
||||||
? '批量上架(${_zongpaiNos.length} 条)'
|
isTransitTarget: _isTransitTarget,
|
||||||
: '确 认 上 架',
|
isLocked: _isLocked,
|
||||||
|
zongpaiCount: _zongpaiNos.length,
|
||||||
|
),
|
||||||
style: const TextStyle(fontSize: 17),
|
style: const TextStyle(fontSize: 17),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -537,6 +720,56 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildLockHeader() {
|
||||||
|
final backgroundColor =
|
||||||
|
_isLocked ? Colors.orange.shade700 : Colors.blue.shade700;
|
||||||
|
final label = _isLocked ? '货位锁定' : '未锁定';
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: backgroundColor,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
onTap: () => _toggleLock(!_isLocked),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildSingleInputPanel() {
|
Widget _buildSingleInputPanel() {
|
||||||
final hasLocation = _locationCode != null;
|
final hasLocation = _locationCode != null;
|
||||||
final hasZongpai = _zongpaiNos.isNotEmpty;
|
final hasZongpai = _zongpaiNos.isNotEmpty;
|
||||||
|
|||||||
39
lib/services/registration_linking.dart
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:pad_scanner/services/code_parser.dart';
|
||||||
|
|
||||||
|
const batchPaichaMismatchMessage = '批量凑箱仅支持同排产号产品,请移除不属于同一批次的总排号';
|
||||||
|
|
||||||
|
bool isTransitTarget(CodeType? locationType, String? locationCode) {
|
||||||
|
return locationType == CodeType.locationTransit ||
|
||||||
|
(locationCode?.startsWith('TRANS-') ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
String registrationSubmitLabel({
|
||||||
|
required bool isTransitTarget,
|
||||||
|
required bool isLocked,
|
||||||
|
required int zongpaiCount,
|
||||||
|
}) {
|
||||||
|
if (!isTransitTarget) {
|
||||||
|
return isLocked && zongpaiCount > 1 ? '批量上架($zongpaiCount 条)' : '确 认 上 架';
|
||||||
|
}
|
||||||
|
return isLocked && zongpaiCount > 1 ? '批量转运并凑箱($zongpaiCount 条)' : '转运并装箱';
|
||||||
|
}
|
||||||
|
|
||||||
|
Color registrationSubmitColor({
|
||||||
|
required bool canSubmit,
|
||||||
|
required bool isTransitTarget,
|
||||||
|
required Color primaryColor,
|
||||||
|
}) {
|
||||||
|
if (!canSubmit) return Colors.grey.shade300;
|
||||||
|
return isTransitTarget ? Colors.orange : primaryColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool allSamePaicha(Iterable<String?> paichaNos) {
|
||||||
|
String? first;
|
||||||
|
for (final paichaNo in paichaNos) {
|
||||||
|
if (paichaNo == null || paichaNo.isEmpty) return false;
|
||||||
|
first ??= paichaNo;
|
||||||
|
if (paichaNo != first) return false;
|
||||||
|
}
|
||||||
|
return first != null;
|
||||||
|
}
|
||||||
@@ -9,19 +9,24 @@ class ScanResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ScannerService {
|
class ScannerService {
|
||||||
static const _eventChannel =
|
static const _eventChannel = EventChannel('com.example.pad_scanner/scan');
|
||||||
EventChannel('com.example.pad_scanner/scan');
|
static Stream<ScanResult>? _sharedScanStream;
|
||||||
|
|
||||||
Stream<ScanResult>? _scanStream;
|
|
||||||
|
|
||||||
Stream<ScanResult> get scanResults {
|
Stream<ScanResult> get scanResults {
|
||||||
_scanStream ??= _eventChannel
|
_sharedScanStream ??= _createSharedScanStream();
|
||||||
|
return _sharedScanStream!;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Stream<ScanResult> _createSharedScanStream() {
|
||||||
|
return _eventChannel
|
||||||
.receiveBroadcastStream()
|
.receiveBroadcastStream()
|
||||||
.map((event) => event as Map)
|
.map((event) => event as Map)
|
||||||
.map((event) => ScanResult(
|
.map(
|
||||||
barcode: event['barcode'] as String? ?? '',
|
(event) => ScanResult(
|
||||||
codeType: event['codeType'] as String? ?? 'UNKNOWN',
|
barcode: event['barcode'] as String? ?? '',
|
||||||
));
|
codeType: event['codeType'] as String? ?? 'UNKNOWN',
|
||||||
return _scanStream!;
|
),
|
||||||
|
)
|
||||||
|
.asBroadcastStream();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
85
test/services/registration_linking_test.dart
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pad_scanner/services/code_parser.dart';
|
||||||
|
import 'package:pad_scanner/services/registration_linking.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('registration linking helpers', () {
|
||||||
|
test('detects transit target by type or code prefix', () {
|
||||||
|
expect(isTransitTarget(CodeType.locationTransit, 'TRANS-01'), isTrue);
|
||||||
|
expect(isTransitTarget(CodeType.locationNormal, 'TRANS-02'), isTrue);
|
||||||
|
expect(isTransitTarget(CodeType.locationNormal, 'A01-02-03'), isFalse);
|
||||||
|
expect(isTransitTarget(null, null), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('builds submit labels for normal and transit modes', () {
|
||||||
|
expect(
|
||||||
|
registrationSubmitLabel(
|
||||||
|
isTransitTarget: false,
|
||||||
|
isLocked: false,
|
||||||
|
zongpaiCount: 1,
|
||||||
|
),
|
||||||
|
'确 认 上 架',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
registrationSubmitLabel(
|
||||||
|
isTransitTarget: false,
|
||||||
|
isLocked: true,
|
||||||
|
zongpaiCount: 3,
|
||||||
|
),
|
||||||
|
'批量上架(3 条)',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
registrationSubmitLabel(
|
||||||
|
isTransitTarget: true,
|
||||||
|
isLocked: false,
|
||||||
|
zongpaiCount: 1,
|
||||||
|
),
|
||||||
|
'转运并装箱',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
registrationSubmitLabel(
|
||||||
|
isTransitTarget: true,
|
||||||
|
isLocked: true,
|
||||||
|
zongpaiCount: 3,
|
||||||
|
),
|
||||||
|
'批量转运并凑箱(3 条)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses orange submit color only for enabled transit target', () {
|
||||||
|
const primary = Colors.blue;
|
||||||
|
expect(
|
||||||
|
registrationSubmitColor(
|
||||||
|
canSubmit: true,
|
||||||
|
isTransitTarget: true,
|
||||||
|
primaryColor: primary,
|
||||||
|
),
|
||||||
|
Colors.orange,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
registrationSubmitColor(
|
||||||
|
canSubmit: true,
|
||||||
|
isTransitTarget: false,
|
||||||
|
primaryColor: primary,
|
||||||
|
),
|
||||||
|
primary,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
registrationSubmitColor(
|
||||||
|
canSubmit: false,
|
||||||
|
isTransitTarget: true,
|
||||||
|
primaryColor: primary,
|
||||||
|
),
|
||||||
|
Colors.grey.shade300,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validates all codes belong to one paicha', () {
|
||||||
|
expect(allSamePaicha(['R00001', 'R00001']), isTrue);
|
||||||
|
expect(allSamePaicha(['R00001', 'R00002']), isFalse);
|
||||||
|
expect(allSamePaicha(['R00001', null]), isFalse);
|
||||||
|
expect(allSamePaicha(<String>[]), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||