feat: add hardware key shortcuts and unify lock toggle design

- Home: add keyboard number shortcuts (1/2/3) for module navigation
- Boxing: add P2 key mode switching with pill-style AppBar header
- Registration: replace Switch with pill-style lock header matching boxing design
- Add Android keyCode fallback for scanner device compatibility
- Simplify home page cards to show only module name + shortcut hint
- Add lock redesign HTML preview document

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-05-18 11:26:02 +08:00
parent 2b5fdf41a9
commit 073162dc65
4 changed files with 950 additions and 308 deletions

View 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">&larr;</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">&larr;</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">&#128274;</span>
<span class="loc-chip blue">普通货架</span>
</div>
<div class="chip-wrap">
<span class="zchip">ZP2024001 <span class="x">&times;</span></span>
<span class="zchip">ZP2024002 <span class="x">&times;</span></span>
<span class="zchip">ZP2024003 <span class="x">&times;</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">&larr;</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">&larr;</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">&#128274;</span>
<span class="loc-chip blue">普通货架</span>
</div>
<div class="chip-wrap">
<span class="zchip">ZP2024001 <span class="x">&times;</span></span>
<span class="zchip">ZP2024002 <span class="x">&times;</span></span>
<span class="zchip">ZP2024003 <span class="x">&times;</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>

View File

@@ -84,9 +84,12 @@ class BoxingPage extends StatefulWidget {
} }
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; StreamSubscription<ScanResult>? _scanSubscription;
// 模式 // 模式
@@ -155,12 +158,14 @@ class _BoxingPageState extends State<BoxingPage> {
_processAutoScanCodes(arguments.autoScanCodes); _processAutoScanCodes(arguments.autoScanCodes);
}); });
} }
WidgetsBinding.instance.addPostFrameCallback((_) => _requestFocus());
} }
@override @override
void dispose() { void dispose() {
_boxNoController.removeListener(_onBoxNoTextChanged); _boxNoController.removeListener(_onBoxNoTextChanged);
_scanSubscription?.cancel(); _scanSubscription?.cancel();
_focusNode.dispose();
_boxNoController.dispose(); _boxNoController.dispose();
_quantityController.dispose(); _quantityController.dispose();
_boxNoFocusNode.dispose(); _boxNoFocusNode.dispose();
@@ -169,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(() {
@@ -360,6 +394,7 @@ class _BoxingPageState extends State<BoxingPage> {
_statusOverrideDot = null; _statusOverrideDot = null;
}); });
_focusQuantityInput(); _focusQuantityInput();
_requestFocus();
} }
void _applyAutoFill() { void _applyAutoFill() {
@@ -1253,163 +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: _isAutoProcessing ? null : _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 (_isAutoProcessing)
Container( Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), padding: const EdgeInsets.symmetric(
color: Colors.orange.shade50, vertical: 8,
child: Row( horizontal: 16,
children: [ ),
SizedBox( color: Colors.orange.shade50,
width: 16, child: Row(
height: 16, children: [
child: CircularProgressIndicator( SizedBox(
strokeWidth: 2, width: 16,
color: Colors.orange.shade700, height: 16,
), child: CircularProgressIndicator(
), strokeWidth: 2,
const SizedBox(width: 8), color: Colors.orange.shade700,
Text( ),
'正在同步转运数据...', ),
style: TextStyle( const SizedBox(width: 8),
color: Colors.orange.shade900, Text(
fontSize: 13, '正在同步转运数据...',
), style: TextStyle(
), color: Colors.orange.shade900,
], fontSize: 13,
),
),
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(
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,
),
),
],
),
),
), ),
); );
} }

View File

@@ -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,
),
),
);
}
} }

View File

@@ -19,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();
@@ -82,11 +84,32 @@ class _RegistrationPageState extends State<RegistrationPage> {
} }
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) {
@@ -623,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: [
@@ -701,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;