Compare commits
50 Commits
2937efdefd
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99ed3bb9ec | ||
|
|
f6bfd8cb10 | ||
|
|
9db50af1e6 | ||
|
|
4e8be6a684 | ||
|
|
5fc5c7de2b | ||
|
|
4891fe4dfd | ||
|
|
305083bf3f | ||
|
|
a601858225 | ||
|
|
9ab7a51dfb | ||
|
|
950effc164 | ||
|
|
e87d29e696 | ||
|
|
b9f17d5d49 | ||
|
|
9eb1bde645 | ||
|
|
c2d9f5fd56 | ||
|
|
f2b2f41929 | ||
|
|
0ae4017eee | ||
|
|
b17e0e7e62 | ||
|
|
3fd47f4d7b | ||
|
|
eb833c830d | ||
|
|
b1a76287d6 | ||
|
|
f587194267 | ||
|
|
a6ee6a718b | ||
|
|
7a95f49acc | ||
|
|
8c967a0f99 | ||
|
|
d9f8e066b3 | ||
|
|
f60745607f | ||
|
|
9e4667d68f | ||
|
|
9b9becb008 | ||
|
|
a7570891c5 | ||
|
|
df3569ba22 | ||
|
|
1dc9e3d3a1 | ||
|
|
f964c76382 | ||
|
|
3710e5955d | ||
|
|
073162dc65 | ||
|
|
2b5fdf41a9 | ||
|
|
deb038404a | ||
|
|
b2f2c536cb | ||
|
|
ce80e1a1f6 | ||
|
|
a165bfeabe | ||
|
|
28871eb21f | ||
|
|
077a1ab1b9 | ||
|
|
42a9cc7204 | ||
|
|
396a19ff43 | ||
|
|
b32c9c5aad | ||
|
|
b975b15ba7 | ||
|
|
e4a8816b98 | ||
|
|
c2fc884b66 | ||
|
|
62583510b2 | ||
|
|
1417c8baf9 | ||
|
|
ac77294ecb |
4
.gitignore
vendored
@@ -47,4 +47,6 @@ app.*.map.json
|
||||
/android/build/
|
||||
|
||||
# AI Agents
|
||||
.claude/
|
||||
.claude/
|
||||
.agents/
|
||||
.sisyphus/
|
||||
58
CLAUDE.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# pad_scanner CLAUDE.md
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
- The main branch is `master`, always kept in a releasable state
|
||||
- All new feature development must be done on a `dev` branch, created from `master`
|
||||
- After verification, merge `dev` back into `master`
|
||||
|
||||
## Pre-Commit Checks
|
||||
|
||||
Before committing Flutter app changes, run these checks:
|
||||
|
||||
```bash
|
||||
dart format --set-exit-if-changed .
|
||||
flutter analyze
|
||||
flutter test
|
||||
```
|
||||
|
||||
If `flutter test` fails with a localhost WebSocket/proxy error, follow the proxy cleanup steps in the Flutter Test Rules section below and run it again.
|
||||
|
||||
### Handling Check Failures
|
||||
|
||||
- If a check fails because of the current change, fix the issue before committing.
|
||||
- If `flutter analyze` reports any issue in files changed by the current task, fix it before committing.
|
||||
- If `flutter analyze` reports only unrelated pre-existing issues, do not fix them in the current commit. Report the file, line, and lint/error name, then handle them in a separate cleanup commit or task.
|
||||
- If a check fails because of unrelated pre-existing issues, do not include unrelated fixes in the same commit. Report the failing command and the existing issues, then handle them in a separate cleanup commit or task.
|
||||
- If formatting fails, format only files changed by the current task. Do not run a broad formatting cleanup unless that is the explicit task.
|
||||
- Treat `flutter test` failures as blocking unless the failure is clearly caused by the proxy issue described below and passes after rerunning with proxy variables cleared.
|
||||
- When committing or reporting completion, mention which checks were run and whether any remaining failures are unrelated pre-existing issues.
|
||||
|
||||
## App Installation Rules
|
||||
|
||||
**Always use `adb install -r` to install the app. Never use `flutter install`.**
|
||||
|
||||
`flutter install` uninstalls the old version first, which wipes app configuration (API URL, sound file paths, etc.). `adb install -r` performs an in-place upgrade that preserves all app data.
|
||||
|
||||
### Installation Steps
|
||||
|
||||
```bash
|
||||
# 1. Build
|
||||
flutter build apk --release
|
||||
|
||||
# 2. Install (in-place upgrade, preserves config)
|
||||
adb install -r build/app/outputs/flutter-apk/app-release.apk
|
||||
```
|
||||
|
||||
## Flutter Test Rules
|
||||
|
||||
Before running `flutter test`, temporarily remove proxy environment variables for the current shell. The Flutter tester uses a localhost WebSocket, and proxy settings can break that connection with `Invalid WebSocket upgrade request`.
|
||||
|
||||
### PowerShell
|
||||
|
||||
```powershell
|
||||
$env:HTTP_PROXY=''
|
||||
$env:HTTPS_PROXY=''
|
||||
$env:NO_PROXY='localhost,127.0.0.1,::1'
|
||||
flutter test
|
||||
```
|
||||
@@ -1,7 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<application
|
||||
android:label="pad_scanner"
|
||||
android:label="@string/app_name"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<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>
|
||||
@@ -1,4 +1,5 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
kotlin.incremental=false
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
systemProp.http.proxyHost=127.0.0.1
|
||||
|
||||
261
docs/error-feedback-refactoring-plan.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# 错误反馈机制重构计划
|
||||
|
||||
> 版本:v1.0
|
||||
> 日期:2026-05-12
|
||||
> 状态:已完成
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 当前问题
|
||||
|
||||
对照 PRD(上架登记模块 v1.0 + 装箱编号模块 v1.3)的要求,当前 Flutter 应用在错误反馈方面存在以下差距:
|
||||
|
||||
| # | 问题 | 影响范围 |
|
||||
|---|------|---------|
|
||||
| 1 | **装箱模块完全没有声音和振动反馈** — `boxing_page.dart` 未引入 `SoundService` 和 `Vibration` | 装箱编号 |
|
||||
| 2 | **反馈横幅位置错误** — 当前在页面顶部显示反馈横幅,PRD 要求统一由底部状态栏承载所有反馈文案 | 上架登记、装箱编号 |
|
||||
| 3 | **底部状态栏缺少黄色(网络异常)和红色(错误)状态** — 注册页状态栏圆点仅使用蓝/橙/绿三色,错误信息只出现在顶部横幅 | 上架登记 |
|
||||
| 4 | **反馈信息重复显示** — 顶部横幅和底部状态栏同时显示类似内容,造成冗余 | 上架登记、装箱编号 |
|
||||
| 5 | **声音类型不足** — `SoundService` 只有 success/failure 两种,PRD 要求区分"扫描提示音"、"错误音"、"成功音"、"连续错误音" | 全局 |
|
||||
| 6 | **重复上架弹窗缺少声音差异化** — PRD 要求"连续错误音"+"连续振动",当前只是普通失败音 | 上架登记 |
|
||||
|
||||
> LED 反馈暂时不实现,后续单独处理。
|
||||
|
||||
### 1.2 重构目标
|
||||
|
||||
- **统一反馈入口**:创建集中的 `FeedbackService`,管理声音/振动三通道反馈
|
||||
- **消除重复**:移除顶部反馈横幅,所有反馈文案统一由底部状态栏显示
|
||||
- **补全缺失**:为装箱模块补齐声音/振动反馈
|
||||
- **符合 PRD**:状态栏圆点颜色、文案、持续时间与 PRD 表格一一对应
|
||||
|
||||
---
|
||||
|
||||
## 2. PRD 反馈要求汇总
|
||||
|
||||
### 2.1 上架登记模块(PRD 第 7 节)
|
||||
|
||||
| 事件 | 屏幕(底部状态栏) | 声音 | 振动 |
|
||||
|------|-------------------|------|------|
|
||||
| 扫入有效总排号 | 总排号字段填入,绿色高亮 | 短促提示音 | 短震 |
|
||||
| 扫入有效货位号 | 货位号字段填入,绿色高亮 | 短促提示音 | 短震 |
|
||||
| 扫入无效码 | 红色提示 2 秒:"无效码:{值}" | 错误音 | 短震 |
|
||||
| 提交成功 | 绿色提示 1.5 秒 | 成功音 | 长震 |
|
||||
| 提交失败 | 红色提示 2 秒 | 失败音 | 短震 |
|
||||
| 重复上架错误 | 红色错误弹窗(需手动关闭) | 连续错误音 | 连续震 |
|
||||
| 网络异常 | 黄色提示:"网络异常,请检查网络连接" | 失败音 | 短震 |
|
||||
|
||||
### 2.2 装箱编号模块(PRD 第 9 节)
|
||||
|
||||
| 事件 | 屏幕(底部状态栏) | 声音 | 振动 |
|
||||
|------|-------------------|------|------|
|
||||
| 扫入有效总排号 | 显示排产号信息,绿色对勾 | 短促提示音 | 短震 |
|
||||
| 扫入无效码 | 红色提示:"无效码,请重新扫描" | 错误音 | 短震 |
|
||||
| 提交成功 | 绿色提示 1.5 秒 | 成功音 | 长震 |
|
||||
| 重复箱号 | amber 提示 + 输入框 amber 边框 | 失败音 | 短震 |
|
||||
| 网络异常 | 黄色提示:"网络异常,请检查网络连接" | 失败音 | 短震 |
|
||||
| 切换装箱模式 | 模式控件高亮切换 | 短促提示音 | — |
|
||||
|
||||
### 2.3 底部状态栏圆点颜色规范
|
||||
|
||||
| 状态 | 圆点颜色 |
|
||||
|------|---------|
|
||||
| 空闲等待 | 蓝色 |
|
||||
| 提交中 | 橙色 |
|
||||
| 成功 | 绿色 |
|
||||
| 错误(无效码/提交失败/重复) | 红色 |
|
||||
| 网络异常 | 黄色 |
|
||||
| 警告(重复箱号) | amber |
|
||||
|
||||
---
|
||||
|
||||
## 3. 架构设计
|
||||
|
||||
### 3.1 新增 `FeedbackService`
|
||||
|
||||
创建 `lib/services/feedback_service.dart`,作为**唯一的反馈调度中心**。
|
||||
|
||||
```
|
||||
FeedbackService
|
||||
├── trigger(FeedbackEvent event) ← 统一入口
|
||||
├── _playSound(SoundType type) ← 声音通道
|
||||
├── _vibrate(VibrationPattern p) ← 振动通道
|
||||
└── 底部状态栏状态由各 Page State 管理,FeedbackService 不直接操控 Widget
|
||||
```
|
||||
|
||||
**FeedbackEvent 枚举:**
|
||||
|
||||
```dart
|
||||
enum FeedbackEvent {
|
||||
scanValid, // 扫入有效码 → 短促提示音 + 短震
|
||||
scanInvalid, // 扫入无效码 → 错误音 + 短震
|
||||
submitSuccess, // 提交成功 → 成功音 + 长震
|
||||
submitFailure, // 提交失败 → 失败音 + 短震
|
||||
duplicateError, // 重复上架 → 连续错误音 + 连续震
|
||||
networkError, // 网络异常 → 失败音 + 短震
|
||||
duplicateBoxNo, // 重复箱号 → 失败音 + 短震
|
||||
modeSwitch, // 模式切换 → 短促提示音
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 声音扩展
|
||||
|
||||
扩展 `SoundService`,增加声音类型:
|
||||
|
||||
| 声音类型 | 方法名 | 用途 |
|
||||
|---------|--------|------|
|
||||
| 短促提示音 | `playBeep()` | 有效扫码、模式切换 |
|
||||
| 错误音 | `playError()` | 无效码 |
|
||||
| 成功音 | `playSuccess()` | 提交成功(保留现有) |
|
||||
| 失败音 | `playFailure()` | 提交失败、网络异常(保留现有) |
|
||||
| 连续错误音 | `playAlertLoop()` | 重复上架弹窗(循环播放直到弹窗关闭) |
|
||||
|
||||
**实现策略**:
|
||||
- 在 `SoundService` 中增加 `beep`、`error`、`alert` 三个可配置路径
|
||||
- 设置页面增加对应的文件选择器
|
||||
- 若路径未配置则静默跳过(不阻塞业务)
|
||||
|
||||
### 3.3 底部状态栏组件化
|
||||
|
||||
抽取 `lib/widgets/status_bar.dart` 为独立 Widget:
|
||||
|
||||
```dart
|
||||
enum StatusDotColor { blue, orange, green, red, yellow, amber }
|
||||
|
||||
class StatusBar extends StatelessWidget {
|
||||
final StatusDotColor dotColor;
|
||||
final String text;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
所有页面统一使用此组件,消除重复代码。
|
||||
|
||||
---
|
||||
|
||||
## 4. 文件变更清单
|
||||
|
||||
### 4.1 新增文件
|
||||
|
||||
| 文件路径 | 说明 |
|
||||
|---------|------|
|
||||
| `lib/services/feedback_service.dart` | 反馈调度中心,管理声音/振动 |
|
||||
| `lib/widgets/status_bar.dart` | 底部状态栏可复用组件 |
|
||||
|
||||
### 4.2 修改文件
|
||||
|
||||
| 文件路径 | 变更内容 |
|
||||
|---------|---------|
|
||||
| `lib/services/sound_service.dart` | 增加 `beep`/`error`/`alert` 声音类型和配置 |
|
||||
| `lib/pages/registration_page.dart` | ① 移除顶部反馈横幅 ② 状态栏增加红/黄颜色 ③ 引入 FeedbackService ④ 重复上架弹窗调用连续错误音 |
|
||||
| `lib/pages/boxing_page.dart` | ① 移除顶部反馈横幅 ② 引入 FeedbackService ③ 增加声音/振动反馈 ④ 状态栏统一使用 StatusBar 组件 |
|
||||
| `lib/pages/settings_page.dart` | 增加声音文件配置项(beep/error/alert) |
|
||||
|
||||
### 4.3 不变文件
|
||||
|
||||
| 文件路径 | 原因 |
|
||||
|---------|------|
|
||||
| `lib/services/api_service.dart` | API 层只负责数据传输,不涉及反馈 |
|
||||
| `lib/services/code_parser.dart` | 解析逻辑不变 |
|
||||
| `lib/services/scanner_service.dart` | 扫码硬件层不变 |
|
||||
| `lib/pages/home_page.dart` | 不涉及作业反馈 |
|
||||
| `lib/pages/boxing_detail_page.dart` | 只读展示页,无反馈交互 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 实施步骤
|
||||
|
||||
### 步骤 1:创建 `FeedbackService`(核心)
|
||||
|
||||
**文件**:`lib/services/feedback_service.dart`
|
||||
|
||||
- 定义 `FeedbackEvent` 枚举
|
||||
- 根据 event 映射声音类型、振动模式
|
||||
- 调用 `SoundService`、`Vibration`
|
||||
|
||||
**预估改动**:~70 行新代码
|
||||
|
||||
### 步骤 2:扩展 `SoundService`
|
||||
|
||||
**文件**:`lib/services/sound_service.dart`
|
||||
|
||||
- 新增 `playBeep()`、`playError()`、`playAlertLoop()`、`stopAlert()` 方法
|
||||
- 新增 `beep_path`、`error_path`、`alert_path` 配置存取
|
||||
- 所有播放方法在路径未配置时静默返回
|
||||
|
||||
**预估改动**:~40 行新增
|
||||
|
||||
### 步骤 3:创建 `StatusBar` 组件
|
||||
|
||||
**文件**:`lib/widgets/status_bar.dart`
|
||||
|
||||
- 抽取底部状态栏为独立 Widget
|
||||
- 接收 `dotColor`(枚举)和 `text` 参数
|
||||
- 统一样式:圆点 + 文字,背景色使用 `surfaceContainerHighest`
|
||||
|
||||
**预估改动**:~40 行新代码
|
||||
|
||||
### 步骤 4:重构上架登记页
|
||||
|
||||
**文件**:`lib/pages/registration_page.dart`
|
||||
|
||||
变更清单:
|
||||
1. **删除**顶部反馈横幅相关代码(`_snackbarMessage`、`_snackbarColor`、`_showFeedback()` 方法中的横幅渲染)
|
||||
2. **引入** `FeedbackService`,在以下时机调用 `trigger()`:
|
||||
- `_onScan` 无效码 → `scanInvalid`
|
||||
- `_onScan` 有效码 → `scanValid`
|
||||
- `_submitOne` 成功 → `submitSuccess`
|
||||
- `_submitOne` 失败 → `submitFailure`
|
||||
- `_submitOne` 重复 → `duplicateError`
|
||||
- catch 网络异常 → `networkError`
|
||||
3. **修改**底部状态栏:
|
||||
- 状态栏圆点颜色增加红色和黄色
|
||||
- 错误文案在状态栏中显示(不再在横幅中)
|
||||
- 移除 `_successMessage` 单独字段,统一由状态栏管理
|
||||
4. **替换** 内联状态栏 Widget 为 `StatusBar` 组件
|
||||
5. **修改** `_showDuplicateDialog`:打开时触发 `duplicateError`(连续音/震),关闭时停止
|
||||
|
||||
**预估改动**:净减 ~30 行(移除横幅 > 新增 trigger 调用)
|
||||
|
||||
### 步骤 5:重构装箱编号页
|
||||
|
||||
**文件**:`lib/pages/boxing_page.dart`
|
||||
|
||||
变更清单:
|
||||
1. **删除**顶部反馈横幅相关代码
|
||||
2. **删除** 内联底部状态栏代码,替换为 `StatusBar` 组件
|
||||
3. **引入** `FeedbackService`,在以下时机调用 `trigger()`:
|
||||
- `_onScan` 无效码 → `scanInvalid`
|
||||
- `_queryBoxInfo` 成功 → `scanValid`
|
||||
- `_submit` 成功 → `submitSuccess`
|
||||
- `_submit` 失败 → `submitFailure`
|
||||
- `_submit` 重复箱号 → `duplicateBoxNo`
|
||||
- catch 网络异常 → `networkError`
|
||||
- `_cycleMode` → `modeSwitch`
|
||||
4. **修改**状态栏颜色逻辑,增加红色/黄色/amber
|
||||
|
||||
**预估改动**:净减 ~20 行
|
||||
|
||||
### 步骤 6:设置页扩展**文件**:`lib/pages/settings_page.dart`- 新增三个声音文件选择器:提示音(beep)、错误音(error)、警报音(alert)- 保持与现有 success/failure 选择器一致的 UI 风格**预估改动**:~60 行新增
|
||||
|
||||
---
|
||||
|
||||
## 6. 风险与约束
|
||||
|
||||
| 风险 | 缓解措施 |
|
||||
|------|---------|
|
||||
| LED 功能依赖设备硬件 | `LedService` 全面包裹 try-catch,不支持时静默降级 |
|
||||
| 声音文件路径未配置 | 所有 `play*()` 方法在路径为空时静默返回 |
|
||||
| 连续错误音循环播放 | `playAlertLoop()` 使用循环计数或时长上限,`stopAlert()` 确保可停止 |
|
||||
| 振动权限 | Android 需要 `VIBRATE` 权限,检查 `AndroidManifest.xml` 是否已声明 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 不在本次范围内
|
||||
|
||||
- 后端 API 错误格式调整(PRD 已定义,后端自行对齐)
|
||||
- 操作日志与操作人员记录
|
||||
- 离线缓存与联网同步
|
||||
- 新模块开发
|
||||
- LED 反馈(后续单独处理)
|
||||
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>
|
||||
1189
docs/multi-code-boxing-ui-preview.html
Normal file
1146
docs/multi-code-boxing-ui-v2.html
Normal file
482
docs/registration-redesign-preview.html
Normal file
@@ -0,0 +1,482 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>上架登记模块 - UI 重构预览</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, 'Segoe UI', 'Noto Sans SC', 'Roboto', sans-serif;
|
||||
background: #eef1f5; height: 100vh; display: flex; overflow: hidden;
|
||||
}
|
||||
|
||||
/* ========== Layout ========== */
|
||||
.sidebar {
|
||||
width: 280px; min-width: 280px; background: #fff; border-right: 1px solid #ddd;
|
||||
overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
.main {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center; padding: 20px; gap: 40px;
|
||||
}
|
||||
|
||||
/* ========== Control Panel ========== */
|
||||
.group { border: 1px solid #e0e0e0; border-radius: 8px; padding: 10px; background: #fafbfc; }
|
||||
.group-title { font-size: 12px; font-weight: 700; color: #333; margin-bottom: 6px; padding-bottom: 5px; border-bottom: 1px solid #eee; }
|
||||
.btn-row { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 5px; }
|
||||
.btn-row:last-child { margin-bottom: 0; }
|
||||
.btn { padding: 4px 9px; font-size: 11px; border: 1px solid #ccc; border-radius: 5px; background: #fff; cursor: pointer; transition: all .15s; white-space: nowrap; color: #333; }
|
||||
.btn:hover { background: #e8f0fe; border-color: #90b4f0; }
|
||||
.btn.active { background: #1a73e8; color: #fff; border-color: #1a73e8; }
|
||||
|
||||
/* ========== Device Frame ========== */
|
||||
.device {
|
||||
width: 400px; height: 700px; border-radius: 16px; overflow: hidden;
|
||||
box-shadow: 0 6px 30px rgba(0,0,0,.22); background: #fff;
|
||||
display: flex; flex-direction: column; position: relative; border: 2px solid #222;
|
||||
}
|
||||
|
||||
/* ========== AppBar ========== */
|
||||
.appbar { height: 44px; min-height: 44px; background: #1565C0; color: #fff; display: flex; align-items: center; padding: 0 4px 0 12px; }
|
||||
.appbar-title { font-size: 17px; font-weight: 600; white-space: nowrap; }
|
||||
.appbar-gap { width: 8px; }
|
||||
.mode-pill { flex: 1; height: 32px; border-radius: 8px; display: flex; align-items: center; justify-content: center; gap: 5px; font-size: 14px; font-weight: 800; color: #fff; border: none; transition: background .2s; }
|
||||
.mode-pill.unlocked { background: #1976D2; }
|
||||
.mode-pill.locked { background: #F57C00; }
|
||||
.mode-pill .icon { font-size: 15px; }
|
||||
.mode-pill .shortcut { font-size: 11px; font-weight: 700; opacity: .85; margin-left: 3px; }
|
||||
|
||||
/* ========== Body ========== */
|
||||
.body { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
|
||||
/* ========== Paicha Header ========== */
|
||||
.paicha-header { padding: 8px 12px 6px; background: #FAFAFA; border-bottom: 1px solid #E0E0E0; display: flex; align-items: center; gap: 12px; }
|
||||
.paicha-header .left { flex-shrink: 0; }
|
||||
.paicha-header .paicha-no { font-size: 24px; font-weight: 800; color: rgba(0,0,0,.87); }
|
||||
.paicha-header .paicha-no.placeholder { color: #BDBDBD; font-weight: 700; }
|
||||
.paicha-header .right { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: flex-end; gap: 3px; }
|
||||
.loc-info { display: flex; align-items: center; gap: 5px; font-size: 12px; color: #757575; }
|
||||
.loc-info .loc-code { font-weight: 600; color: #555; }
|
||||
.loc-chip { padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 700; white-space: nowrap; }
|
||||
.loc-chip.normal { background: rgba(33,150,243,.12); color: #1976D2; }
|
||||
.loc-chip.transit { background: rgba(255,152,0,.12); color: #E65100; }
|
||||
.stats { display: flex; align-items: center; gap: 5px; justify-content: flex-end; flex-wrap: wrap; }
|
||||
.stat-tag { padding: 1px 7px; border-radius: 10px; font-size: 10px; font-weight: 700; white-space: nowrap; }
|
||||
.stat-tag.total { background: #E3F2FD; color: #1565C0; }
|
||||
.stat-tag.shelved { background: #E8F5E9; color: #2E7D32; }
|
||||
.stat-tag.transferred { background: #FFF3E0; color: #E65100; }
|
||||
.stat-tag.pending { background: #F5F5F5; color: #757575; }
|
||||
.stat-tag.placeholder { background: #F5F5F5; color: #BDBDBD; }
|
||||
|
||||
/* ========== Table Head ========== */
|
||||
.table-head { height: 28px; padding: 0 12px; background: #EEEEEE; border-bottom: 1px solid #BDBDBD; display: flex; align-items: center; }
|
||||
.table-head span { font-size: 10px; font-weight: 700; color: #666; }
|
||||
.table-head .th1 { flex: 22; }
|
||||
.table-head .th2 { flex: 18; }
|
||||
.table-head .th3 { flex: 12; text-align: center; }
|
||||
.table-head .th4 { flex: 28; text-align: right; }
|
||||
|
||||
/* ========== List Body ========== */
|
||||
.list-body { flex: 1; overflow-y: auto; }
|
||||
|
||||
/* Table rows */
|
||||
.table-row { height: 36px; padding: 0 12px; display: flex; align-items: center; border-bottom: 1px solid #EEEEEE; position: relative; }
|
||||
.table-row .color-bar { position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 2px; }
|
||||
.table-row .cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.table-row .cell.c1 { flex: 22; font-size: 12px; font-weight: 600; color: rgba(0,0,0,.87); }
|
||||
.table-row .cell.c2 { flex: 18; font-size: 12px; color: rgba(0,0,0,.6); }
|
||||
.table-row .cell.c3 { flex: 12; font-size: 12px; color: rgba(0,0,0,.6); text-align: center; }
|
||||
.table-row .cell.c4 { flex: 28; font-size: 12px; font-weight: 600; color: rgba(0,0,0,.87); text-align: right; }
|
||||
|
||||
/* Scanned row — always green indicator, regardless of underlying status */
|
||||
.table-row.scanned { background: #F1F8E9; }
|
||||
.table-row.scanned .color-bar { background: #43A047; }
|
||||
.table-row.scanned .cell.c1 { font-weight: 700; }
|
||||
|
||||
/* Unscanned rows — status-based */
|
||||
.table-row.on_shelf { background: #fff; }
|
||||
.table-row.on_shelf .color-bar { background: #2196F3; }
|
||||
.table-row.transferred { background: #FFF8E1; }
|
||||
.table-row.transferred .color-bar { background: #FF9800; }
|
||||
.table-row.not_shelved { background: #FAFAFA; }
|
||||
.table-row.not_shelved .color-bar { background: #BDBDBD; }
|
||||
|
||||
/* Section divider */
|
||||
.section-divider {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 0 12px; height: 24px;
|
||||
border-bottom: 1px solid #EEEEEE;
|
||||
}
|
||||
.section-divider .line { flex: 1; height: 0; border-top: 1px dashed #CCC; }
|
||||
.section-divider .label { font-size: 10px; font-weight: 600; color: #9E9E9E; white-space: nowrap; letter-spacing: .3px; }
|
||||
|
||||
.overview-msg { display: flex; align-items: center; justify-content: center; font-size: 13px; color: #9E9E9E; font-style: italic; height: 100%; }
|
||||
|
||||
/* ========== Bottom Bar ========== */
|
||||
.bottom-bar { min-height: 50px; padding: 8px 12px; background: #fff; border-top: 1px solid #E0E0E0; display: flex; align-items: center; gap: 8px; }
|
||||
.submit-btn { flex: 1; height: 34px; border-radius: 6px; border: none; font-size: 14px; font-weight: 700; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background .15s; }
|
||||
.submit-btn.disabled { background: #E0E0E0; color: #9E9E9E; cursor: default; }
|
||||
.submit-btn.primary { background: #1976D2; color: #fff; }
|
||||
.submit-btn.transit { background: #FF9800; color: #fff; }
|
||||
.submit-btn .spinner { width: 18px; height: 18px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ========== Status Bar ========== */
|
||||
.status-bar { height: 28px; min-height: 28px; padding: 0 12px; background: #F5F5F5; display: flex; align-items: center; gap: 7px; border-top: 1px solid #E0E0E0; }
|
||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
||||
.status-dot.blue { background: #2196F3; }
|
||||
.status-dot.orange { background: #FF9800; }
|
||||
.status-dot.green { background: #4CAF50; }
|
||||
.status-dot.red { background: #F44336; }
|
||||
.status-dot.yellow { background: #FBC02D; }
|
||||
.status-text { font-size: 11px; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
|
||||
/* ========== Dialog ========== */
|
||||
.dialog-overlay { position: absolute; inset: 0; background: rgba(0,0,0,.4); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
.dialog-overlay.hidden { display: none; }
|
||||
.dialog { background: #FFEBEE; border-radius: 12px; padding: 20px; width: 300px; box-shadow: 0 8px 30px rgba(0,0,0,.25); }
|
||||
.dialog-title { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.dialog-title .icon { color: #F44336; font-size: 20px; }
|
||||
.dialog-title .text { font-size: 17px; font-weight: 600; color: #F44336; }
|
||||
.dialog-body { font-size: 13px; color: #333; line-height: 1.7; }
|
||||
.dialog-body strong { font-weight: 700; }
|
||||
.dialog-actions { margin-top: 14px; text-align: right; }
|
||||
.dialog-close { padding: 5px 14px; font-size: 13px; border: none; border-radius: 5px; background: transparent; color: #1976D2; cursor: pointer; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ==================== Sidebar ==================== -->
|
||||
<div class="sidebar">
|
||||
<div style="font-size:15px;font-weight:700;color:#1565C0;text-align:center;padding-bottom:8px;border-bottom:2px solid #1565C0;">
|
||||
上架登记 - 重构预览控制
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<div class="group-title">1. 锁定模式</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setState('locked',false)">未锁定</button>
|
||||
<button class="btn" onclick="setState('locked',true)">已锁定</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<div class="group-title">2. 货位</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setState('location',null)">无</button>
|
||||
<button class="btn" onclick="setState('location','normal')">普通 A-01-03</button>
|
||||
<button class="btn" onclick="setState('location','transit')">转运 TRANS-001</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<div class="group-title">3. 总排号</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setState('zongpai',0)">无</button>
|
||||
<button class="btn" onclick="setState('zongpai',1)">1 条</button>
|
||||
<button class="btn" onclick="setState('zongpai',3)">3 条</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<div class="group-title">4. 提交中</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setState('submitting',false)">空闲</button>
|
||||
<button class="btn" onclick="setState('submitting',true)">Loading</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<div class="group-title">5. 总览</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setState('overview','empty')">未扫描</button>
|
||||
<button class="btn" onclick="setState('overview','loaded')">正常</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<div class="group-title">6. 状态栏</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setState('status','auto')">自动</button>
|
||||
<button class="btn" onclick="setState('status','success')">上架成功</button>
|
||||
<button class="btn" onclick="setState('status','offShelf')">下架成功</button>
|
||||
<button class="btn" onclick="setState('status','batch')">批量成功(3条)</button>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn" onclick="setState('status','invalid')">无效码</button>
|
||||
<button class="btn" onclick="setState('status','network')">网络异常</button>
|
||||
<button class="btn" onclick="setState('status','fail')">提交失败</button>
|
||||
<button class="btn" onclick="setState('status','duplicate')">重复(弹窗)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== Device ==================== -->
|
||||
<div class="main">
|
||||
<div class="device" id="device">
|
||||
|
||||
<!-- AppBar -->
|
||||
<div class="appbar">
|
||||
<span class="appbar-title">上架登记</span>
|
||||
<span class="appbar-gap"></span>
|
||||
<button class="mode-pill unlocked" id="modePill">
|
||||
<span class="icon">⇄</span>
|
||||
<span id="modeLabel">未锁定</span>
|
||||
<span class="shortcut">P2</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="body">
|
||||
|
||||
<!-- Paicha Header -->
|
||||
<div class="paicha-header" id="paichaHeader">
|
||||
<div class="left">
|
||||
<span class="paicha-no placeholder" id="paichaNo">--</span>
|
||||
</div>
|
||||
<div class="right">
|
||||
<span class="loc-info" id="locInfo" style="display:none;"></span>
|
||||
<span class="stats" id="paichaStats"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table Head -->
|
||||
<div class="table-head" id="tableHead" style="display:none;">
|
||||
<span class="th1">总排号</span>
|
||||
<span class="th2">工令号</span>
|
||||
<span class="th3">数量</span>
|
||||
<span class="th4">货位号</span>
|
||||
</div>
|
||||
|
||||
<!-- List Body -->
|
||||
<div class="list-body" id="listBody">
|
||||
<div class="overview-msg">扫描总排号后自动显示排产号上架情况</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Bar -->
|
||||
<div class="bottom-bar">
|
||||
<button class="submit-btn disabled" id="submitBtn">
|
||||
<span id="submitText">确 认 上 架</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<div class="status-bar">
|
||||
<span class="status-dot blue" id="statusDot"></span>
|
||||
<span class="status-text" id="statusTextEl">等待扫描总排号或货位号…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog -->
|
||||
<div class="dialog-overlay hidden" id="dialogOverlay">
|
||||
<div class="dialog">
|
||||
<div class="dialog-title"><span class="icon">⚠</span><span class="text">重复上架</span></div>
|
||||
<div class="dialog-body">
|
||||
<div>总排号:<strong>26B35</strong></div>
|
||||
<div style="margin-top:3px;">已登记货位:<strong>A-02-05</strong></div>
|
||||
<div style="margin-top:3px;">登记时间:<strong>2026-05-19 14:30:22</strong></div>
|
||||
<div style="margin-top:10px;font-weight:700;">请核查实物,确认是否操作错误。</div>
|
||||
</div>
|
||||
<div class="dialog-actions"><button class="dialog-close">关闭</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const S = { locked: false, location: null, zongpai: 0, submitting: false, overview: 'empty', status: 'auto' };
|
||||
|
||||
const DATA = {
|
||||
paichaNo: 'R00015', totalCount: 5, shelvedCount: 2, transferredCount: 1, pendingCount: 2,
|
||||
items: [
|
||||
{ zp: '26B35', wo: '3-1', qty: 50, loc: 'A-01-03', status: 'on_shelf' },
|
||||
{ zp: '26B36', wo: '3-2-1', qty: 100, loc: 'A-01-05', status: 'on_shelf' },
|
||||
{ zp: '26B37', wo: '3-2-2', qty: 100, loc: null, status: 'not_shelved' },
|
||||
{ zp: '26B38', wo: '3-2-3', qty: 50, loc: 'TRANS-001', status: 'transferred' },
|
||||
{ zp: '26B39', wo: '3-3', qty: 45, loc: null, status: 'not_shelved' },
|
||||
]
|
||||
};
|
||||
|
||||
// scanned items are identified by index: when zongpai=1 → [0], zongpai=3 → [0,2,4]
|
||||
const SCANNED_INDICES = { 1: [0], 3: [0, 2, 4] };
|
||||
|
||||
const STATUS_MAP = {
|
||||
success: { dot: 'green', text: '上架成功' },
|
||||
offShelf: { dot: 'green', text: '下架成功' },
|
||||
batch: { dot: 'green', text: '批量上架成功(3 条)' },
|
||||
invalid: { dot: 'red', text: '无效码:ABCXYZ123' },
|
||||
network: { dot: 'yellow', text: '网络异常,请检查网络连接' },
|
||||
fail: { dot: 'red', text: '请求参数错误' },
|
||||
duplicate: { dot: 'red', text: '请确认信息并提交' },
|
||||
};
|
||||
|
||||
function setState(key, val) {
|
||||
S[key] = val;
|
||||
document.querySelectorAll('.btn').forEach(btn => {
|
||||
const m = btn.getAttribute('onclick')?.match(/setState\('(\w+)',\s*(.+)\)/);
|
||||
if (!m) return;
|
||||
btn.classList.toggle('active', String(val) === m[2].replace(/'/g, '') && m[1] === key);
|
||||
});
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderAppBar();
|
||||
renderPaichaHeader();
|
||||
renderList();
|
||||
renderBottomBar();
|
||||
renderStatusBar();
|
||||
renderDialog();
|
||||
}
|
||||
|
||||
function renderAppBar() {
|
||||
const pill = document.getElementById('modePill');
|
||||
const label = document.getElementById('modeLabel');
|
||||
pill.className = 'mode-pill ' + (S.locked ? 'locked' : 'unlocked');
|
||||
label.textContent = S.locked ? '货位锁定' : '未锁定';
|
||||
}
|
||||
|
||||
function renderPaichaHeader() {
|
||||
const no = document.getElementById('paichaNo');
|
||||
const locInfo = document.getElementById('locInfo');
|
||||
const stats = document.getElementById('paichaStats');
|
||||
const hasLoc = S.location !== null;
|
||||
const locCode = S.location === 'normal' ? 'A-01-03' : S.location === 'transit' ? 'TRANS-001' : null;
|
||||
|
||||
// Paicha number (left)
|
||||
if (S.overview === 'loaded') {
|
||||
no.textContent = DATA.paichaNo; no.className = 'paicha-no';
|
||||
} else {
|
||||
no.textContent = '--'; no.className = 'paicha-no placeholder';
|
||||
}
|
||||
|
||||
// Location info (right, top row)
|
||||
if (hasLoc) {
|
||||
locInfo.style.display = 'flex';
|
||||
locInfo.innerHTML = `<span class="loc-code">${locCode}</span><span class="loc-chip ${S.location}">${S.location === 'transit' ? '转运区域' : '普通货架'}</span>`;
|
||||
} else {
|
||||
locInfo.style.display = 'none';
|
||||
}
|
||||
|
||||
// Stats tags (right, bottom row)
|
||||
if (S.overview === 'loaded') {
|
||||
stats.innerHTML =
|
||||
`<span class="stat-tag total">共 ${DATA.totalCount}</span>` +
|
||||
`<span class="stat-tag shelved">上架 ${DATA.shelvedCount}</span>` +
|
||||
`<span class="stat-tag transferred">转运 ${DATA.transferredCount}</span>` +
|
||||
`<span class="stat-tag pending">待上架 ${DATA.pendingCount}</span>`;
|
||||
} else {
|
||||
stats.innerHTML =
|
||||
`<span class="stat-tag placeholder">共 -</span>` +
|
||||
`<span class="stat-tag placeholder">上架 -</span>` +
|
||||
`<span class="stat-tag placeholder">转运 -</span>` +
|
||||
`<span class="stat-tag placeholder">待上架 -</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const tableHead = document.getElementById('tableHead');
|
||||
const body = document.getElementById('listBody');
|
||||
|
||||
if (S.overview !== 'loaded') {
|
||||
tableHead.style.display = 'none';
|
||||
body.innerHTML = '<div class="overview-msg">扫描总排号后自动显示排产号上架情况</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
tableHead.style.display = 'flex';
|
||||
|
||||
// Determine which items are scanned
|
||||
const scannedSet = new Set(S.zongpai > 0 ? (SCANNED_INDICES[S.zongpai] || []) : []);
|
||||
|
||||
// Split into scanned and unscanned groups
|
||||
const scanned = [], unscanned = [];
|
||||
DATA.items.forEach((item, idx) => {
|
||||
if (scannedSet.has(idx)) scanned.push({ ...item, origIdx: idx });
|
||||
else unscanned.push({ ...item, origIdx: idx });
|
||||
});
|
||||
|
||||
let html = '';
|
||||
|
||||
// Scanned items (always at top)
|
||||
scanned.forEach(item => {
|
||||
html += rowHtml(item, true);
|
||||
});
|
||||
|
||||
// Divider between groups (only when both groups exist)
|
||||
if (scanned.length > 0 && unscanned.length > 0) {
|
||||
html += `<div class="section-divider">
|
||||
<span class="line"></span>
|
||||
<span class="label">以下 ${unscanned.length} 项未操作</span>
|
||||
<span class="line"></span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Unscanned items
|
||||
unscanned.forEach(item => {
|
||||
html += rowHtml(item, false);
|
||||
});
|
||||
|
||||
body.innerHTML = html;
|
||||
}
|
||||
|
||||
function rowHtml(item, isScanned) {
|
||||
const cls = isScanned ? 'scanned' : item.status;
|
||||
return `<div class="table-row ${cls}">
|
||||
<div class="color-bar"></div>
|
||||
<span class="cell c1" style="padding-left:10px;">${item.zp}</span>
|
||||
<span class="cell c2">${item.wo}</span>
|
||||
<span class="cell c3">${item.qty}</span>
|
||||
<span class="cell c4">${item.loc || '\u2014'}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderBottomBar() {
|
||||
const btn = document.getElementById('submitBtn');
|
||||
const text = document.getElementById('submitText');
|
||||
const hasLoc = S.location !== null, hasZp = S.zongpai > 0;
|
||||
const isTransit = S.location === 'transit';
|
||||
const canSubmit = hasLoc && hasZp && !S.submitting;
|
||||
|
||||
let label;
|
||||
if (S.submitting) { label = null; }
|
||||
else if (isTransit) { label = S.locked && S.zongpai > 1 ? `批量转运并凑箱(${S.zongpai} 条)` : '转运并装箱'; }
|
||||
else { label = S.locked && S.zongpai > 1 ? `批量上架(${S.zongpai} 条)` : '确 认 上 架'; }
|
||||
|
||||
if (S.submitting) {
|
||||
btn.className = 'submit-btn ' + (isTransit ? 'transit' : (canSubmit ? 'primary' : 'disabled'));
|
||||
text.innerHTML = '<span class="spinner"></span>';
|
||||
} else if (!canSubmit) {
|
||||
btn.className = 'submit-btn disabled'; text.textContent = label;
|
||||
} else {
|
||||
btn.className = 'submit-btn ' + (isTransit ? 'transit' : 'primary'); text.textContent = label;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatusBar() {
|
||||
const dot = document.getElementById('statusDot');
|
||||
const text = document.getElementById('statusTextEl');
|
||||
|
||||
if (S.status !== 'auto' && STATUS_MAP[S.status]) {
|
||||
const o = STATUS_MAP[S.status]; dot.className = 'status-dot ' + o.dot; text.textContent = o.text; return;
|
||||
}
|
||||
if (S.submitting) { dot.className = 'status-dot orange'; text.textContent = '正在提交…'; return; }
|
||||
const hasLoc = S.location !== null, hasZp = S.zongpai > 0;
|
||||
if (S.locked && hasLoc && !hasZp) { dot.className = 'status-dot blue'; text.textContent = '货位已锁定,请扫描下一张执行卡'; }
|
||||
else if (hasLoc && hasZp) { dot.className = 'status-dot blue'; text.textContent = '请确认信息并提交'; }
|
||||
else if (hasZp) { dot.className = 'status-dot blue'; text.textContent = '请扫描目标货位号'; }
|
||||
else if (hasLoc) { dot.className = 'status-dot blue'; text.textContent = '请扫描执行卡'; }
|
||||
else { dot.className = 'status-dot blue'; text.textContent = '等待扫描总排号或货位号…'; }
|
||||
}
|
||||
|
||||
function renderDialog() {
|
||||
document.getElementById('dialogOverlay').classList.toggle('hidden', S.status !== 'duplicate');
|
||||
}
|
||||
|
||||
document.querySelector('.dialog-close').addEventListener('click', () => setState('status', 'auto'));
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
901
docs/registration-ui-states.html
Normal file
@@ -0,0 +1,901 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>上架登记模块 - UI 状态全览</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, 'Segoe UI', 'Noto Sans SC', 'Roboto', sans-serif;
|
||||
background: #eef1f5; height: 100vh; display: flex; overflow: hidden;
|
||||
}
|
||||
|
||||
/* ========== Layout ========== */
|
||||
.sidebar {
|
||||
width: 310px; min-width: 310px; background: #fff; border-right: 1px solid #ddd;
|
||||
overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 14px;
|
||||
}
|
||||
.main {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center; padding: 20px;
|
||||
}
|
||||
|
||||
/* ========== Control Panel ========== */
|
||||
.group { border: 1px solid #e0e0e0; border-radius: 10px; padding: 12px; background: #fafbfc; }
|
||||
.group-title {
|
||||
font-size: 13px; font-weight: 700; color: #333; margin-bottom: 8px;
|
||||
padding-bottom: 6px; border-bottom: 1px solid #eee;
|
||||
}
|
||||
.btn-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
|
||||
.btn-row:last-child { margin-bottom: 0; }
|
||||
.btn {
|
||||
padding: 5px 10px; font-size: 12px; border: 1px solid #ccc; border-radius: 6px;
|
||||
background: #fff; cursor: pointer; transition: all .15s; white-space: nowrap;
|
||||
color: #333;
|
||||
}
|
||||
.btn:hover { background: #e8f0fe; border-color: #90b4f0; }
|
||||
.btn.active { background: #1a73e8; color: #fff; border-color: #1a73e8; }
|
||||
.btn.active:hover { background: #1565c0; }
|
||||
.label { font-size: 11px; color: #888; margin-bottom: 4px; margin-top: 4px; }
|
||||
|
||||
/* ========== Device Frame ========== */
|
||||
.device {
|
||||
width: 400px; height: 720px; border-radius: 18px; overflow: hidden;
|
||||
box-shadow: 0 6px 30px rgba(0,0,0,.22); background: #fff;
|
||||
display: flex; flex-direction: column; position: relative;
|
||||
border: 2px solid #222;
|
||||
}
|
||||
|
||||
/* ========== Flutter UI Simulation ========== */
|
||||
|
||||
/* AppBar */
|
||||
.fl-appbar {
|
||||
height: 56px; min-height: 56px; background: #1565C0; color: #fff;
|
||||
display: flex; align-items: center; padding: 0 4px 0 12px; gap: 0;
|
||||
}
|
||||
.fl-appbar-title { font-size: 18px; font-weight: 600; white-space: nowrap; }
|
||||
.fl-appbar-spacer { width: 12px; }
|
||||
.fl-lock-btn {
|
||||
flex: 1; height: 36px; border-radius: 8px; display: flex; align-items: center;
|
||||
justify-content: center; gap: 6px; cursor: pointer; font-size: 15px;
|
||||
font-weight: 800; color: #fff; border: none; transition: background .2s;
|
||||
}
|
||||
.fl-lock-btn.unlocked { background: #1976D2; }
|
||||
.fl-lock-btn.locked { background: #F57C00; }
|
||||
.fl-lock-btn .shortcut {
|
||||
font-size: 12px; font-weight: 700; opacity: .88; margin-left: 4px;
|
||||
}
|
||||
.fl-lock-btn .icon { font-size: 16px; }
|
||||
|
||||
/* Body */
|
||||
.fl-body { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
|
||||
/* Operation Area */
|
||||
.fl-op-area { padding: 10px 12px 8px; }
|
||||
.fl-section-title {
|
||||
font-size: 14px; font-weight: 700; color: #333; margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Input Panel - Single */
|
||||
.fl-input-single {
|
||||
min-height: 58px; padding: 10px; border-radius: 8px;
|
||||
display: flex; align-items: center; gap: 0;
|
||||
transition: border .2s;
|
||||
}
|
||||
.fl-input-single.empty { border: 1px solid #BDBDBD; }
|
||||
.fl-input-single.filled { border: 2px solid #4CAF50; }
|
||||
.fl-input-single .loc-part {
|
||||
flex: 5; display: flex; align-items: center; gap: 6px; min-width: 0;
|
||||
}
|
||||
.fl-input-single .loc-text {
|
||||
font-size: 18px; font-weight: 700; color: rgba(0,0,0,.87);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.fl-input-single .loc-text.placeholder { color: #9E9E9E; }
|
||||
.fl-input-single .divider {
|
||||
padding: 0 8px; font-size: 20px; color: #9E9E9E; flex-shrink: 0;
|
||||
}
|
||||
.fl-input-single .zp-part {
|
||||
flex: 3; text-align: right; min-width: 0;
|
||||
}
|
||||
.fl-input-single .zp-text {
|
||||
font-size: 18px; font-weight: 700; color: rgba(0,0,0,.87);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.fl-input-single .zp-text.placeholder { color: #9E9E9E; }
|
||||
|
||||
/* Input Panel - Locked */
|
||||
.fl-input-locked {
|
||||
min-height: 82px; padding: 8px 10px; border-radius: 8px;
|
||||
transition: border .2s;
|
||||
}
|
||||
.fl-input-locked.empty { border: 1px solid #BDBDBD; }
|
||||
.fl-input-locked.filled { border: 2px solid #4CAF50; }
|
||||
.fl-input-locked .loc-row {
|
||||
display: flex; align-items: center; gap: 6px; margin-bottom: 8px;
|
||||
}
|
||||
.fl-input-locked .loc-text {
|
||||
flex: 1; font-size: 18px; font-weight: 700; color: rgba(0,0,0,.87);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.fl-lock-icon { color: #FF9800; font-size: 16px; flex-shrink: 0; }
|
||||
.fl-chips-area { display: flex; flex-wrap: wrap; gap: 6px; min-height: 30px; }
|
||||
.fl-chips-area.empty-space { height: 24px; }
|
||||
|
||||
/* InputChip */
|
||||
.fl-chip {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 2px 4px 2px 10px; background: #E8E8E8; border-radius: 16px;
|
||||
font-size: 13px; font-weight: 500; color: #333;
|
||||
}
|
||||
.fl-chip-del {
|
||||
width: 18px; height: 18px; border-radius: 50%; border: none;
|
||||
background: rgba(0,0,0,.12); color: #555; font-size: 11px;
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Location Chip */
|
||||
.fl-loc-chip {
|
||||
padding: 2px 6px; border-radius: 4px; font-size: 11px; font-weight: 700;
|
||||
white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
.fl-loc-chip.normal { background: rgba(33,150,243,.15); color: #2196F3; }
|
||||
.fl-loc-chip.transit { background: rgba(255,152,0,.15); color: #FF9800; }
|
||||
|
||||
/* Submit Button */
|
||||
.fl-submit {
|
||||
width: 100%; height: 46px; border-radius: 8px; border: none;
|
||||
font-size: 17px; font-weight: 500; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin-top: 10px; transition: background .15s;
|
||||
}
|
||||
.fl-submit.disabled { background: #E0E0E0; color: #757575; cursor: default; }
|
||||
.fl-submit.primary { background: #1976D2; color: #fff; }
|
||||
.fl-submit.transit { background: #FF9800; color: #fff; }
|
||||
.fl-submit.submitting { cursor: wait; }
|
||||
|
||||
/* Spinner */
|
||||
.fl-spinner {
|
||||
width: 22px; height: 22px; border: 2.5px solid rgba(255,255,255,.3);
|
||||
border-top-color: #fff; border-radius: 50%;
|
||||
animation: fl-spin .8s linear infinite;
|
||||
}
|
||||
.fl-spinner.small {
|
||||
width: 16px; height: 16px; border-width: 2px;
|
||||
border-color: rgba(0,0,0,.08); border-top-color: #666;
|
||||
}
|
||||
@keyframes fl-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Divider */
|
||||
.fl-divider { height: 1px; background: #E0E0E0; }
|
||||
|
||||
/* Overview Section */
|
||||
.fl-overview { flex: 1; padding: 8px 12px; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.fl-overview-header-row {
|
||||
display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
|
||||
}
|
||||
.fl-overview-title { font-size: 14px; font-weight: 700; color: #333; flex: 1; }
|
||||
.fl-overview-body { flex: 1; overflow-y: auto; }
|
||||
|
||||
.fl-overview-msg {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100%; text-align: center; font-size: 13px; color: #757575;
|
||||
}
|
||||
.fl-overview-error {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
height: 100%; gap: 6px;
|
||||
}
|
||||
.fl-overview-error .msg { font-size: 13px; color: #F44336; text-align: center; }
|
||||
.fl-retry-btn {
|
||||
padding: 4px 12px; font-size: 13px; border: 1px solid #ccc; border-radius: 6px;
|
||||
background: #fff; cursor: pointer; color: #1976D2;
|
||||
}
|
||||
|
||||
/* Overview table */
|
||||
.fl-ov-info { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 6px; }
|
||||
.fl-ov-error-bar {
|
||||
padding-bottom: 6px; display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.fl-ov-error-bar .msg { flex: 1; font-size: 12px; color: #F44336; }
|
||||
.fl-table-head {
|
||||
height: 32px; padding: 0 6px; background: #EEEEEE;
|
||||
border: 1px solid #BDBDBD; display: flex; align-items: center;
|
||||
}
|
||||
.fl-table-head span { font-size: 12px; font-weight: 700; color: #333; }
|
||||
.fl-table-head .c1 { flex: 20; }
|
||||
.fl-table-head .c2 { flex: 20; }
|
||||
.fl-table-head .c3 { flex: 14; text-align: center; }
|
||||
.fl-table-head .c4 { flex: 26; text-align: right; }
|
||||
|
||||
.fl-table-row {
|
||||
min-height: 34px; padding: 6px; display: flex; align-items: center;
|
||||
border-left: 1px solid #E0E0E0; border-right: 1px solid #E0E0E0;
|
||||
border-bottom: 1px solid #E0E0E0;
|
||||
}
|
||||
.fl-table-row.current {
|
||||
border-left-color: #4CAF50; border-left-width: 2px;
|
||||
border-right-color: #4CAF50; border-right-width: 2px;
|
||||
border-bottom-color: #4CAF50; border-bottom-width: 2px;
|
||||
}
|
||||
.fl-table-row.bg-onshelf { background: #E3F2FD; }
|
||||
.fl-table-row.bg-transferred { background: #FFF3E0; }
|
||||
.fl-table-row.bg-default { background: #F5F5F5; }
|
||||
.fl-table-row.bg-white { background: #fff; }
|
||||
.fl-table-row span {
|
||||
font-size: 12px; color: rgba(0,0,0,.87); overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.fl-table-row span.bold { font-weight: 700; }
|
||||
.fl-table-row .c1 { flex: 20; }
|
||||
.fl-table-row .c2 { flex: 20; }
|
||||
.fl-table-row .c3 { flex: 14; text-align: center; }
|
||||
.fl-table-row .c4 { flex: 26; text-align: right; }
|
||||
|
||||
/* Status Bar */
|
||||
.fl-statusbar {
|
||||
height: 28px; min-height: 28px; padding: 0 12px;
|
||||
background: #F5F5F5; display: flex; align-items: center; gap: 7px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
.fl-status-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.fl-status-dot.blue { background: #2196F3; }
|
||||
.fl-status-dot.orange { background: #FF9800; }
|
||||
.fl-status-dot.green { background: #4CAF50; }
|
||||
.fl-status-dot.red { background: #F44336; }
|
||||
.fl-status-dot.yellow { background: #FBC02D; }
|
||||
.fl-status-dot.amber { background: #FFC107; }
|
||||
.fl-status-text { font-size: 11px; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
|
||||
/* Dialog overlay */
|
||||
.fl-dialog-overlay {
|
||||
position: absolute; inset: 0; background: rgba(0,0,0,.4);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||
}
|
||||
.fl-dialog-overlay.hidden { display: none; }
|
||||
.fl-dialog {
|
||||
background: #FFEBEE; border-radius: 12px; padding: 20px;
|
||||
width: 320px; box-shadow: 0 8px 30px rgba(0,0,0,.25);
|
||||
}
|
||||
.fl-dialog-title {
|
||||
display: flex; align-items: center; gap: 8px; margin-bottom: 14px;
|
||||
}
|
||||
.fl-dialog-title .icon { color: #F44336; font-size: 22px; }
|
||||
.fl-dialog-title .text { font-size: 18px; font-weight: 600; color: #F44336; }
|
||||
.fl-dialog-body { font-size: 14px; color: #333; line-height: 1.7; }
|
||||
.fl-dialog-body strong { font-weight: 700; }
|
||||
.fl-dialog-actions { margin-top: 16px; text-align: right; }
|
||||
.fl-dialog-close {
|
||||
padding: 6px 16px; font-size: 14px; border: none; border-radius: 6px;
|
||||
background: transparent; color: #1976D2; cursor: pointer; font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ==================== Sidebar Control Panel ==================== -->
|
||||
<div class="sidebar">
|
||||
|
||||
<div style="font-size:16px;font-weight:700;color:#1565C0;text-align:center;padding-bottom:10px;border-bottom:2px solid #1565C0;">
|
||||
上架登记 - UI 状态控制面板
|
||||
</div>
|
||||
|
||||
<!-- Lock Mode -->
|
||||
<div class="group">
|
||||
<div class="group-title">1. 锁定模式</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setLock(false)">未锁定</button>
|
||||
<button class="btn" onclick="setLock(true)">已锁定</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Type -->
|
||||
<div class="group">
|
||||
<div class="group-title">2. 货位类型</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setLocation(null)">无</button>
|
||||
<button class="btn" onclick="setLocation('normal')">普通货架 A-01-03</button>
|
||||
<button class="btn" onclick="setLocation('transit')">转运区域 TRANS-001</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zongpai Count -->
|
||||
<div class="group">
|
||||
<div class="group-title">3. 总排号数量</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setZongpai(0)">无</button>
|
||||
<button class="btn" onclick="setZongpai(1)">1 条</button>
|
||||
<button class="btn" onclick="setZongpai(3)">3 条</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submitting -->
|
||||
<div class="group">
|
||||
<div class="group-title">4. 提交中状态</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setSubmitting(false)">空闲</button>
|
||||
<button class="btn" onclick="setSubmitting(true)">提交中 (Loading)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overview State -->
|
||||
<div class="group">
|
||||
<div class="group-title">5. 排产总览区域</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setOverview('empty')">未扫描</button>
|
||||
<button class="btn" onclick="setOverview('loading')">加载中</button>
|
||||
<button class="btn" onclick="setOverview('notFound')">暂无排产信息</button>
|
||||
<button class="btn" onclick="setOverview('error')">加载失败</button>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn" onclick="setOverview('loaded')">正常显示</button>
|
||||
<button class="btn" onclick="setOverview('errorWithData')">失败(有缓存)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Bar Override -->
|
||||
<div class="group">
|
||||
<div class="group-title">6. 状态栏覆盖状态</div>
|
||||
<div class="label">点击后状态栏显示对应覆盖文本(持续显示至切换)</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setStatusBarOverride(null)">自动 (跟随基础状态)</button>
|
||||
</div>
|
||||
<div class="label">-- 扫描反馈 --</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn" onclick="setStatusBarOverride('invalid')">无效码</button>
|
||||
</div>
|
||||
<div class="label">-- 配置/操作错误 --</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn" onclick="setStatusBarOverride('noApi')">未配置API</button>
|
||||
<button class="btn" onclick="setStatusBarOverride('noLocation')">请先扫描货位号</button>
|
||||
<button class="btn" onclick="setStatusBarOverride('paichaMismatch')">排产号不一致</button>
|
||||
</div>
|
||||
<div class="label">-- 提交成功 --</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn" onclick="setStatusBarOverride('success')">上架成功</button>
|
||||
<button class="btn" onclick="setStatusBarOverride('successLocked')">锁定成功</button>
|
||||
<button class="btn" onclick="setStatusBarOverride('offShelf')">下架成功</button>
|
||||
<button class="btn" onclick="setStatusBarOverride('batchSuccess')">批量上架成功</button>
|
||||
<button class="btn" onclick="setStatusBarOverride('batchOffShelf')">批量下架成功</button>
|
||||
</div>
|
||||
<div class="label">-- 提交失败 --</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn" onclick="setStatusBarOverride('duplicate')">重复上架(弹窗)</button>
|
||||
<button class="btn" onclick="setStatusBarOverride('alreadyOffShelf')">已下架冲突</button>
|
||||
<button class="btn" onclick="setStatusBarOverride('networkError')">网络异常</button>
|
||||
<button class="btn" onclick="setStatusBarOverride('submitFail')">提交失败</button>
|
||||
<button class="btn" onclick="setStatusBarOverride('batchPartial')">批量部分失败</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog -->
|
||||
<div class="group">
|
||||
<div class="group-title">7. 弹窗</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn active" onclick="setDialog(false)">关闭</button>
|
||||
<button class="btn" onclick="setDialog(true)">重复上架弹窗</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- State Summary -->
|
||||
<div class="group" style="background:#eef6ff;">
|
||||
<div class="group-title" style="color:#1565C0;">当前状态摘要</div>
|
||||
<div id="stateSummary" style="font-size:11px;color:#555;line-height:1.8;"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ==================== Device Simulation ==================== -->
|
||||
<div class="main">
|
||||
<div class="device" id="device">
|
||||
|
||||
<!-- AppBar -->
|
||||
<div class="fl-appbar">
|
||||
<span class="fl-appbar-title">上架登记</span>
|
||||
<span class="fl-appbar-spacer"></span>
|
||||
<button class="fl-lock-btn unlocked" id="lockBtn">
|
||||
<span class="icon">⇄</span>
|
||||
<span id="lockLabel">未锁定</span>
|
||||
<span class="shortcut">P2</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="fl-body">
|
||||
|
||||
<!-- Operation Area -->
|
||||
<div class="fl-op-area">
|
||||
<div class="fl-section-title">登记操作区</div>
|
||||
|
||||
<!-- Input Panel: Single Mode -->
|
||||
<div class="fl-input-single empty" id="singlePanel">
|
||||
<div class="loc-part">
|
||||
<span class="loc-text placeholder" id="singleLoc"></span>
|
||||
<span id="singleLocChip"></span>
|
||||
</div>
|
||||
<span class="divider">|</span>
|
||||
<div class="zp-part">
|
||||
<span class="zp-text placeholder" id="singleZp"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Panel: Locked Mode -->
|
||||
<div class="fl-input-locked empty" id="lockedPanel" style="display:none;">
|
||||
<div class="loc-row">
|
||||
<span class="loc-text" id="lockedLoc"></span>
|
||||
<span class="fl-lock-icon">🔒</span>
|
||||
<span id="lockedLocChip"></span>
|
||||
</div>
|
||||
<div class="fl-chips-area empty-space" id="chipArea"></div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button class="fl-submit disabled" id="submitBtn">
|
||||
<span id="submitText">确 认 上 架</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="fl-divider"></div>
|
||||
|
||||
<!-- Overview Section -->
|
||||
<div class="fl-overview">
|
||||
<div class="fl-overview-header-row">
|
||||
<span class="fl-overview-title">排产号货架总览</span>
|
||||
<span id="overviewSpinner" style="display:none;"><span class="fl-spinner small"></span></span>
|
||||
</div>
|
||||
<div class="fl-overview-body" id="overviewBody">
|
||||
<div class="fl-overview-msg">扫描总排号后自动显示排产号上架情况</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<div class="fl-statusbar">
|
||||
<span class="fl-status-dot blue" id="statusDot"></span>
|
||||
<span class="fl-status-text" id="statusText">等待扫描总排号或货位号…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog Overlay -->
|
||||
<div class="fl-dialog-overlay hidden" id="dialogOverlay">
|
||||
<div class="fl-dialog">
|
||||
<div class="fl-dialog-title">
|
||||
<span class="icon">⚠</span>
|
||||
<span class="text">重复上架</span>
|
||||
</div>
|
||||
<div class="fl-dialog-body">
|
||||
<div>总排号:<strong>26B35</strong></div>
|
||||
<div style="margin-top:4px;">已登记货位:<strong>A-02-05</strong></div>
|
||||
<div style="margin-top:4px;">登记时间:<strong>2026-05-19 14:30:22</strong></div>
|
||||
<div style="margin-top:12px;"><strong>请核查实物,确认是否操作错误。</strong></div>
|
||||
</div>
|
||||
<div class="fl-dialog-actions">
|
||||
<button class="fl-dialog-close">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ===================== State =====================
|
||||
const state = {
|
||||
locked: false,
|
||||
locationType: null, // null | 'normal' | 'transit'
|
||||
zongpaiCount: 0, // 0, 1, 3
|
||||
submitting: false,
|
||||
overviewState: 'empty',
|
||||
statusOverride: null,
|
||||
showDialog: false,
|
||||
};
|
||||
|
||||
const sampleData = {
|
||||
paichaNo: 'R00015',
|
||||
totalCount: 5,
|
||||
items: [
|
||||
{ zongpaiNo: '26B35', workOrderNo: '3-1', quantity: 50, locationCode: 'A-01-03', status: 'on_shelf' },
|
||||
{ zongpaiNo: '26B36', workOrderNo: '3-2-1', quantity: 100, locationCode: 'A-01-05', status: 'on_shelf' },
|
||||
{ zongpaiNo: '26B37', workOrderNo: '3-2-2', quantity: 100, locationCode: null, status: 'not_shelved' },
|
||||
{ zongpaiNo: '26B38', workOrderNo: '3-2-3', quantity: 50, locationCode: 'TRANS-001', status: 'transferred' },
|
||||
{ zongpaiNo: '26B39', workOrderNo: '3-3', quantity: 45, locationCode: null, status: 'not_shelved' },
|
||||
]
|
||||
};
|
||||
|
||||
const zongpaiSamples = ['26B35', '26B37', '26B39'];
|
||||
|
||||
// ===================== State Setters =====================
|
||||
function setLock(v) {
|
||||
state.locked = v;
|
||||
updateGroup('setLock', v);
|
||||
updateUI();
|
||||
}
|
||||
function setLocation(v) {
|
||||
state.locationType = v;
|
||||
updateGroup('setLocation', v);
|
||||
updateUI();
|
||||
}
|
||||
function setZongpai(v) {
|
||||
state.zongpaiCount = v;
|
||||
updateGroup('setZongpai', v);
|
||||
updateUI();
|
||||
}
|
||||
function setSubmitting(v) {
|
||||
state.submitting = v;
|
||||
updateGroup('setSubmitting', v);
|
||||
updateUI();
|
||||
}
|
||||
function setOverview(v) {
|
||||
state.overviewState = v;
|
||||
updateGroup('setOverview', v);
|
||||
updateUI();
|
||||
}
|
||||
|
||||
const statusOverrideMap = {
|
||||
invalid: { text: '无效码:ABCXYZ123', dot: 'red' },
|
||||
noApi: { text: '未配置 API 地址,请前往设置', dot: 'red' },
|
||||
noLocation: { text: '请先扫描货位号', dot: 'red' },
|
||||
paichaMismatch:{ text: '批量凑箱仅支持同排产号产品,请移除不属于同一批次的总排号', dot: 'red' },
|
||||
success: { text: '上架成功', dot: 'green' },
|
||||
successLocked: { text: '货位已锁定,请扫描下一张执行卡', dot: 'green' },
|
||||
offShelf: { text: '下架成功', dot: 'green' },
|
||||
batchSuccess: { text: '批量上架成功(3 条)', dot: 'green' },
|
||||
batchOffShelf: { text: '批量下架成功(3 条)', dot: 'green' },
|
||||
duplicate: { text: '请确认信息并提交', dot: 'red', dialog: true },
|
||||
alreadyOffShelf:{ text: '该总排号已下架至转运区域,不可重新上架', dot: 'red' },
|
||||
networkError: { text: '网络异常,请检查网络连接', dot: 'yellow' },
|
||||
submitFail: { text: '请求参数错误', dot: 'red' },
|
||||
batchPartial: { text: '成功 2 条,失败 1 条', dot: 'red' },
|
||||
};
|
||||
|
||||
function setStatusBarOverride(key) {
|
||||
state.statusOverride = key;
|
||||
updateGroup('setStatusBarOverride', key);
|
||||
// auto show dialog for duplicate
|
||||
if (key === 'duplicate') {
|
||||
state.showDialog = true;
|
||||
updateGroup('setDialog', true);
|
||||
}
|
||||
updateUI();
|
||||
}
|
||||
|
||||
function setDialog(v) {
|
||||
state.showDialog = v;
|
||||
updateGroup('setDialog', v);
|
||||
updateUI();
|
||||
}
|
||||
|
||||
// ===================== Button Group Helper =====================
|
||||
function updateGroup(fnName, activeValue) {
|
||||
// Find all buttons in the same group by looking at the onclick pattern
|
||||
const allBtns = document.querySelectorAll('.btn');
|
||||
allBtns.forEach(btn => {
|
||||
const onclick = btn.getAttribute('onclick') || '';
|
||||
if (onclick.includes(fnName + '(')) {
|
||||
// Extract the argument
|
||||
const match = onclick.match(fnName + '\\(([^)]+)\\)');
|
||||
if (match) {
|
||||
let arg = match[1].trim().replace(/'/g, '').replace(/"/g, '');
|
||||
let isActive = false;
|
||||
if (arg === String(activeValue) ||
|
||||
(activeValue === null && arg === 'null') ||
|
||||
(activeValue === true && arg === 'true') ||
|
||||
(activeValue === false && arg === 'false')) {
|
||||
isActive = true;
|
||||
}
|
||||
if (isActive) {
|
||||
btn.classList.add('active');
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===================== UI Update =====================
|
||||
function updateUI() {
|
||||
updateAppBar();
|
||||
updateInputPanel();
|
||||
updateSubmitButton();
|
||||
updateOverview();
|
||||
updateStatusBar();
|
||||
updateDialogVisibility();
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
function updateAppBar() {
|
||||
const btn = document.getElementById('lockBtn');
|
||||
const label = document.getElementById('lockLabel');
|
||||
if (state.locked) {
|
||||
btn.className = 'fl-lock-btn locked';
|
||||
label.textContent = '货位锁定';
|
||||
} else {
|
||||
btn.className = 'fl-lock-btn unlocked';
|
||||
label.textContent = '未锁定';
|
||||
}
|
||||
}
|
||||
|
||||
function updateInputPanel() {
|
||||
const singlePanel = document.getElementById('singlePanel');
|
||||
const lockedPanel = document.getElementById('lockedPanel');
|
||||
const hasLoc = state.locationType !== null;
|
||||
const hasZp = state.zongpaiCount > 0;
|
||||
const locCode = state.locationType === 'normal' ? 'A-01-03' : state.locationType === 'transit' ? 'TRANS-001' : null;
|
||||
const locFilled = hasLoc || hasZp;
|
||||
|
||||
if (!state.locked) {
|
||||
// Single mode
|
||||
singlePanel.style.display = 'flex';
|
||||
lockedPanel.style.display = 'none';
|
||||
|
||||
singlePanel.className = 'fl-input-single ' + (locFilled ? 'filled' : 'empty');
|
||||
|
||||
const singleLoc = document.getElementById('singleLoc');
|
||||
const singleLocChip = document.getElementById('singleLocChip');
|
||||
const singleZp = document.getElementById('singleZp');
|
||||
|
||||
if (hasLoc) {
|
||||
singleLoc.textContent = locCode;
|
||||
singleLoc.className = 'loc-text';
|
||||
singleLocChip.innerHTML = buildLocChip(state.locationType);
|
||||
} else {
|
||||
singleLoc.textContent = '';
|
||||
singleLoc.className = 'loc-text placeholder';
|
||||
singleLocChip.innerHTML = '';
|
||||
}
|
||||
|
||||
if (hasZp) {
|
||||
singleZp.textContent = zongpaiSamples[0];
|
||||
singleZp.className = 'zp-text';
|
||||
} else {
|
||||
singleZp.textContent = '';
|
||||
singleZp.className = 'zp-text placeholder';
|
||||
}
|
||||
} else {
|
||||
// Locked mode
|
||||
singlePanel.style.display = 'none';
|
||||
lockedPanel.style.display = 'block';
|
||||
|
||||
lockedPanel.className = 'fl-input-locked ' + (locFilled ? 'filled' : 'empty');
|
||||
|
||||
const lockedLoc = document.getElementById('lockedLoc');
|
||||
const lockedLocChip = document.getElementById('lockedLocChip');
|
||||
const chipArea = document.getElementById('chipArea');
|
||||
|
||||
if (hasLoc) {
|
||||
lockedLoc.textContent = locCode;
|
||||
lockedLocChip.innerHTML = buildLocChip(state.locationType);
|
||||
} else {
|
||||
lockedLoc.textContent = '';
|
||||
lockedLocChip.innerHTML = '';
|
||||
}
|
||||
|
||||
if (state.zongpaiCount === 0) {
|
||||
chipArea.className = 'fl-chips-area empty-space';
|
||||
chipArea.innerHTML = '';
|
||||
} else {
|
||||
chipArea.className = 'fl-chips-area';
|
||||
let html = '';
|
||||
for (let i = 0; i < state.zongpaiCount; i++) {
|
||||
html += `<span class="fl-chip">${zongpaiSamples[i]}<span class="fl-chip-del">x</span></span>`;
|
||||
}
|
||||
chipArea.innerHTML = html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildLocChip(type) {
|
||||
if (!type) return '';
|
||||
const cls = type === 'transit' ? 'transit' : 'normal';
|
||||
const label = type === 'transit' ? '转运区域' : '普通货架';
|
||||
return `<span class="fl-loc-chip ${cls}">${label}</span>`;
|
||||
}
|
||||
|
||||
function updateSubmitButton() {
|
||||
const btn = document.getElementById('submitBtn');
|
||||
const textEl = document.getElementById('submitText');
|
||||
const hasLoc = state.locationType !== null;
|
||||
const hasZp = state.zongpaiCount > 0;
|
||||
const isTransit = state.locationType === 'transit';
|
||||
const canSubmit = hasLoc && hasZp && !state.submitting;
|
||||
|
||||
// Determine label
|
||||
let label;
|
||||
if (state.submitting) {
|
||||
label = null; // show spinner
|
||||
} else if (!canSubmit) {
|
||||
if (isTransit) {
|
||||
label = state.locked && state.zongpaiCount > 1
|
||||
? `批量转运并凑箱(${state.zongpaiCount} 条)`
|
||||
: '转运并装箱';
|
||||
} else {
|
||||
label = state.locked && state.zongpaiCount > 1
|
||||
? `批量上架(${state.zongpaiCount} 条)`
|
||||
: '确 认 上 架';
|
||||
}
|
||||
} else {
|
||||
if (isTransit) {
|
||||
label = state.locked && state.zongpaiCount > 1
|
||||
? `批量转运并凑箱(${state.zongpaiCount} 条)`
|
||||
: '转运并装箱';
|
||||
} else {
|
||||
label = state.locked && state.zongpaiCount > 1
|
||||
? `批量上架(${state.zongpaiCount} 条)`
|
||||
: '确 认 上 架';
|
||||
}
|
||||
}
|
||||
|
||||
// Determine style
|
||||
let cls = 'fl-submit';
|
||||
if (state.submitting) {
|
||||
cls += isTransit ? ' transit submitting' : (canSubmit || (hasLoc && hasZp) ? ' primary submitting' : ' disabled submitting');
|
||||
} else if (!canSubmit) {
|
||||
cls += ' disabled';
|
||||
} else if (isTransit) {
|
||||
cls += ' transit';
|
||||
} else {
|
||||
cls += ' primary';
|
||||
}
|
||||
|
||||
btn.className = cls;
|
||||
|
||||
if (state.submitting) {
|
||||
textEl.innerHTML = '<span class="fl-spinner"></span>';
|
||||
} else {
|
||||
textEl.textContent = label;
|
||||
}
|
||||
}
|
||||
|
||||
function updateOverview() {
|
||||
const body = document.getElementById('overviewBody');
|
||||
const spinner = document.getElementById('overviewSpinner');
|
||||
|
||||
switch (state.overviewState) {
|
||||
case 'empty':
|
||||
spinner.style.display = 'none';
|
||||
body.innerHTML = '<div class="fl-overview-msg">扫描总排号后自动显示排产号上架情况</div>';
|
||||
break;
|
||||
case 'loading':
|
||||
spinner.style.display = 'inline-block';
|
||||
body.innerHTML = '<div class="fl-overview-msg">正在加载排产号上架情况…</div>';
|
||||
break;
|
||||
case 'notFound':
|
||||
spinner.style.display = 'none';
|
||||
body.innerHTML = '<div class="fl-overview-msg">暂无排产信息</div>';
|
||||
break;
|
||||
case 'error':
|
||||
spinner.style.display = 'none';
|
||||
body.innerHTML = '<div class="fl-overview-error"><span class="msg">加载失败,点击重试</span><button class="fl-retry-btn">重试</button></div>';
|
||||
break;
|
||||
case 'loaded':
|
||||
spinner.style.display = 'none';
|
||||
renderOverviewTable(body, false);
|
||||
break;
|
||||
case 'errorWithData':
|
||||
spinner.style.display = 'none';
|
||||
renderOverviewTable(body, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function renderOverviewTable(container, withError) {
|
||||
let html = '';
|
||||
if (withError) {
|
||||
html += `<div class="fl-ov-error-bar">
|
||||
<span class="msg">加载失败,点击重试</span>
|
||||
<button class="fl-retry-btn">重试</button>
|
||||
</div>`;
|
||||
}
|
||||
html += `<div class="fl-ov-info">${sampleData.paichaNo} 共 ${sampleData.totalCount} 个总排号</div>`;
|
||||
html += `<div class="fl-table-head">
|
||||
<span class="c1">总排号</span>
|
||||
<span class="c2">工令号</span>
|
||||
<span class="c3">数量</span>
|
||||
<span class="c4">货位号</span>
|
||||
</div>`;
|
||||
|
||||
sampleData.items.forEach((item, idx) => {
|
||||
const isCurrent = idx === 0; // first item is current
|
||||
let bgCls = 'bg-white';
|
||||
if (item.status === 'on_shelf') bgCls = 'bg-onshelf';
|
||||
else if (item.status === 'transferred') bgCls = 'bg-transferred';
|
||||
else bgCls = 'bg-default';
|
||||
|
||||
const rowCls = `fl-table-row ${bgCls}` + (isCurrent ? ' current' : '');
|
||||
const boldCls = isCurrent ? ' bold' : '';
|
||||
html += `<div class="${rowCls}">
|
||||
<span class="c1${boldCls}">${item.zongpaiNo}</span>
|
||||
<span class="c2${boldCls}">${item.workOrderNo || '--'}</span>
|
||||
<span class="c3${boldCls}">${item.quantity}</span>
|
||||
<span class="c4${boldCls}">${item.locationCode || '\u2014'}</span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function updateStatusBar() {
|
||||
const dot = document.getElementById('statusDot');
|
||||
const text = document.getElementById('statusText');
|
||||
|
||||
// Check override first
|
||||
if (state.statusOverride && statusOverrideMap[state.statusOverride]) {
|
||||
const ov = statusOverrideMap[state.statusOverride];
|
||||
dot.className = 'fl-status-dot ' + ov.dot;
|
||||
text.textContent = ov.text;
|
||||
return;
|
||||
}
|
||||
|
||||
// Base status (mirrors _updateBaseStatus)
|
||||
if (state.submitting) {
|
||||
dot.className = 'fl-status-dot orange';
|
||||
text.textContent = '正在提交…';
|
||||
return;
|
||||
}
|
||||
if (state.locked && state.locationType !== null && state.zongpaiCount === 0) {
|
||||
dot.className = 'fl-status-dot blue';
|
||||
text.textContent = '货位已锁定,请扫描下一张执行卡';
|
||||
return;
|
||||
}
|
||||
const hasZp = state.zongpaiCount > 0;
|
||||
const hasLoc = state.locationType !== null;
|
||||
if (hasZp && hasLoc) {
|
||||
dot.className = 'fl-status-dot blue';
|
||||
text.textContent = '请确认信息并提交';
|
||||
} else if (hasZp) {
|
||||
dot.className = 'fl-status-dot blue';
|
||||
text.textContent = '请扫描目标货位号';
|
||||
} else if (hasLoc) {
|
||||
dot.className = 'fl-status-dot blue';
|
||||
text.textContent = '请扫描执行卡';
|
||||
} else {
|
||||
dot.className = 'fl-status-dot blue';
|
||||
text.textContent = '等待扫描总排号或货位号…';
|
||||
}
|
||||
}
|
||||
|
||||
function updateDialogVisibility() {
|
||||
const overlay = document.getElementById('dialogOverlay');
|
||||
if (state.showDialog) {
|
||||
overlay.classList.remove('hidden');
|
||||
} else {
|
||||
overlay.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function updateSummary() {
|
||||
const el = document.getElementById('stateSummary');
|
||||
const locLabel = state.locationType === 'normal' ? '普通货架' : state.locationType === 'transit' ? '转运区域' : '无';
|
||||
const ovLabels = {
|
||||
empty: '未扫描', loading: '加载中', notFound: '暂无信息',
|
||||
error: '加载失败', loaded: '正常显示', errorWithData: '失败(有缓存)'
|
||||
};
|
||||
const hasLoc = state.locationType !== null;
|
||||
const hasZp = state.zongpaiCount > 0;
|
||||
const canSubmit = hasLoc && hasZp && !state.submitting;
|
||||
|
||||
el.innerHTML = `
|
||||
<div><b>锁定:</b>${state.locked ? '已锁定' : '未锁定'}</div>
|
||||
<div><b>货位:</b>${locLabel}</div>
|
||||
<div><b>总排号:</b>${state.zongpaiCount === 0 ? '无' : state.zongpaiCount + ' 条'}</div>
|
||||
<div><b>提交中:</b>${state.submitting ? '是' : '否'}</div>
|
||||
<div><b>可提交:</b>${canSubmit ? '是' : '否'}</div>
|
||||
<div><b>总览:</b>${ovLabels[state.overviewState]}</div>
|
||||
<div><b>状态覆盖:</b>${state.statusOverride || '(自动)'}</div>
|
||||
<div><b>弹窗:</b>${state.showDialog ? '显示中' : '无'}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Close dialog via button
|
||||
document.querySelector('.fl-dialog-close').addEventListener('click', () => {
|
||||
setDialog(false);
|
||||
});
|
||||
|
||||
// Initial render
|
||||
updateUI();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
2104
docs/single-code-boxing-ui-states.html
Normal file
1506
docs/single-code-boxing-ui-v2.html
Normal file
279
docs/单码装箱UI重构PRD.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# 单码装箱页面 UI 重构 PRD
|
||||
|
||||
**文档版本**:v2.0
|
||||
**更新日期**:2026-05-18
|
||||
**涉及页面**:`lib/pages/boxing_page.dart`(BoxingMode.singleCode)
|
||||
**参考设计**:多码凑箱 UI v2(`multi-code-boxing-ui-v2.html`)
|
||||
**产出物**:`single-code-boxing-ui-v2.html`
|
||||
|
||||
---
|
||||
|
||||
## 一、背景与目标
|
||||
|
||||
原单码装箱页面各区块自然堆叠,当已分配记录增多或通知条出现时,下方输入区会被挤出屏幕可视范围,操作体验差。同时,原版与多码凑箱在视觉语言上存在差异,两种模式切换后用户需要重新适应布局。
|
||||
|
||||
**重构目标:**
|
||||
|
||||
1. 与多码凑箱采用统一的设计语言,降低双模式切换的认知成本
|
||||
2. 采用固定顶部 + 可滚动中间 + 固定底栏结构,无论已分配记录多少,输入区始终可见
|
||||
3. 在不损失信息量的前提下,压缩各区块高度,让屏幕展示更多有效内容
|
||||
4. 去掉返回按钮,简化底栏操作区
|
||||
|
||||
---
|
||||
|
||||
## 二、整体布局结构
|
||||
|
||||
### v1(原版)布局
|
||||
|
||||
```
|
||||
AppBar(~52px)
|
||||
扫码卡片(~52px)
|
||||
信息区 - 排产号+工单+件数(~30px)
|
||||
信息区 - 已有箱数+最大箱号+详情(~28px)
|
||||
分隔线
|
||||
已分配列表(自然高度,不可滚动)
|
||||
输入区 - 箱号+数量+确认(~48px)
|
||||
操作行 - 返回按钮(~48px)
|
||||
状态栏(~38px)
|
||||
```
|
||||
|
||||
> 问题:各区全部占据固定高度,列表一旦有 3 条以上记录,输入区就会被推出屏幕,需要手动滚动才能操作。
|
||||
|
||||
### v2(重构后)布局
|
||||
|
||||
```
|
||||
AppBar(44px) ← 固定顶部
|
||||
[通知条 × N](每条 ~30px) ← 可选,固定
|
||||
扫码条(38px) ← 固定
|
||||
排产信息栏(~52px) ← 固定
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
已分配列表(弹性高度) ← 可滚动区域
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
输入行(~50px) ← 固定底栏
|
||||
状态栏(28px) ← 固定底栏
|
||||
```
|
||||
|
||||
输入行与状态栏固定于底部,任何情况下都不会因列表增长而被遮挡。
|
||||
|
||||
---
|
||||
|
||||
## 三、各区块改动详情
|
||||
|
||||
### 3.1 AppBar
|
||||
|
||||
| 项目 | v1 | v2 |
|
||||
|---|---|---|
|
||||
| 高度 | ~52px | **44px** |
|
||||
| 标题 | `装箱编号`,大号字体 | `装箱编号`,15px/700 |
|
||||
| 模式标识 | 独占一行的大模式按钮区 | 右侧蓝色 Pill `⇄ 单码装箱 P2`,与标题同行 |
|
||||
| 模式颜色 | 蓝色 | **蓝色**(单码),橙色(多码),与多码对称 |
|
||||
|
||||
Pill 点击或按 P2 物理键切换模式,行为不变。
|
||||
|
||||
---
|
||||
|
||||
### 3.2 通知条(Notice Banner)
|
||||
|
||||
替代原版内嵌式 `banner` 组件,统一为横条形通知,可多条叠加显示。
|
||||
|
||||
| 颜色 | 触发场景 |
|
||||
|---|---|
|
||||
| 🟠 橙色 | 正在同步转运数据 |
|
||||
| 🟡 琥珀色 | 箱号重复警告 / 忽略无效码 |
|
||||
| 🔵 蓝色 | 排产号切换提示 |
|
||||
| 🟢 绿色 | 全部装箱完毕提示 |
|
||||
| 🔴 红色 | 数量超限 / 提交失败 / 严重错误 |
|
||||
|
||||
每条高度约 **30px**(含 6px 上下 padding),字号 12px,图标 + 文字,`border-bottom: 1px` 与下方区域隔开。
|
||||
|
||||
---
|
||||
|
||||
### 3.3 扫码条
|
||||
|
||||
| 项目 | v1 | v2 |
|
||||
|---|---|---|
|
||||
| 高度 | ~52px 卡片 | **38px** 横条 |
|
||||
| 等待态 | 灰色圆角卡片,居中文字 | 灰底横条,📷 图标 + 文字 |
|
||||
| 已识别态 | 绿色边框卡片 + 大号总排号 | 绿底横条,✓ 绿圆 + 总排号 + `已识别` 徽章 |
|
||||
|
||||
---
|
||||
|
||||
### 3.4 排产信息栏(info-bar)
|
||||
|
||||
原版将排产号/工单/件数与已有箱数分为两个分散区块,共约 80px。v2 合并为一个紧凑的 header-bar,高度约 **52px**。左侧包含三层信息,右侧固定放置醒目的详情按钮:
|
||||
|
||||
```
|
||||
左侧第一层:[排产号] [工单号] [ERP总件数] 右侧:[📋 详情]
|
||||
左侧第二层:[进度条 ████████░░░░░░░░] ← 新增
|
||||
左侧第三层:[已有X箱 · 最大箱号Y · 已装A/B]
|
||||
```
|
||||
|
||||
**新增:装箱进度条**
|
||||
|
||||
- 高度 3px,不单独占行,嵌在第一层与第三层之间
|
||||
- 进度 = 已装件数 / ERP 总件数
|
||||
- 未完成时为蓝色(`#1565c0`),全部装完后变绿色(`#2e7d32`)
|
||||
|
||||
**详情入口**
|
||||
|
||||
- 放在排产信息栏最右侧,与三层信息垂直居中
|
||||
- 使用图标 + 文案的描边按钮样式,正常态为蓝色浅底,空状态置灰不可点击
|
||||
|
||||
**空状态**(未扫码)时,所有字段显示为灰色占位符 `—`,详情按钮置灰不可点击。
|
||||
|
||||
---
|
||||
|
||||
### 3.5 已分配列表
|
||||
|
||||
原版已分配记录直接堆叠在页面中间,无法滚动。v2 改为**弹性高度可滚动区域**,与多码凑箱的「已装入本箱」列表结构完全一致。
|
||||
|
||||
**列表头(sticky,滚动时吸顶):**
|
||||
|
||||
```
|
||||
已分配(此总排号) 2 条
|
||||
```
|
||||
|
||||
**每行结构(高度 40px):**
|
||||
|
||||
```
|
||||
[序号] [彩色竖条] [箱号 / 来源标注] [件数] [✏️] [🗑]
|
||||
```
|
||||
|
||||
| 元素 | 说明 |
|
||||
|---|---|
|
||||
| 序号 | 10px 灰色,右对齐 |
|
||||
| 彩色竖条 | 宽 3px,高 22px;绿=正常,琥珀=编辑中,红=待删除 |
|
||||
| 箱号 | 13px/700,如「3 号箱」 |
|
||||
| 来源标注 | 10px 灰色,如「本次扫码」 |
|
||||
| 件数 | 13px/600,右对齐 |
|
||||
| 操作按钮 | ✏️ 编辑 / 🗑 删除,各 28×28px |
|
||||
|
||||
**空状态**文案:`扫码后显示已分配箱号记录` / `本次扫码尚未分配箱号`
|
||||
|
||||
**全部装箱完毕**时,顶部显示绿色通知 `已全部装箱完毕,无需操作`,进度条变为绿色满格,输入区禁用;已分配列表继续显示具体箱号和件数明细,避免用户只能看到完成提示却无法核对装箱结果。
|
||||
|
||||
#### 行内编辑态
|
||||
|
||||
点击 ✏️ 后该行变为编辑态(琥珀色背景 `#fff8e1`),件数文字变为输入框,操作按钮变为 ✓ 保存 / ✕ 取消。其他行保持正常可交互。
|
||||
|
||||
#### 删除确认态
|
||||
|
||||
点击 🗑 后该行背景变红(`#ffebee`),行下方展开一条 28px 确认条:
|
||||
|
||||
```
|
||||
确认删除 X 号箱记录? [删除] [取消]
|
||||
```
|
||||
|
||||
不弹出 Dialog,行内展开,减少视觉干扰。
|
||||
|
||||
---
|
||||
|
||||
### 3.6 固定底栏(输入区)
|
||||
|
||||
原版输入区含三个独立行:箱号+数量+确认(~48px)、返回按钮(~48px)。
|
||||
|
||||
**v2 改动:**
|
||||
|
||||
- 箱号、数量、确认按钮**合并为单行**(高度 ~50px)
|
||||
- **移除返回按钮**
|
||||
- 正在提交时:两个输入框禁用,确认按钮替换为 loading 转圈
|
||||
|
||||
**输入行布局:**
|
||||
|
||||
```
|
||||
[箱号] [ 8 ] [数量] [ 50 ] [确认]
|
||||
```
|
||||
|
||||
| 元素 | 规格 |
|
||||
|---|---|
|
||||
| 标签 | 11px/700,紧贴输入框左侧 |
|
||||
| 箱号输入 | 宽 52px,高 34px,居中,18px/800 |
|
||||
| 数量输入 | 宽 70px,高 34px,居中,16px/800 |
|
||||
| 确认按钮 | 高 34px,蓝底白字,14px/700 |
|
||||
|
||||
**输入状态对应边框色:**
|
||||
|
||||
| 状态 | 箱号输入框边框 | 数量输入框边框 | 确认按钮 |
|
||||
|---|---|---|---|
|
||||
| 正常可提交 | 蓝色 | 蓝色 | 可点击 |
|
||||
| 箱号重复 | **琥珀色** | 正常 | 禁用 |
|
||||
| 数量超限 | 正常 | **红色** | 禁用 |
|
||||
| 提交中 | 禁用灰 | 禁用灰 | loading 转圈 |
|
||||
| 等待扫码 | 禁用灰 | 禁用灰 | 禁用 |
|
||||
|
||||
**边界规则:** 当提交后刚好装完(剩余数量变为 0)时,输入框中残留的上一次提交数量不应触发“数量超限”;此时按“全部装箱完毕”状态处理,确认按钮禁用。
|
||||
|
||||
---
|
||||
|
||||
### 3.7 状态栏
|
||||
|
||||
| 项目 | v1 | v2 |
|
||||
|---|---|---|
|
||||
| 高度 | ~38px | **28px** |
|
||||
| 内容 | 状态圆点 + 文字 | 同,字号压缩至 11px |
|
||||
| 背景 | `#EEEEEE` | `#f5f5f5` |
|
||||
|
||||
**圆点颜色语义(与多码统一):**
|
||||
|
||||
| 颜色 | 状态 |
|
||||
|---|---|
|
||||
| 🔵 蓝 | 等待/空闲/数量已填入 |
|
||||
| 🟠 橙 | 提交中 / 同步中 |
|
||||
| 🟢 绿 | 操作成功 / 全部装完 |
|
||||
| 🔴 红 | 错误(无效码 / 超限 / 提交失败) |
|
||||
| 🟡 琥珀 | 警告(箱号重复 / 编辑中) |
|
||||
|
||||
---
|
||||
|
||||
## 四、场景状态清单
|
||||
|
||||
| # | 场景 | 通知条 | 扫码条 | 列表 | 输入行 | 状态栏点 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | 等待扫码 | 无 | 灰色等待 | 空 | 全禁用 | 🔵 |
|
||||
| 2 | 已扫码(无历史分配) | 无 | 绿色已识别 | 空 | 自动填充,可编辑 | 🔵 |
|
||||
| 3 | 已扫码 + 已有分配记录 | 无 | 绿色已识别 | 显示分配行 | 自动填充下一箱 | 🔵 |
|
||||
| 4 | 已分配行 — 编辑中 | 无 | 绿色已识别 | 目标行琥珀背景 + 输入 | 正常 | 🟡 |
|
||||
| 5 | 已分配行 — 删除确认 | 无 | 绿色已识别 | 目标行红背景 + 确认条 | 正常 | 🔴 |
|
||||
| 6 | 正在提交 | 无 | 绿色已识别 | 正常 | 全禁用 + loading | 🟠 |
|
||||
| 7 | 箱号重复 | 🟡 琥珀警告 | 绿色已识别 | 正常 | 箱号框红框,确认禁用 | 🟡 |
|
||||
| 8 | 数量超限 | 🔴 红色警告 | 绿色已识别 | 正常 | 数量框红框,确认禁用 | 🔴 |
|
||||
| 9 | 全部装箱完毕 | 🟢 绿色提示 | 绿色已识别 | 显示已分配明细 | 全禁用,进度条全绿 | 🟢 |
|
||||
| 10 | 自动同步中 | 🟠 同步 + 🟡 忽略 | 绿色已识别 | 正常,不可操作 | 全禁用 | 🟠 |
|
||||
|
||||
---
|
||||
|
||||
## 五、垂直空间节省汇总
|
||||
|
||||
| 区块 | v1 高度 | v2 高度 | 节省 |
|
||||
|---|---|---|---|
|
||||
| AppBar | ~52px | 44px | **8px** |
|
||||
| 扫码区 | ~52px | 38px | **14px** |
|
||||
| 排产信息 | ~80px(两块) | ~52px(合并)| **28px** |
|
||||
| 已分配行/条 | ~52px/条 | 40px/条 | **12px/条** |
|
||||
| 通知条 | ~36px | ~30px | **6px/条** |
|
||||
| 操作行(返回) | ~48px | 0(已移除)| **48px** |
|
||||
| 状态栏 | ~38px | 28px | **10px** |
|
||||
|
||||
> 固定区域合计节省约 **114px**,相当于屏幕高度约 1/7,可多展示约 **2~3 条**已分配记录。
|
||||
|
||||
---
|
||||
|
||||
## 六、与多码凑箱的设计统一点
|
||||
|
||||
以下组件与多码凑箱在样式、尺寸、行为上完全一致,无差异:
|
||||
|
||||
- AppBar 高度与 Pill 组件(颜色区分模式)
|
||||
- 通知条(Notice Banner)的结构与色彩语义
|
||||
- 已分配/已装入列表的行高、彩色竖条、序号、操作按钮
|
||||
- 行内编辑(输入框 + ✓/✕)与删除确认条
|
||||
- 状态栏高度、圆点颜色语义
|
||||
- 底栏的 loading 转圈形态
|
||||
|
||||
---
|
||||
|
||||
## 七、不在本次范围内的改动
|
||||
|
||||
- 装箱详情页(`boxing_detail_page.dart`)布局不变
|
||||
- 状态机逻辑(`_Phase` 枚举、`_applyAutoFill`、API 调用时机)不变
|
||||
- 物理按键 P2 切换模式行为不变
|
||||
- 所有字段数据来源(`BoxInfoResult`、`saveBoxRecord` 等 API)不变
|
||||
191
docs/多码凑箱UI布局优化PRD.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# PRD · 多码凑箱页面布局优化
|
||||
|
||||
**模块**:装箱编号 > 多码凑箱模式(BoxingMode.multiCode)
|
||||
**文件**:`lib/pages/boxing_page.dart`(主要影响 `_buildManyToOneBody` 及相关子方法)
|
||||
**版本**:v2
|
||||
**状态**:待评审
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
多码凑箱页面在手持 PDA / 手机屏幕(宽度约 375–390px)上使用时,原版布局存在以下问题:
|
||||
|
||||
- **垂直空间浪费**:AppBar、箱号区、排产信息区各自独占一行,三块合计高度约 140px,挤压了中间列表区的可视行数。
|
||||
- **操作区随列表滚动**:扫码区和提交区位于列表下方,装入项目多时需要下拉才能看到操作控件,增加操作摩擦。
|
||||
- **信息层次不清晰**:删除确认使用弹窗,打断了列表的整体感知;通知条与内容区边界不明确。
|
||||
|
||||
**优化目标**:在同一屏内展示更多已装入项目,同时保持操作控件始终可见,整体界面整洁、层次分明。
|
||||
|
||||
---
|
||||
|
||||
## 2. 改动总览
|
||||
|
||||
| 区域 | 原版高度(估算) | v2 高度(估算) | 节省 |
|
||||
|------|--------------|--------------|------|
|
||||
| AppBar | ~52px | 44px | 8px |
|
||||
| 箱号区 + 排产信息区 | ~80px(两行) | 48px(合并一行) | 32px |
|
||||
| 列表每行 | ~48–52px | 40px | 8–12px/行 |
|
||||
| 通知条(每条) | ~36px | 30px | 6px |
|
||||
| 状态栏 | ~38px | 28px | 10px |
|
||||
|
||||
三项以上改动叠加后,典型场景下可多显示 **1–2 条**已装入记录,且操作底栏固定可见。
|
||||
|
||||
---
|
||||
|
||||
## 3. 详细改动说明
|
||||
|
||||
### 3.1 AppBar(`_buildAppBar`)
|
||||
|
||||
**原版**:高度约 52px,标题与模式切换按钮分布松散,模式徽章内边距较大。
|
||||
|
||||
**v2**:
|
||||
- 高度收紧至 **44px**(Material 标准最小可触高度)。
|
||||
- 标题(`装箱编号`)与模式徽章(`⇄ 多码凑箱 P2`)左右对齐,中间用弹性空白(`Spacer`)分隔。
|
||||
- 模式徽章内边距从 `6px 10px` 收窄至 `3px 9px`,字号保持 `11px`,快捷键角标用半透明背景小标签呈现。
|
||||
|
||||
---
|
||||
|
||||
### 3.2 箱号区 + 排产信息区 → 合并为 `header-bar`(`_buildManyToOneBoxNoPanel`)
|
||||
|
||||
**原版**:箱号输入框(22px 标签 + 44px 输入框)与排产信息行(排产号、已有箱数、最大箱号、详情按钮)分两个独立区块,共约 80px。
|
||||
|
||||
**v2**:合并为单行 `header-bar`,高度约 **48px**,布局如下:
|
||||
|
||||
```
|
||||
[ 箱号 ██ 1 ██ ] │ W00009 已有 1 箱 · 最大箱号 1 详情
|
||||
```
|
||||
|
||||
- 箱号输入框:宽 52px,高 32px,字号 18px 加粗,左侧配 `11px` 灰色小标签"箱号"。
|
||||
- 竖向分隔线将箱号区与排产信息区视觉隔开。
|
||||
- 排产号(`13px` 加粗)+ 统计信息(`11px` 灰色)两行叠放,`flex: 1` 撑满中间。
|
||||
- `详情` 链接按钮右对齐,无数据时置灰。
|
||||
|
||||
**影响代码**:`_buildManyToOneBoxNoPanel()`、相关 `_boxNoController` 绑定逻辑不变,仅 UI 结构调整。
|
||||
|
||||
---
|
||||
|
||||
### 3.3 已装入列表(`_buildManyToOnePackedList`)
|
||||
|
||||
**原版**:每行 `packed-item` 高度约 48–52px,内含 `8px 8px` 内边距,信息横排一行。
|
||||
|
||||
**v2**:每行高度压缩至 **40px**,采用左侧色条 + 两行叠放的结构:
|
||||
|
||||
```
|
||||
序号 ▌ 26BW0011 80 / 80 ✏️ 🗑
|
||||
▌ WO-2026-001
|
||||
```
|
||||
|
||||
- **左侧色条**(宽 3px,高 26px,圆角):正常 = 绿色,编辑中 = 琥珀色,删除确认 = 红色。用色条替代原版整行背景色染色,减少视觉干扰。
|
||||
- **序号列**(宽 14px):灰色小字,方便操作者快速定位。
|
||||
- **主信息列**:总排号 `12px` 加粗(上行)+ 工单号 `11px` 灰色(下行),`overflow: ellipsis` 防止溢出。
|
||||
- **数量列**:`12px` 加粗绿色,格式 `已装 / 总量`。
|
||||
- **操作按钮**:✏️(编辑)/ 🗑(删除),触摸区域 28×28px,悬停背景 `#eeeeee`。
|
||||
|
||||
**编辑态**:数量文字替换为 52px 宽输入框,操作按钮变为 ✓(保存)/ ✕(取消)。
|
||||
|
||||
**删除确认态**:废弃弹窗方案,改为**行内展开次级条**(高度约 26px),与触发行紧邻,包含`删除` / `取消` 两个小按钮。行背景变淡红,色条变红,视觉上整体标记该条目处于危险操作中。
|
||||
|
||||
**列表头**(sticky):高度约 30px,`已装入本箱` 左对齐,`N 项` 蓝色右对齐,固定在列表顶部,滚动时不消失。
|
||||
|
||||
---
|
||||
|
||||
### 3.4 底部操作区固定化(`_buildManyToOneScanArea` + `_buildManyToOneSubmitArea`)
|
||||
|
||||
**原版**:扫码区、数量输入、确认按钮、完成本箱按钮位于列表下方,随列表内容延伸,装入项目多时操作区不可见。
|
||||
|
||||
**v2**:底部操作区改为**固定底栏(不参与滚动)**,从上至下分三行:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ [✓ 26BW0014 · WO-004 · 50件] │ 扫码条 ~38px
|
||||
│ 数量 [ 50 ] [确认] │ 提交行 ~48px
|
||||
│ [ 完成本箱 ] │ 完成行 ~50px
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**扫码条**:
|
||||
- 等待状态:灰色背景,`📷 请扫描执行卡二维码`,文字灰色。
|
||||
- 已扫码状态:浅绿背景,`✓ 总排号 · 工单号 · N件`,文字绿色,绿色边框。
|
||||
|
||||
**提交行**:
|
||||
- `数量` 标签(`11px`)+ 输入框(`flex:1`,高 34px)+ 确认按钮(高 34px,`padding 0 18px`)。
|
||||
- 未扫码时:输入框禁用,确认按钮灰色不可点。
|
||||
- 已扫码时:输入框激活,确认按钮蓝色可点。
|
||||
|
||||
**完成本箱行**:
|
||||
- 全宽描边按钮(高 36px,圆角 8px),有已装入项时激活,否则灰色。
|
||||
|
||||
---
|
||||
|
||||
### 3.5 通知条(Alert Bars)
|
||||
|
||||
**原版**:每条高度约 36px,字号 13px,内边距 `8px 16px`。
|
||||
|
||||
**v2**:
|
||||
- 高度压缩至约 **30px**,内边距 `6px 12px`,字号 `12px`。
|
||||
- 多条通知可叠加显示(同步条 + 警告条并存,场景6)。
|
||||
- 各通知类型颜色语义不变:橙(同步中)/ 琥珀(警告)/ 蓝(信息)/ 黄(重复箱号)。
|
||||
- 同步条保留旋转动画小图标(`12px`,`0.7s` 线性)。
|
||||
|
||||
---
|
||||
|
||||
### 3.6 状态栏(`StatusBar`)
|
||||
|
||||
**原版**:高度约 38px,背景 `#f0f0f0`,内边距 `12px 16px`。
|
||||
|
||||
**v2**:
|
||||
- 高度压缩至约 **28px**,内边距 `6px 12px`。
|
||||
- 状态点直径从 8px 缩至 7px,文字从 14px 缩至 12px。
|
||||
- 颜色语义不变(蓝/绿/橙/红/琥珀)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 整体布局结构(v2)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐ 44px
|
||||
│ 装箱编号 [⇄ 多码凑箱 P2] │ AppBar
|
||||
├──────────────────────────────────────────┤
|
||||
│ [通知条,可叠加,每条 ~30px] │ 可选
|
||||
├──────────────────────────────────────────┤ 48px
|
||||
│ 箱号 [ 1 ] │ W00009 已有1箱·最大箱1 详情│ header-bar
|
||||
├──────────────────────────────────────────┤
|
||||
│ 已装入本箱(sticky) 3 项 │ ~30px
|
||||
│ 1 ▌ 26BW0011 WO-001 80/80 ✏️ 🗑 │ 40px
|
||||
│ 2 ▌ 26BW0012 WO-002 60/60 ✏️ 🗑 │ 40px
|
||||
│ 3 ▌ 26BW0013 WO-003 40/40 ✏️ 🗑 │ 40px ← 可滚动
|
||||
│ ... │
|
||||
├──────────────────────────────────────────┤
|
||||
│ [✓ 26BW0014 · WO-004 · 50件] │ ~38px 固定
|
||||
│ 数量 [ 50 ] [确认] │ ~48px 固定
|
||||
│ [ 完成本箱 ] │ ~50px 固定
|
||||
├──────────────────────────────────────────┤ 28px
|
||||
│ ● 装箱成功,请继续扫码 │ 状态栏
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 影响范围
|
||||
|
||||
| 方法/组件 | 改动类型 |
|
||||
|-----------|---------|
|
||||
| `_buildManyToOneBody()` | 结构重组(列表区 + 固定底栏) |
|
||||
| `_buildManyToOneBoxNoPanel()` | 重写(合并为 header-bar) |
|
||||
| `_buildManyToOnePackedList()` | 重写(行高、色条、内联删除确认) |
|
||||
| `_buildManyToOneScanArea()` | 移入固定底栏,样式调整 |
|
||||
| `_buildManyToOneSubmitArea()` | 移入固定底栏,高度调整 |
|
||||
| `_buildAppBar()` 多码分支 | 高度和内边距调整 |
|
||||
| `StatusBar` widget | 尺寸参数调整 |
|
||||
| Alert bar 区域 | 内边距和字号调整 |
|
||||
|
||||
业务逻辑(`decideMultiCodePaichanContext`、`_applyAutoFill`、`_onSubmitSuccess`、`_finishManyToOneBox` 等)**不受影响**。
|
||||
|
||||
---
|
||||
|
||||
## 6. 不在本次范围内
|
||||
|
||||
- 单码装箱模式(`BoxingMode.single`)的布局不做调整。
|
||||
- 字体、主题色、品牌色系不做变更。
|
||||
- 横屏/平板适配留作后续专项。
|
||||
150
lib/pages/boxing/boxing_api_actions.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
import 'package:pad_scanner/services/app_config_service.dart';
|
||||
|
||||
enum BoxingActionErrorKind { missingApiUrl, network, duplicate, backend }
|
||||
|
||||
class BoxingActionResult<T> {
|
||||
final bool success;
|
||||
final T? data;
|
||||
final BoxingActionErrorKind? errorKind;
|
||||
final String? errorMessage;
|
||||
final int? boxNo;
|
||||
|
||||
const BoxingActionResult._({
|
||||
required this.success,
|
||||
this.data,
|
||||
this.errorKind,
|
||||
this.errorMessage,
|
||||
this.boxNo,
|
||||
});
|
||||
|
||||
const BoxingActionResult.ok(T data) : this._(success: true, data: data);
|
||||
|
||||
const BoxingActionResult.error({
|
||||
required BoxingActionErrorKind kind,
|
||||
required String message,
|
||||
int? boxNo,
|
||||
}) : this._(
|
||||
success: false,
|
||||
errorKind: kind,
|
||||
errorMessage: message,
|
||||
boxNo: boxNo,
|
||||
);
|
||||
}
|
||||
|
||||
typedef ApiUrlLoader = Future<String?> Function();
|
||||
|
||||
class BoxingApiActions {
|
||||
static const networkErrorMessage = '网络异常,请检查网络连接';
|
||||
static const missingApiUrlMessage = '未配置 API 地址,请前往设置';
|
||||
|
||||
final ApiService _apiService;
|
||||
final ApiUrlLoader _loadApiUrl;
|
||||
|
||||
BoxingApiActions({required ApiService apiService, ApiUrlLoader? loadApiUrl})
|
||||
: _apiService = apiService,
|
||||
_loadApiUrl =
|
||||
loadApiUrl ?? (() => AppConfigService().getString('api_url'));
|
||||
|
||||
Future<BoxingActionResult<BoxInfoResult>> fetchBoxInfo(String zongpai) async {
|
||||
final baseUrl = await _requireBaseUrl();
|
||||
if (baseUrl == null) return _missingApiUrl();
|
||||
|
||||
final result = await _apiService.fetchBoxInfo(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpai,
|
||||
);
|
||||
if (result.success) return BoxingActionResult.ok(result);
|
||||
return BoxingActionResult.error(
|
||||
kind: _kindForMessage(result.errorMessage),
|
||||
message: result.errorMessage ?? '查询失败',
|
||||
);
|
||||
}
|
||||
|
||||
Future<BoxingActionResult<BoxSaveResult>> saveBoxRecord({
|
||||
required String zongpaiNo,
|
||||
required int boxNo,
|
||||
required int quantity,
|
||||
}) async {
|
||||
final baseUrl = await _requireBaseUrl();
|
||||
if (baseUrl == null) return _missingApiUrl();
|
||||
|
||||
final result = await _apiService.saveBoxRecord(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
return _saveResult(result, fallbackMessage: '提交失败');
|
||||
}
|
||||
|
||||
Future<BoxingActionResult<BoxSaveResult>> updateBoxRecord({
|
||||
required int boxItemId,
|
||||
required int boxNo,
|
||||
required int quantity,
|
||||
}) async {
|
||||
final baseUrl = await _requireBaseUrl();
|
||||
if (baseUrl == null) return _missingApiUrl();
|
||||
|
||||
final result = await _apiService.updateBoxRecord(
|
||||
baseUrl: baseUrl,
|
||||
boxItemId: boxItemId,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
return _saveResult(result, fallbackMessage: '修改失败');
|
||||
}
|
||||
|
||||
Future<BoxingActionResult<BoxDeleteResult>> deleteBoxRecord({
|
||||
required int boxItemId,
|
||||
}) async {
|
||||
final baseUrl = await _requireBaseUrl();
|
||||
if (baseUrl == null) return _missingApiUrl();
|
||||
|
||||
final result = await _apiService.deleteBoxRecord(
|
||||
baseUrl: baseUrl,
|
||||
boxItemId: boxItemId,
|
||||
);
|
||||
if (result.success) return BoxingActionResult.ok(result);
|
||||
return BoxingActionResult.error(
|
||||
kind: _kindForMessage(result.errorMessage),
|
||||
message: result.errorMessage ?? '删除失败',
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _requireBaseUrl() async {
|
||||
final baseUrl = await _loadApiUrl() ?? '';
|
||||
if (baseUrl.isEmpty) return null;
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
BoxingActionResult<T> _missingApiUrl<T>() {
|
||||
return const BoxingActionResult.error(
|
||||
kind: BoxingActionErrorKind.missingApiUrl,
|
||||
message: missingApiUrlMessage,
|
||||
);
|
||||
}
|
||||
|
||||
BoxingActionResult<BoxSaveResult> _saveResult(
|
||||
BoxSaveResult result, {
|
||||
required String fallbackMessage,
|
||||
}) {
|
||||
if (result.success) return BoxingActionResult.ok(result);
|
||||
if (result.isDuplicate) {
|
||||
return BoxingActionResult.error(
|
||||
kind: BoxingActionErrorKind.duplicate,
|
||||
message: result.errorMessage ?? fallbackMessage,
|
||||
boxNo: result.boxNo,
|
||||
);
|
||||
}
|
||||
return BoxingActionResult.error(
|
||||
kind: _kindForMessage(result.errorMessage),
|
||||
message: result.errorMessage ?? fallbackMessage,
|
||||
);
|
||||
}
|
||||
|
||||
BoxingActionErrorKind _kindForMessage(String? message) {
|
||||
if (message == networkErrorMessage) return BoxingActionErrorKind.network;
|
||||
return BoxingActionErrorKind.backend;
|
||||
}
|
||||
}
|
||||
135
lib/pages/boxing/boxing_box_mutations.dart
Normal file
@@ -0,0 +1,135 @@
|
||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
class BoxMutationResult {
|
||||
final List<BoxDetailData> boxes;
|
||||
final int maxBoxNo;
|
||||
|
||||
const BoxMutationResult({required this.boxes, required this.maxBoxNo});
|
||||
}
|
||||
|
||||
int maxBoxNoFor(List<BoxDetailData> boxes) {
|
||||
return boxes.fold<int>(0, (max, box) => box.boxNo > max ? box.boxNo : max);
|
||||
}
|
||||
|
||||
BoxMutationResult addExistingBoxItem({
|
||||
required List<BoxDetailData> existingBoxes,
|
||||
required int boxNo,
|
||||
required int quantity,
|
||||
required int? boxItemId,
|
||||
required String zongpaiNo,
|
||||
required String? workOrderNo,
|
||||
required int? totalQuantity,
|
||||
}) {
|
||||
final item = BoxItemData(
|
||||
boxItemId: boxItemId,
|
||||
zongpaiNo: zongpaiNo,
|
||||
workOrderNo: workOrderNo,
|
||||
quantity: quantity,
|
||||
totalQuantity: totalQuantity,
|
||||
);
|
||||
final index = existingBoxes.indexWhere((box) => box.boxNo == boxNo);
|
||||
if (index < 0) {
|
||||
final boxes = [
|
||||
...existingBoxes,
|
||||
BoxDetailData(boxNo: boxNo, items: [item]),
|
||||
]..sort((a, b) => a.boxNo.compareTo(b.boxNo));
|
||||
return BoxMutationResult(boxes: boxes, maxBoxNo: maxBoxNoFor(boxes));
|
||||
}
|
||||
|
||||
final boxes = List<BoxDetailData>.from(existingBoxes);
|
||||
final box = boxes[index];
|
||||
boxes[index] = BoxDetailData(boxNo: box.boxNo, items: [...box.items, item]);
|
||||
return BoxMutationResult(boxes: boxes, maxBoxNo: maxBoxNoFor(boxes));
|
||||
}
|
||||
|
||||
BoxMutationResult replaceExistingBoxItem({
|
||||
required List<BoxDetailData> existingBoxes,
|
||||
required CurrentZongpaiBoxData editing,
|
||||
required int boxNo,
|
||||
required int quantity,
|
||||
required String zongpaiNo,
|
||||
required String? workOrderNo,
|
||||
required int? totalQuantity,
|
||||
}) {
|
||||
final updatedItem = BoxItemData(
|
||||
boxItemId: editing.boxItemId,
|
||||
zongpaiNo: zongpaiNo,
|
||||
workOrderNo: workOrderNo,
|
||||
quantity: quantity,
|
||||
totalQuantity: totalQuantity,
|
||||
);
|
||||
return _replaceItemById(
|
||||
existingBoxes: existingBoxes,
|
||||
boxItemId: editing.boxItemId,
|
||||
targetBoxNo: boxNo,
|
||||
updatedItem: updatedItem,
|
||||
);
|
||||
}
|
||||
|
||||
BoxMutationResult replaceExistingPackedItem({
|
||||
required List<BoxDetailData> existingBoxes,
|
||||
required ManyToOnePackedItem item,
|
||||
required int boxNo,
|
||||
required int quantity,
|
||||
}) {
|
||||
final updatedItem = BoxItemData(
|
||||
boxItemId: item.boxItemId,
|
||||
zongpaiNo: item.zongpaiNo,
|
||||
workOrderNo: item.workOrderNo,
|
||||
quantity: quantity,
|
||||
totalQuantity: item.totalQuantity,
|
||||
);
|
||||
return _replaceItemById(
|
||||
existingBoxes: existingBoxes,
|
||||
boxItemId: item.boxItemId,
|
||||
targetBoxNo: boxNo,
|
||||
updatedItem: updatedItem,
|
||||
);
|
||||
}
|
||||
|
||||
BoxMutationResult removeExistingBoxItem({
|
||||
required List<BoxDetailData> existingBoxes,
|
||||
required int boxItemId,
|
||||
}) {
|
||||
final boxes = <BoxDetailData>[];
|
||||
for (final box in existingBoxes) {
|
||||
final items = box.items
|
||||
.where((item) => item.boxItemId != boxItemId)
|
||||
.toList();
|
||||
if (items.isNotEmpty) {
|
||||
boxes.add(BoxDetailData(boxNo: box.boxNo, items: items));
|
||||
}
|
||||
}
|
||||
return BoxMutationResult(boxes: boxes, maxBoxNo: maxBoxNoFor(boxes));
|
||||
}
|
||||
|
||||
BoxMutationResult _replaceItemById({
|
||||
required List<BoxDetailData> existingBoxes,
|
||||
required int boxItemId,
|
||||
required int targetBoxNo,
|
||||
required BoxItemData updatedItem,
|
||||
}) {
|
||||
final boxes = <BoxDetailData>[];
|
||||
for (final box in existingBoxes) {
|
||||
final items = box.items.where((item) {
|
||||
return item.boxItemId != boxItemId;
|
||||
}).toList();
|
||||
if (items.isNotEmpty) {
|
||||
boxes.add(BoxDetailData(boxNo: box.boxNo, items: items));
|
||||
}
|
||||
}
|
||||
|
||||
final targetIndex = boxes.indexWhere((box) => box.boxNo == targetBoxNo);
|
||||
if (targetIndex >= 0) {
|
||||
final target = boxes[targetIndex];
|
||||
boxes[targetIndex] = BoxDetailData(
|
||||
boxNo: target.boxNo,
|
||||
items: [...target.items, updatedItem],
|
||||
);
|
||||
} else {
|
||||
boxes.add(BoxDetailData(boxNo: targetBoxNo, items: [updatedItem]));
|
||||
}
|
||||
boxes.sort((a, b) => a.boxNo.compareTo(b.boxNo));
|
||||
return BoxMutationResult(boxes: boxes, maxBoxNo: maxBoxNoFor(boxes));
|
||||
}
|
||||
101
lib/pages/boxing/boxing_calculations.dart
Normal file
@@ -0,0 +1,101 @@
|
||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
int packedQuantity(List<CurrentZongpaiBoxData> items) {
|
||||
return items.fold<int>(0, (sum, item) => sum + item.quantity);
|
||||
}
|
||||
|
||||
int remainingQuantity({
|
||||
required int? totalQuantity,
|
||||
required List<CurrentZongpaiBoxData> currentBoxes,
|
||||
}) {
|
||||
return (totalQuantity ?? 0) - packedQuantity(currentBoxes);
|
||||
}
|
||||
|
||||
bool quantityTooHigh({
|
||||
required int remainingQuantity,
|
||||
required String quantityText,
|
||||
}) {
|
||||
if (remainingQuantity <= 0) return false;
|
||||
final quantity = int.tryParse(quantityText);
|
||||
return quantity != null && quantity > remainingQuantity;
|
||||
}
|
||||
|
||||
bool canSubmitBoxing({
|
||||
required bool isSubmitting,
|
||||
required BoxingPhase phase,
|
||||
required String? zongpaiNo,
|
||||
required String boxNoText,
|
||||
required String quantityText,
|
||||
required int remainingQuantity,
|
||||
required bool quantityTooHigh,
|
||||
required bool isDuplicateBoxNo,
|
||||
}) {
|
||||
if (isSubmitting || phase != BoxingPhase.scanned) return false;
|
||||
if (zongpaiNo == null) return false;
|
||||
final boxNo = int.tryParse(boxNoText);
|
||||
final quantity = int.tryParse(quantityText);
|
||||
if (boxNo == null || boxNo <= 0 || quantity == null || quantity <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (remainingQuantity <= 0) return false;
|
||||
if (quantityTooHigh) return false;
|
||||
if (isDuplicateBoxNo) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
int? currentManyToOneBoxNo(String boxNoText) {
|
||||
final boxNo = int.tryParse(boxNoText.trim());
|
||||
if (boxNo == null || boxNo <= 0) return null;
|
||||
return boxNo;
|
||||
}
|
||||
|
||||
bool singleCodeBoxNoIsDuplicate({
|
||||
required String boxNoText,
|
||||
required List<CurrentZongpaiBoxData> currentBoxes,
|
||||
required int? editingBoxItemId,
|
||||
}) {
|
||||
final boxNo = int.tryParse(boxNoText);
|
||||
if (boxNo == null) return false;
|
||||
return currentBoxes.any((box) {
|
||||
if (box.boxNo != boxNo) return false;
|
||||
return editingBoxItemId == null || box.boxItemId != editingBoxItemId;
|
||||
});
|
||||
}
|
||||
|
||||
List<ManyToOnePackedItem> visibleManyToOnePackedItems({
|
||||
required int? boxNo,
|
||||
required List<BoxDetailData> existingBoxes,
|
||||
required List<ManyToOnePackedItem> packedItems,
|
||||
required String? paichanNo,
|
||||
}) {
|
||||
if (boxNo == null) return const [];
|
||||
final byItemId = <int, ManyToOnePackedItem>{};
|
||||
for (final box in existingBoxes.where((box) => box.boxNo == boxNo)) {
|
||||
for (final item in box.items) {
|
||||
final boxItemId = item.boxItemId;
|
||||
if (boxItemId == null) continue;
|
||||
byItemId[boxItemId] = ManyToOnePackedItem(
|
||||
boxItemId: boxItemId,
|
||||
zongpaiNo: item.zongpaiNo,
|
||||
paichanNo: paichanNo,
|
||||
workOrderNo: item.workOrderNo,
|
||||
boxNo: boxNo,
|
||||
quantity: item.quantity,
|
||||
totalQuantity: item.totalQuantity,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (final item in packedItems.where((item) => item.boxNo == boxNo)) {
|
||||
byItemId[item.boxItemId] = item;
|
||||
}
|
||||
return byItemId.values.toList(growable: false);
|
||||
}
|
||||
|
||||
int maxAssignedQuantity({
|
||||
required int? totalQuantity,
|
||||
required int packedQuantity,
|
||||
required int itemQuantity,
|
||||
}) {
|
||||
return (totalQuantity ?? 0) - (packedQuantity - itemQuantity);
|
||||
}
|
||||
56
lib/pages/boxing/boxing_models.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
enum BoxingMode {
|
||||
singleCode, // 单码装箱
|
||||
multiCode, // 多码凑箱
|
||||
}
|
||||
|
||||
enum BoxingPhase {
|
||||
waiting, // 等待扫码
|
||||
scanned, // 已扫码,显示信息
|
||||
submitted, // 已提交成功
|
||||
}
|
||||
|
||||
class BoxingPageArguments {
|
||||
final BoxingMode initialMode;
|
||||
final List<String> autoScanCodes;
|
||||
|
||||
const BoxingPageArguments({
|
||||
required this.initialMode,
|
||||
required this.autoScanCodes,
|
||||
});
|
||||
}
|
||||
|
||||
class ManyToOnePackedItem {
|
||||
final int boxItemId;
|
||||
final String zongpaiNo;
|
||||
final String? paichanNo;
|
||||
final String? workOrderNo;
|
||||
final int boxNo;
|
||||
final int quantity;
|
||||
final int? totalQuantity;
|
||||
|
||||
const ManyToOnePackedItem({
|
||||
required this.boxItemId,
|
||||
required this.zongpaiNo,
|
||||
this.paichanNo,
|
||||
required this.workOrderNo,
|
||||
required this.boxNo,
|
||||
required this.quantity,
|
||||
this.totalQuantity,
|
||||
});
|
||||
|
||||
ManyToOnePackedItem copyWith({
|
||||
int? boxNo,
|
||||
int? quantity,
|
||||
int? totalQuantity,
|
||||
}) {
|
||||
return ManyToOnePackedItem(
|
||||
boxItemId: boxItemId,
|
||||
zongpaiNo: zongpaiNo,
|
||||
paichanNo: paichanNo,
|
||||
workOrderNo: workOrderNo,
|
||||
boxNo: boxNo ?? this.boxNo,
|
||||
quantity: quantity ?? this.quantity,
|
||||
totalQuantity: totalQuantity ?? this.totalQuantity,
|
||||
);
|
||||
}
|
||||
}
|
||||
97
lib/pages/boxing/boxing_status_presenter.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
||||
import 'package:pad_scanner/widgets/status_bar.dart';
|
||||
|
||||
class BoxingStatusPresentation {
|
||||
final StatusDotColor dot;
|
||||
final String text;
|
||||
|
||||
const BoxingStatusPresentation({required this.dot, required this.text});
|
||||
}
|
||||
|
||||
BoxingStatusPresentation boxingStatusFor({
|
||||
required bool isAutoProcessing,
|
||||
required bool isSubmitting,
|
||||
required bool isDuplicateBoxNo,
|
||||
required bool quantityTooHigh,
|
||||
required bool isEditingAssigned,
|
||||
required bool isDeletingAssigned,
|
||||
required bool hasPaichanSwitchNotice,
|
||||
required BoxingPhase phase,
|
||||
required bool isMultiCode,
|
||||
required bool hasVisibleManyToOneItems,
|
||||
required int remainingQuantity,
|
||||
required String boxNoText,
|
||||
}) {
|
||||
if (isAutoProcessing) {
|
||||
return const BoxingStatusPresentation(
|
||||
dot: StatusDotColor.orange,
|
||||
text: '正在同步转运数据...',
|
||||
);
|
||||
}
|
||||
if (isSubmitting) {
|
||||
return const BoxingStatusPresentation(
|
||||
dot: StatusDotColor.orange,
|
||||
text: '正在提交…',
|
||||
);
|
||||
}
|
||||
if (isDuplicateBoxNo && phase == BoxingPhase.scanned) {
|
||||
return BoxingStatusPresentation(
|
||||
dot: StatusDotColor.amber,
|
||||
text: '箱号 $boxNoText 已存在,请重新输入',
|
||||
);
|
||||
}
|
||||
if (quantityTooHigh && phase == BoxingPhase.scanned) {
|
||||
return const BoxingStatusPresentation(
|
||||
dot: StatusDotColor.red,
|
||||
text: '超出可装数量上限',
|
||||
);
|
||||
}
|
||||
if (isEditingAssigned) {
|
||||
return const BoxingStatusPresentation(
|
||||
dot: StatusDotColor.amber,
|
||||
text: '正在编辑已分配记录',
|
||||
);
|
||||
}
|
||||
if (isDeletingAssigned) {
|
||||
return const BoxingStatusPresentation(
|
||||
dot: StatusDotColor.red,
|
||||
text: '请确认是否删除该箱记录',
|
||||
);
|
||||
}
|
||||
if (hasPaichanSwitchNotice) {
|
||||
return const BoxingStatusPresentation(
|
||||
dot: StatusDotColor.blue,
|
||||
text: '排产号已切换,箱号已重置',
|
||||
);
|
||||
}
|
||||
|
||||
switch (phase) {
|
||||
case BoxingPhase.waiting:
|
||||
if (isMultiCode && hasVisibleManyToOneItems) {
|
||||
return const BoxingStatusPresentation(
|
||||
dot: StatusDotColor.blue,
|
||||
text: '请继续扫码或完成本箱',
|
||||
);
|
||||
}
|
||||
return const BoxingStatusPresentation(
|
||||
dot: StatusDotColor.blue,
|
||||
text: '等待扫码',
|
||||
);
|
||||
case BoxingPhase.scanned:
|
||||
if (remainingQuantity <= 0) {
|
||||
return const BoxingStatusPresentation(
|
||||
dot: StatusDotColor.red,
|
||||
text: '该总排号已全部装箱完毕',
|
||||
);
|
||||
}
|
||||
return const BoxingStatusPresentation(
|
||||
dot: StatusDotColor.blue,
|
||||
text: '数量已填入,请确认或修改',
|
||||
);
|
||||
case BoxingPhase.submitted:
|
||||
return BoxingStatusPresentation(
|
||||
dot: StatusDotColor.green,
|
||||
text: isMultiCode ? '请扫描下一个总排号' : '装箱成功,可继续扫码',
|
||||
);
|
||||
}
|
||||
}
|
||||
126
lib/pages/boxing/dialogs/cross_paichan_dialog.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
Future<bool> showCrossPaichanDialog({
|
||||
required BuildContext context,
|
||||
required String currentPaicha,
|
||||
required String newPaicha,
|
||||
}) async {
|
||||
final dialogFocus = FocusNode();
|
||||
var selectedIndex = 0; // 0 = "否" (default), 1 = "是"
|
||||
|
||||
final dialogFuture = showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) {
|
||||
void dismissAsNo() => Navigator.of(ctx).pop(false);
|
||||
void confirmSwitch() => Navigator.of(ctx).pop(true);
|
||||
|
||||
return KeyboardListener(
|
||||
focusNode: dialogFocus,
|
||||
onKeyEvent: (event) {
|
||||
if (event is! KeyDownEvent) return;
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
|
||||
setDialogState(() => selectedIndex = 0);
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
setDialogState(() => selectedIndex = 1);
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
selectedIndex == 0 ? dismissAsNo() : confirmSwitch();
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
dismissAsNo();
|
||||
}
|
||||
},
|
||||
child: PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) dismissAsNo();
|
||||
},
|
||||
child: AlertDialog(
|
||||
backgroundColor: Colors.orange.shade50,
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber, color: Colors.orange.shade800),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'跨排产号扫描',
|
||||
style: TextStyle(color: Colors.orange.shade900),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'当前排产号:$currentPaicha\n'
|
||||
'新排产号:$newPaicha\n\n'
|
||||
'是否切换到新的排产号?\n\n'
|
||||
'选择「是」将放弃当前已扫描的所有数据。',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_DialogOptionButton(
|
||||
label: '否',
|
||||
selected: selectedIndex == 0,
|
||||
onTap: dismissAsNo,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_DialogOptionButton(
|
||||
label: '是,切换排产号',
|
||||
selected: selectedIndex == 1,
|
||||
onTap: confirmSwitch,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
dialogFocus.requestFocus();
|
||||
});
|
||||
|
||||
final result = await dialogFuture;
|
||||
dialogFocus.dispose();
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
class _DialogOptionButton extends StatelessWidget {
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _DialogOptionButton({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 40,
|
||||
child: ElevatedButton(
|
||||
onPressed: onTap,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: selected
|
||||
? Colors.blue.shade700
|
||||
: Colors.grey.shade200,
|
||||
foregroundColor: selected ? Colors.white : Colors.black87,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
59
lib/pages/boxing/parts/boxing_box_mutation_part.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
part of '../../boxing_page.dart';
|
||||
|
||||
extension _BoxingBoxMutationPart on _BoxingPageState {
|
||||
void _replaceExistingBoxItem(
|
||||
CurrentZongpaiBoxData editing,
|
||||
int boxNo,
|
||||
int quantity,
|
||||
) {
|
||||
final result = box_mutations.replaceExistingBoxItem(
|
||||
existingBoxes: _existingBoxes,
|
||||
editing: editing,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
zongpaiNo: _zongpaiNo!,
|
||||
workOrderNo: _workOrderNo,
|
||||
totalQuantity: _erpQuantity,
|
||||
);
|
||||
_existingBoxes = result.boxes;
|
||||
_maxBoxNo = result.maxBoxNo;
|
||||
}
|
||||
|
||||
void _addExistingBoxItem(int boxNo, int quantity, int? boxItemId) {
|
||||
final result = box_mutations.addExistingBoxItem(
|
||||
existingBoxes: _existingBoxes,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
boxItemId: boxItemId,
|
||||
zongpaiNo: _zongpaiNo!,
|
||||
workOrderNo: _workOrderNo,
|
||||
totalQuantity: _erpQuantity,
|
||||
);
|
||||
_existingBoxes = result.boxes;
|
||||
_maxBoxNo = result.maxBoxNo;
|
||||
}
|
||||
|
||||
void _replaceExistingPackedItem(
|
||||
ManyToOnePackedItem item,
|
||||
int boxNo,
|
||||
int quantity,
|
||||
) {
|
||||
final result = box_mutations.replaceExistingPackedItem(
|
||||
existingBoxes: _existingBoxes,
|
||||
item: item,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
_existingBoxes = result.boxes;
|
||||
_maxBoxNo = result.maxBoxNo;
|
||||
}
|
||||
|
||||
void _removeExistingBoxItem(int boxItemId) {
|
||||
final result = box_mutations.removeExistingBoxItem(
|
||||
existingBoxes: _existingBoxes,
|
||||
boxItemId: boxItemId,
|
||||
);
|
||||
_existingBoxes = result.boxes;
|
||||
_maxBoxNo = result.maxBoxNo;
|
||||
}
|
||||
}
|
||||
112
lib/pages/boxing/parts/boxing_multi_ops_part.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member, unnecessary_this
|
||||
|
||||
part of '../../boxing_page.dart';
|
||||
|
||||
extension _BoxingMultiOpsPart on _BoxingPageState {
|
||||
void _finishManyToOneBox() {
|
||||
setState(() {
|
||||
_manyToOnePackedItems.clear();
|
||||
_editingPackedItemId = null;
|
||||
_editingPackedQuantity = '';
|
||||
_deletingPackedItemId = null;
|
||||
_zongpaiNo = null;
|
||||
_phase = BoxingPhase.waiting;
|
||||
_quantityController.clear();
|
||||
_boxNoController.text = (_maxBoxNo + 1).toString();
|
||||
_statusOverrideText = null;
|
||||
_statusOverrideDot = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _startEditPackedItem(ManyToOnePackedItem item) {
|
||||
setState(() {
|
||||
_editingPackedItemId = item.boxItemId;
|
||||
_editingPackedQuantity = item.quantity.toString();
|
||||
_deletingPackedItemId = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelEditPackedItem() {
|
||||
setState(() {
|
||||
_editingPackedItemId = null;
|
||||
_editingPackedQuantity = '';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _savePackedItem(ManyToOnePackedItem item) async {
|
||||
final quantity = int.tryParse(_editingPackedQuantity);
|
||||
final boxNo = int.tryParse(_boxNoController.text.trim());
|
||||
if (quantity == null || quantity <= 0 || boxNo == null || boxNo <= 0) {
|
||||
this._showStatusOverride(
|
||||
'请输入有效箱号和数量',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
final action = await _apiActions.updateBoxRecord(
|
||||
boxItemId: item.boxItemId,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
if (action.success) {
|
||||
setState(() {
|
||||
final index = _manyToOnePackedItems.indexWhere(
|
||||
(packed) => packed.boxItemId == item.boxItemId,
|
||||
);
|
||||
if (index >= 0) {
|
||||
_manyToOnePackedItems[index] = _manyToOnePackedItems[index].copyWith(
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
}
|
||||
this._replaceExistingPackedItem(item, boxNo, quantity);
|
||||
_editingPackedItemId = null;
|
||||
_editingPackedQuantity = '';
|
||||
});
|
||||
this._showStatusOverride(
|
||||
'修改成功',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
} else {
|
||||
this._showActionError(action, fallback: '修改失败');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deletePackedItem(ManyToOnePackedItem item) async {
|
||||
setState(() => _isSubmitting = true);
|
||||
final action = await _apiActions.deleteBoxRecord(boxItemId: item.boxItemId);
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
if (action.success) {
|
||||
_manyToOnePackedItems.removeWhere(
|
||||
(packed) => packed.boxItemId == item.boxItemId,
|
||||
);
|
||||
this._removeExistingBoxItem(item.boxItemId);
|
||||
if (_editingPackedItemId == item.boxItemId) {
|
||||
_editingPackedItemId = null;
|
||||
_editingPackedQuantity = '';
|
||||
}
|
||||
_deletingPackedItemId = null;
|
||||
}
|
||||
});
|
||||
|
||||
if (action.success) {
|
||||
this._showStatusOverride(
|
||||
'删除成功',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
} else {
|
||||
this._showActionError(action, fallback: '删除失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
271
lib/pages/boxing/parts/boxing_scan_part.dart
Normal file
@@ -0,0 +1,271 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member, unnecessary_this
|
||||
|
||||
part of '../../boxing_page.dart';
|
||||
|
||||
extension _BoxingScanPart on _BoxingPageState {
|
||||
void _onScan(ScanResult result) {
|
||||
if (_isAutoProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_crossPaichaPending) {
|
||||
_feedbackService.trigger(FeedbackEvent.alreadyCompleted);
|
||||
return;
|
||||
}
|
||||
|
||||
final parsed = CodeParser.parse(result.barcode);
|
||||
|
||||
if (parsed.type != CodeType.zongpaiNo) {
|
||||
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
||||
this._showStatusOverride(
|
||||
'无效码,请重新扫描',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final zongpai = parsed.value;
|
||||
|
||||
// 三种模式都允许后扫入的总排号覆盖当前扫码区;未确认数据不会入库。
|
||||
this._queryBoxInfo(zongpai);
|
||||
}
|
||||
|
||||
Future<bool> _queryBoxInfo(String zongpai) async {
|
||||
final action = await _apiActions.fetchBoxInfo(zongpai);
|
||||
|
||||
if (!mounted) return false;
|
||||
|
||||
if (!action.success) {
|
||||
_feedbackService.trigger(switch (action.errorKind) {
|
||||
BoxingActionErrorKind.network => FeedbackEvent.networkError,
|
||||
BoxingActionErrorKind.missingApiUrl => FeedbackEvent.submitFailure,
|
||||
_ => FeedbackEvent.scanInvalid,
|
||||
});
|
||||
this._showStatusOverride(
|
||||
action.errorMessage ?? '查询失败',
|
||||
this._dotForActionError(action.errorKind),
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
final result = action.data!;
|
||||
// 判断该总牌号是否已全部装箱完毕
|
||||
final packedQuantity = result.currentZongpaiBoxes.fold<int>(
|
||||
0,
|
||||
(sum, item) => sum + item.quantity,
|
||||
);
|
||||
final totalQuantity = result.quantity ?? 0;
|
||||
final alreadyCompleted =
|
||||
totalQuantity > 0 && packedQuantity >= totalQuantity;
|
||||
|
||||
_feedbackService.trigger(
|
||||
alreadyCompleted
|
||||
? FeedbackEvent.alreadyCompleted
|
||||
: FeedbackEvent.scanValid,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_zongpaiNo = zongpai;
|
||||
_paichanNo = result.paichanNo;
|
||||
_workOrderNo = result.workOrderNo;
|
||||
_erpQuantity = result.quantity;
|
||||
_currentZongpaiBoxes = result.currentZongpaiBoxes;
|
||||
_existingBoxes = result.existingBoxes;
|
||||
_maxBoxNo = result.maxBoxNo;
|
||||
_phase = BoxingPhase.scanned;
|
||||
_isDuplicateBoxNo = false;
|
||||
_editingBox = null;
|
||||
_editingAssignedItemId = null;
|
||||
_editingAssignedQuantity = '';
|
||||
_deletingAssignedItemId = null;
|
||||
_completedJumpBoxNo = null;
|
||||
_statusOverrideText = null;
|
||||
if (_mode == BoxingMode.singleCode) {
|
||||
_paichanSwitchNotice = null;
|
||||
}
|
||||
|
||||
// 根据模式自动填充
|
||||
this._applyAutoFill();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _processAutoScanCodes(List<String> codes) async {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isAutoProcessing = true;
|
||||
_autoWarnings.clear();
|
||||
_statusOverrideText = '正在同步转运数据...';
|
||||
_statusOverrideDot = StatusDotColor.orange;
|
||||
});
|
||||
|
||||
if (_mode == BoxingMode.singleCode) {
|
||||
final code = codes.first;
|
||||
final ok = await this._queryBoxInfo(code);
|
||||
if (!ok && mounted) {
|
||||
setState(() => _autoWarnings.add('总排号 $code 未找到排产号信息,已忽略'));
|
||||
}
|
||||
} else {
|
||||
for (final code in codes) {
|
||||
if (!mounted) return;
|
||||
final queried = await this._queryBoxInfo(code);
|
||||
if (!queried) {
|
||||
if (mounted) {
|
||||
setState(() => _autoWarnings.add('总排号 $code 未找到排产号信息,已忽略'));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!_canSubmit) {
|
||||
if (mounted) {
|
||||
setState(() => _autoWarnings.add('总排号 $code 暂不能装箱,已忽略'));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
final saved = await this._submit();
|
||||
if (!saved && mounted) {
|
||||
setState(() => _autoWarnings.add('总排号 $code 自动装箱失败,请手动处理'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isAutoProcessing = false;
|
||||
_statusOverrideText = null;
|
||||
_statusOverrideDot = null;
|
||||
});
|
||||
_focusQuantityInput();
|
||||
_requestFocus();
|
||||
}
|
||||
|
||||
void _applyAutoFill() {
|
||||
switch (_mode) {
|
||||
case BoxingMode.singleCode:
|
||||
final remaining = _remainingQuantity;
|
||||
if (remaining <= 0) {
|
||||
_boxNoController.clear();
|
||||
_quantityController.clear();
|
||||
_statusOverrideText = '该总排号已全部装箱完毕';
|
||||
_statusOverrideDot = StatusDotColor.red;
|
||||
return;
|
||||
}
|
||||
_boxNoController.text = (_maxBoxNo + 1).toString();
|
||||
_quantityController.text = remaining.toString();
|
||||
_quantityController.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: _quantityController.text.length,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _mode == BoxingMode.singleCode) {
|
||||
_quantityFocusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
case BoxingMode.multiCode:
|
||||
final decision = decideMultiCodePaichanContext(
|
||||
lastPaichanNo: _lastScannedPaichanNo,
|
||||
currentPaichanNo: _paichanNo,
|
||||
currentBoxNoText: _boxNoController.text,
|
||||
maxBoxNo: _maxBoxNo,
|
||||
);
|
||||
if (decision.isSwitched) {
|
||||
_crossPaichaPending = true;
|
||||
_feedbackService.trigger(FeedbackEvent.alreadyCompleted);
|
||||
this._showCrossPaichaDialog();
|
||||
return;
|
||||
} else {
|
||||
_paichanSwitchNotice = null;
|
||||
}
|
||||
final boxNoToApply = decision.boxNoToApply;
|
||||
if (boxNoToApply != null) {
|
||||
_boxNoController.text = boxNoToApply.toString();
|
||||
}
|
||||
_lastScannedPaichanNo = _paichanNo;
|
||||
final remaining = _remainingQuantity;
|
||||
if (remaining <= 0) {
|
||||
_quantityController.clear();
|
||||
if (_manyToOnePackedItems.isEmpty &&
|
||||
_currentZongpaiBoxes.isNotEmpty) {
|
||||
_completedJumpBoxNo = _currentZongpaiBoxes.first.boxNo;
|
||||
}
|
||||
_statusOverrideText = '该总排号已全部装箱完毕';
|
||||
_statusOverrideDot = StatusDotColor.red;
|
||||
return;
|
||||
}
|
||||
_quantityController.text = remaining.toString();
|
||||
_quantityController.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: _quantityController.text.length,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _mode == BoxingMode.multiCode) {
|
||||
_quantityFocusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
_isDuplicateBoxNo = _boxNoIsDuplicate();
|
||||
}
|
||||
|
||||
Future<void> _showCrossPaichaDialog() async {
|
||||
final currentPaicha = _lastScannedPaichanNo ?? '--';
|
||||
final newPaicha = _paichanNo ?? '--';
|
||||
final confirmed = await showCrossPaichanDialog(
|
||||
context: context,
|
||||
currentPaicha: currentPaicha,
|
||||
newPaicha: newPaicha,
|
||||
);
|
||||
if (!mounted) return;
|
||||
confirmed ? this._confirmPaichanSwitch() : this._cancelPaichanSwitch();
|
||||
}
|
||||
|
||||
void _confirmPaichanSwitch() {
|
||||
setState(() {
|
||||
_crossPaichaPending = false;
|
||||
_manyToOnePackedItems.clear();
|
||||
_editingPackedItemId = null;
|
||||
_editingPackedQuantity = '';
|
||||
_deletingPackedItemId = null;
|
||||
_paichanSwitchNotice = '排产号已切换:${_paichanNo ?? "--"}';
|
||||
_boxNoController.text = (_maxBoxNo + 1).toString();
|
||||
_lastScannedPaichanNo = _paichanNo;
|
||||
});
|
||||
_feedbackService.trigger(FeedbackEvent.paichanSwitch);
|
||||
|
||||
// Continue with remaining quantity flow
|
||||
final remaining = _remainingQuantity;
|
||||
if (remaining <= 0) {
|
||||
_quantityController.clear();
|
||||
if (_manyToOnePackedItems.isEmpty && _currentZongpaiBoxes.isNotEmpty) {
|
||||
_completedJumpBoxNo = _currentZongpaiBoxes.first.boxNo;
|
||||
}
|
||||
setState(() {
|
||||
_statusOverrideText = '该总排号已全部装箱完毕';
|
||||
_statusOverrideDot = StatusDotColor.red;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_quantityController.text = remaining.toString();
|
||||
_quantityController.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: _quantityController.text.length,
|
||||
);
|
||||
_isDuplicateBoxNo = _boxNoIsDuplicate();
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _mode == BoxingMode.multiCode) {
|
||||
_quantityFocusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelPaichanSwitch() {
|
||||
setState(() {
|
||||
_crossPaichaPending = false;
|
||||
_zongpaiNo = null;
|
||||
_paichanNo = _lastScannedPaichanNo;
|
||||
_phase = BoxingPhase.waiting;
|
||||
});
|
||||
}
|
||||
}
|
||||
133
lib/pages/boxing/parts/boxing_single_ops_part.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member, unnecessary_this
|
||||
|
||||
part of '../../boxing_page.dart';
|
||||
|
||||
extension _BoxingSingleOpsPart on _BoxingPageState {
|
||||
void _startEditAssigned(CurrentZongpaiBoxData item) {
|
||||
setState(() {
|
||||
_editingAssignedItemId = item.boxItemId;
|
||||
_editingAssignedQuantity = item.quantity.toString();
|
||||
_deletingAssignedItemId = null;
|
||||
_statusOverrideText = null;
|
||||
_statusOverrideDot = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelEditAssigned() {
|
||||
setState(() {
|
||||
_editingAssignedItemId = null;
|
||||
_editingAssignedQuantity = '';
|
||||
});
|
||||
}
|
||||
|
||||
int _maxAssignedQuantity(CurrentZongpaiBoxData item) {
|
||||
return boxing_calculations.maxAssignedQuantity(
|
||||
totalQuantity: _erpQuantity,
|
||||
packedQuantity: _packedQuantity,
|
||||
itemQuantity: item.quantity,
|
||||
);
|
||||
}
|
||||
|
||||
void _refreshSingleCodeNewInput({bool focusQuantity = true}) {
|
||||
_boxNoController.text = (_maxBoxNo + 1).toString();
|
||||
final remaining = _remainingQuantity;
|
||||
if (remaining > 0) {
|
||||
_quantityController.text = remaining.toString();
|
||||
_quantityController.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: _quantityController.text.length,
|
||||
);
|
||||
if (focusQuantity) {
|
||||
_focusQuantityInput();
|
||||
}
|
||||
} else {
|
||||
_quantityController.clear();
|
||||
}
|
||||
_isDuplicateBoxNo = false;
|
||||
}
|
||||
|
||||
Future<void> _saveAssignedItem(CurrentZongpaiBoxData item) async {
|
||||
final quantity = int.tryParse(_editingAssignedQuantity);
|
||||
if (quantity == null || quantity <= 0) {
|
||||
this._showStatusOverride(
|
||||
'请输入有效数量',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (quantity > this._maxAssignedQuantity(item)) {
|
||||
this._showStatusOverride(
|
||||
'超出可装数量上限',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
final action = await _apiActions.updateBoxRecord(
|
||||
boxItemId: item.boxItemId,
|
||||
boxNo: item.boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
if (action.success) {
|
||||
setState(() {
|
||||
_currentZongpaiBoxes = _currentZongpaiBoxes.map((box) {
|
||||
if (box.boxItemId != item.boxItemId) return box;
|
||||
return CurrentZongpaiBoxData(
|
||||
boxItemId: box.boxItemId,
|
||||
boxNo: box.boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
}).toList();
|
||||
this._replaceExistingBoxItem(item, item.boxNo, quantity);
|
||||
_editingAssignedItemId = null;
|
||||
_editingAssignedQuantity = '';
|
||||
this._refreshSingleCodeNewInput();
|
||||
});
|
||||
this._showStatusOverride(
|
||||
'修改成功',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
} else {
|
||||
this._showActionError(action, fallback: '修改失败');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteAssignedItem(CurrentZongpaiBoxData item) async {
|
||||
setState(() => _isSubmitting = true);
|
||||
final action = await _apiActions.deleteBoxRecord(boxItemId: item.boxItemId);
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
if (action.success) {
|
||||
_currentZongpaiBoxes = _currentZongpaiBoxes
|
||||
.where((box) => box.boxItemId != item.boxItemId)
|
||||
.toList();
|
||||
this._removeExistingBoxItem(item.boxItemId);
|
||||
if (_editingAssignedItemId == item.boxItemId) {
|
||||
_editingAssignedItemId = null;
|
||||
_editingAssignedQuantity = '';
|
||||
}
|
||||
_deletingAssignedItemId = null;
|
||||
this._refreshSingleCodeNewInput();
|
||||
}
|
||||
});
|
||||
|
||||
if (action.success) {
|
||||
this._showStatusOverride(
|
||||
'删除成功',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
} else {
|
||||
this._showActionError(action, fallback: '删除失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
86
lib/pages/boxing/parts/boxing_status_part.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member, unnecessary_this
|
||||
|
||||
part of '../../boxing_page.dart';
|
||||
|
||||
extension _BoxingStatusPart on _BoxingPageState {
|
||||
StatusDotColor _dotForActionError(BoxingActionErrorKind? kind) {
|
||||
return switch (kind) {
|
||||
BoxingActionErrorKind.network => StatusDotColor.yellow,
|
||||
BoxingActionErrorKind.duplicate => StatusDotColor.amber,
|
||||
_ => StatusDotColor.red,
|
||||
};
|
||||
}
|
||||
|
||||
void _showActionError<T>(
|
||||
BoxingActionResult<T> action, {
|
||||
required String fallback,
|
||||
}) {
|
||||
_feedbackService.trigger(switch (action.errorKind) {
|
||||
BoxingActionErrorKind.network => FeedbackEvent.networkError,
|
||||
BoxingActionErrorKind.duplicate => FeedbackEvent.duplicateBoxNo,
|
||||
_ => FeedbackEvent.submitFailure,
|
||||
});
|
||||
this._showStatusOverride(
|
||||
action.errorMessage ?? fallback,
|
||||
this._dotForActionError(action.errorKind),
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
|
||||
void _showStatusOverride(String text, StatusDotColor dot, Duration duration) {
|
||||
setState(() {
|
||||
_statusOverrideText = text;
|
||||
_statusOverrideDot = dot;
|
||||
});
|
||||
Future.delayed(duration, () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusOverrideText = null;
|
||||
_statusOverrideDot = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _jumpToCompletedBox() {
|
||||
final boxNo = _completedJumpBoxNo;
|
||||
if (boxNo == null) return;
|
||||
setState(() {
|
||||
_boxNoController.text = boxNo.toString();
|
||||
_completedJumpBoxNo = null;
|
||||
_statusOverrideText = null;
|
||||
_statusOverrideDot = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _openDetail() {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BoxingDetailPage(
|
||||
paichanNo: _paichanNo ?? '',
|
||||
existingBoxes: _existingBoxes,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _updateBaseStatus() {
|
||||
final status = boxingStatusFor(
|
||||
isAutoProcessing: _isAutoProcessing,
|
||||
isSubmitting: _isSubmitting,
|
||||
isDuplicateBoxNo: _isDuplicateBoxNo,
|
||||
quantityTooHigh: _quantityTooHigh,
|
||||
isEditingAssigned: _editingAssignedItemId != null,
|
||||
isDeletingAssigned: _deletingAssignedItemId != null,
|
||||
hasPaichanSwitchNotice: _paichanSwitchNotice != null,
|
||||
phase: _phase,
|
||||
isMultiCode: _mode == BoxingMode.multiCode,
|
||||
hasVisibleManyToOneItems: _visibleManyToOnePackedItems.isNotEmpty,
|
||||
remainingQuantity: _remainingQuantity,
|
||||
boxNoText: _boxNoController.text,
|
||||
);
|
||||
_statusDot = status.dot;
|
||||
_statusText = status.text;
|
||||
}
|
||||
}
|
||||
161
lib/pages/boxing/parts/boxing_submit_part.dart
Normal file
@@ -0,0 +1,161 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member, unnecessary_this
|
||||
|
||||
part of '../../boxing_page.dart';
|
||||
|
||||
extension _BoxingSubmitPart on _BoxingPageState {
|
||||
Future<bool> _submit() async {
|
||||
if (!_canSubmit) return false;
|
||||
|
||||
final boxNo = int.parse(_boxNoController.text);
|
||||
final quantity = int.parse(_quantityController.text);
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
final editing = _editingBox;
|
||||
final action = editing == null
|
||||
? await _apiActions.saveBoxRecord(
|
||||
zongpaiNo: _zongpaiNo!,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
)
|
||||
: await _apiActions.updateBoxRecord(
|
||||
boxItemId: editing.boxItemId,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
|
||||
if (!mounted) return false;
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
|
||||
if (action.success) {
|
||||
if (editing == null) {
|
||||
this._onSubmitSuccess(boxNo, quantity, action.data!.boxItemId);
|
||||
} else {
|
||||
this._onUpdateSuccess(editing, boxNo, quantity);
|
||||
}
|
||||
return true;
|
||||
} else if (action.errorKind == BoxingActionErrorKind.duplicate) {
|
||||
_feedbackService.trigger(FeedbackEvent.duplicateBoxNo);
|
||||
this._showStatusOverride(
|
||||
'该总排号在箱号 ${action.boxNo ?? ""} 已存在,请重新输入',
|
||||
StatusDotColor.amber,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return false;
|
||||
} else {
|
||||
this._showActionError(action, fallback: '提交失败');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void _onSubmitSuccess(int boxNo, int quantity, int? boxItemId) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||
|
||||
setState(() {
|
||||
if (_mode == BoxingMode.singleCode && boxItemId != null) {
|
||||
_currentZongpaiBoxes = List.from(_currentZongpaiBoxes)
|
||||
..add(
|
||||
CurrentZongpaiBoxData(
|
||||
boxItemId: boxItemId,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
),
|
||||
);
|
||||
}
|
||||
this._addExistingBoxItem(boxNo, quantity, boxItemId);
|
||||
_maxBoxNo = _maxBoxNo > boxNo ? _maxBoxNo : boxNo;
|
||||
});
|
||||
|
||||
switch (_mode) {
|
||||
case BoxingMode.singleCode:
|
||||
final remaining = _remainingQuantity;
|
||||
if (remaining <= 0) {
|
||||
this._showStatusOverride(
|
||||
'装箱成功',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
} else {
|
||||
setState(() {
|
||||
_phase = BoxingPhase.scanned;
|
||||
_boxNoController.text = (_maxBoxNo + 1).toString();
|
||||
_quantityController.text = remaining.toString();
|
||||
_quantityController.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: _quantityController.text.length,
|
||||
);
|
||||
_isDuplicateBoxNo = false;
|
||||
});
|
||||
_focusQuantityInput();
|
||||
this._showStatusOverride(
|
||||
'请继续完成剩余数量装箱',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
}
|
||||
|
||||
case BoxingMode.multiCode:
|
||||
_lastScannedPaichanNo = _paichanNo;
|
||||
setState(() {
|
||||
if (boxItemId != null) {
|
||||
_manyToOnePackedItems.add(
|
||||
ManyToOnePackedItem(
|
||||
boxItemId: boxItemId,
|
||||
zongpaiNo: _zongpaiNo!,
|
||||
paichanNo: _paichanNo,
|
||||
workOrderNo: _workOrderNo,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
totalQuantity: _erpQuantity,
|
||||
),
|
||||
);
|
||||
}
|
||||
_phase = BoxingPhase.waiting;
|
||||
_zongpaiNo = null;
|
||||
_editingPackedItemId = null;
|
||||
_editingPackedQuantity = '';
|
||||
_deletingPackedItemId = null;
|
||||
});
|
||||
this._showStatusOverride(
|
||||
'装箱成功,请继续扫码',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onUpdateSuccess(
|
||||
CurrentZongpaiBoxData editing,
|
||||
int boxNo,
|
||||
int quantity,
|
||||
) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||
|
||||
setState(() {
|
||||
_currentZongpaiBoxes = _currentZongpaiBoxes.map((item) {
|
||||
if (item.boxItemId != editing.boxItemId) return item;
|
||||
return CurrentZongpaiBoxData(
|
||||
boxItemId: item.boxItemId,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
}).toList();
|
||||
this._replaceExistingBoxItem(editing, boxNo, quantity);
|
||||
_editingBox = null;
|
||||
_boxNoController.text = (_maxBoxNo + 1).toString();
|
||||
final remaining = _remainingQuantity;
|
||||
if (remaining > 0) {
|
||||
_quantityController.text = remaining.toString();
|
||||
} else {
|
||||
_quantityController.clear();
|
||||
}
|
||||
_isDuplicateBoxNo = false;
|
||||
});
|
||||
this._showStatusOverride(
|
||||
'修改成功',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
}
|
||||
}
|
||||
178
lib/pages/boxing/widgets/boxing_notice_banners.dart
Normal file
@@ -0,0 +1,178 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
||||
|
||||
class BoxingNoticeBanners extends StatelessWidget {
|
||||
final bool isAutoProcessing;
|
||||
final List<String> autoWarnings;
|
||||
final String? paichanSwitchNotice;
|
||||
final bool isDuplicateBoxNo;
|
||||
final bool quantityTooHigh;
|
||||
final BoxingPhase phase;
|
||||
final String boxNoText;
|
||||
final int remainingQuantity;
|
||||
final String? zongpaiNo;
|
||||
final int? completedJumpBoxNo;
|
||||
final VoidCallback onJumpToCompletedBox;
|
||||
|
||||
const BoxingNoticeBanners({
|
||||
super.key,
|
||||
required this.isAutoProcessing,
|
||||
required this.autoWarnings,
|
||||
required this.paichanSwitchNotice,
|
||||
required this.isDuplicateBoxNo,
|
||||
required this.quantityTooHigh,
|
||||
required this.phase,
|
||||
required this.boxNoText,
|
||||
required this.remainingQuantity,
|
||||
required this.zongpaiNo,
|
||||
required this.completedJumpBoxNo,
|
||||
required this.onJumpToCompletedBox,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final banners = <Widget>[];
|
||||
if (isAutoProcessing) {
|
||||
banners.add(
|
||||
_NoticeBanner(
|
||||
icon: SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
color: Colors.orange.shade700,
|
||||
),
|
||||
),
|
||||
text: '正在同步转运数据...',
|
||||
background: Colors.orange.shade50,
|
||||
foreground: Colors.orange.shade900,
|
||||
border: Colors.orange.shade100,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (autoWarnings.isNotEmpty) {
|
||||
banners.add(
|
||||
_NoticeBanner(
|
||||
icon: Icon(
|
||||
Icons.warning_amber,
|
||||
size: 14,
|
||||
color: Colors.amber.shade900,
|
||||
),
|
||||
text: autoWarnings.join(';'),
|
||||
background: Colors.amber.shade100,
|
||||
foreground: Colors.amber.shade900,
|
||||
border: Colors.amber.shade200,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (paichanSwitchNotice != null) {
|
||||
banners.add(
|
||||
_NoticeBanner(
|
||||
icon: Icon(Icons.info_outline, size: 14, color: Colors.blue.shade900),
|
||||
text: paichanSwitchNotice!,
|
||||
background: Colors.blue.shade50,
|
||||
foreground: Colors.blue.shade900,
|
||||
border: Colors.blue.shade100,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (isDuplicateBoxNo && phase == BoxingPhase.scanned) {
|
||||
banners.add(
|
||||
_NoticeBanner(
|
||||
icon: Icon(
|
||||
Icons.warning_amber,
|
||||
size: 14,
|
||||
color: Colors.amber.shade900,
|
||||
),
|
||||
text: '箱号 $boxNoText 已存在,请重新输入',
|
||||
background: Colors.amber.shade100,
|
||||
foreground: Colors.amber.shade900,
|
||||
border: Colors.amber.shade200,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (quantityTooHigh && phase == BoxingPhase.scanned) {
|
||||
banners.add(
|
||||
_NoticeBanner(
|
||||
icon: Icon(Icons.close, size: 14, color: Colors.red.shade800),
|
||||
text: '超出可装数量上限(最多可装 $remainingQuantity 件)',
|
||||
background: Colors.red.shade50,
|
||||
foreground: Colors.red.shade800,
|
||||
border: Colors.red.shade100,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (phase == BoxingPhase.scanned &&
|
||||
zongpaiNo != null &&
|
||||
remainingQuantity <= 0) {
|
||||
final jumpBoxNo = completedJumpBoxNo;
|
||||
if (jumpBoxNo != null) {
|
||||
banners.add(
|
||||
GestureDetector(
|
||||
onTap: onJumpToCompletedBox,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: _NoticeBanner(
|
||||
icon: Icon(Icons.warning, size: 14, color: Colors.amber.shade800),
|
||||
text: '$zongpaiNo 已完成装箱。(P4跳转所在箱号)',
|
||||
background: Colors.amber.shade50,
|
||||
foreground: Colors.amber.shade800,
|
||||
border: Colors.amber.shade200,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
banners.add(
|
||||
_NoticeBanner(
|
||||
icon: Icon(Icons.warning, size: 14, color: Colors.red.shade800),
|
||||
text: '$zongpaiNo 已全部装箱完毕,无需操作',
|
||||
background: Colors.red.shade50,
|
||||
foreground: Colors.red.shade800,
|
||||
border: Colors.red.shade100,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Column(mainAxisSize: MainAxisSize.min, children: banners);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoticeBanner extends StatelessWidget {
|
||||
final Widget icon;
|
||||
final String text;
|
||||
final Color background;
|
||||
final Color foreground;
|
||||
final Color border;
|
||||
|
||||
const _NoticeBanner({
|
||||
required this.icon,
|
||||
required this.text,
|
||||
required this.background,
|
||||
required this.foreground,
|
||||
required this.border,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
border: Border(bottom: BorderSide(color: border)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
icon,
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(fontSize: 12, color: foreground),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
161
lib/pages/boxing/widgets/boxing_shared_widgets.dart
Normal file
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const boxingHeaderStyle = TextStyle(fontSize: 10, fontWeight: FontWeight.w700);
|
||||
|
||||
class BoxingDetailButton extends StatelessWidget {
|
||||
final bool enabled;
|
||||
final ColorScheme colorScheme;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const BoxingDetailButton({
|
||||
super.key,
|
||||
required this.enabled,
|
||||
required this.colorScheme,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final foreground = enabled ? colorScheme.primary : Colors.grey.shade400;
|
||||
final background = enabled ? Colors.blue.shade50 : Colors.grey.shade100;
|
||||
final border = enabled ? colorScheme.primary : Colors.grey.shade300;
|
||||
|
||||
return SizedBox(
|
||||
height: 34,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: enabled ? onPressed : null,
|
||||
icon: Icon(Icons.receipt_long, size: 15, color: foreground),
|
||||
label: Text(
|
||||
'详情',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: foreground,
|
||||
),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
backgroundColor: background,
|
||||
side: BorderSide(color: border),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BoxingModePill extends StatelessWidget {
|
||||
final bool isSingle;
|
||||
final String label;
|
||||
final bool enabled;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const BoxingModePill({
|
||||
super.key,
|
||||
required this.isSingle,
|
||||
required this.label,
|
||||
required this.enabled,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bgColor = isSingle ? Colors.blue.shade700 : Colors.orange.shade700;
|
||||
|
||||
return Material(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: bgColor,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: enabled ? onTap : null,
|
||||
child: Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.swap_horiz, color: Colors.white, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'P2',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.88),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CompactIconButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color? color;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const CompactIconButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
this.color,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: IconButton(
|
||||
icon: Icon(icon, size: 16),
|
||||
color: color,
|
||||
onPressed: onTap,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
InputDecoration compactInputDecoration({
|
||||
required bool disabled,
|
||||
required Color borderColor,
|
||||
}) {
|
||||
final disabledBorder = OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
);
|
||||
final activeBorder = OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(color: borderColor, width: 1.5),
|
||||
);
|
||||
return InputDecoration(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
border: activeBorder,
|
||||
enabledBorder: activeBorder,
|
||||
focusedBorder: activeBorder,
|
||||
disabledBorder: disabledBorder,
|
||||
filled: disabled,
|
||||
fillColor: Colors.grey.shade100,
|
||||
);
|
||||
}
|
||||
606
lib/pages/boxing/widgets/multi_code_body.dart
Normal file
@@ -0,0 +1,606 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
||||
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
|
||||
import 'package:pad_scanner/pages/boxing/widgets/multi_packed_row.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
class MultiCodeBody extends StatelessWidget {
|
||||
final bool hasScan;
|
||||
final String? zongpaiNo;
|
||||
final String? paichanNo;
|
||||
final String? workOrderNo;
|
||||
final int? erpQuantity;
|
||||
final int maxBoxNo;
|
||||
final List<BoxDetailData> existingBoxes;
|
||||
final List<ManyToOnePackedItem> visibleItems;
|
||||
final TextEditingController boxNoController;
|
||||
final TextEditingController quantityController;
|
||||
final FocusNode boxNoFocusNode;
|
||||
final FocusNode quantityFocusNode;
|
||||
final bool isSubmitting;
|
||||
final bool quantityTooHigh;
|
||||
final bool canSubmit;
|
||||
final int? editingPackedItemId;
|
||||
final String editingPackedQuantity;
|
||||
final int? deletingPackedItemId;
|
||||
final VoidCallback onOpenDetail;
|
||||
final VoidCallback onSubmitFromKeyboard;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback onQuantityChanged;
|
||||
final VoidCallback onFinishBox;
|
||||
final ValueChanged<String> onEditingPackedQuantityChanged;
|
||||
final ValueChanged<ManyToOnePackedItem> onSavePackedItem;
|
||||
final VoidCallback onCancelEditPackedItem;
|
||||
final ValueChanged<ManyToOnePackedItem> onStartEditPackedItem;
|
||||
final ValueChanged<ManyToOnePackedItem> onStartDeletePackedItem;
|
||||
final ValueChanged<ManyToOnePackedItem> onDeletePackedItem;
|
||||
final VoidCallback onCancelDeletePackedItem;
|
||||
|
||||
const MultiCodeBody({
|
||||
super.key,
|
||||
required this.hasScan,
|
||||
required this.zongpaiNo,
|
||||
required this.paichanNo,
|
||||
required this.workOrderNo,
|
||||
required this.erpQuantity,
|
||||
required this.maxBoxNo,
|
||||
required this.existingBoxes,
|
||||
required this.visibleItems,
|
||||
required this.boxNoController,
|
||||
required this.quantityController,
|
||||
required this.boxNoFocusNode,
|
||||
required this.quantityFocusNode,
|
||||
required this.isSubmitting,
|
||||
required this.quantityTooHigh,
|
||||
required this.canSubmit,
|
||||
required this.editingPackedItemId,
|
||||
required this.editingPackedQuantity,
|
||||
required this.deletingPackedItemId,
|
||||
required this.onOpenDetail,
|
||||
required this.onSubmitFromKeyboard,
|
||||
required this.onSubmit,
|
||||
required this.onQuantityChanged,
|
||||
required this.onFinishBox,
|
||||
required this.onEditingPackedQuantityChanged,
|
||||
required this.onSavePackedItem,
|
||||
required this.onCancelEditPackedItem,
|
||||
required this.onStartEditPackedItem,
|
||||
required this.onStartDeletePackedItem,
|
||||
required this.onDeletePackedItem,
|
||||
required this.onCancelDeletePackedItem,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
_MultiHeaderBar(
|
||||
paichanNo: paichanNo,
|
||||
maxBoxNo: maxBoxNo,
|
||||
existingBoxes: existingBoxes,
|
||||
boxNoController: boxNoController,
|
||||
boxNoFocusNode: boxNoFocusNode,
|
||||
isSubmitting: isSubmitting,
|
||||
onSubmitFromKeyboard: onSubmitFromKeyboard,
|
||||
onOpenDetail: onOpenDetail,
|
||||
),
|
||||
const _MultiListHeader(),
|
||||
Expanded(
|
||||
child: visibleItems.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'尚未装入任何物料',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey.shade500,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: visibleItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = visibleItems[index];
|
||||
return MultiPackedRow(
|
||||
item: item,
|
||||
editing: editingPackedItemId == item.boxItemId,
|
||||
deleting: deletingPackedItemId == item.boxItemId,
|
||||
isSubmitting: isSubmitting,
|
||||
editingQuantity: editingPackedQuantity,
|
||||
onEditingQuantityChanged: onEditingPackedQuantityChanged,
|
||||
onSave: () => onSavePackedItem(item),
|
||||
onCancelEdit: onCancelEditPackedItem,
|
||||
onStartEdit: () => onStartEditPackedItem(item),
|
||||
onStartDelete: () => onStartDeletePackedItem(item),
|
||||
onDelete: () => onDeletePackedItem(item),
|
||||
onCancelDelete: onCancelDeletePackedItem,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
_MultiBottomArea(
|
||||
hasScan: hasScan,
|
||||
hasItems: visibleItems.isNotEmpty,
|
||||
zongpaiNo: zongpaiNo,
|
||||
workOrderNo: workOrderNo,
|
||||
erpQuantity: erpQuantity,
|
||||
quantityController: quantityController,
|
||||
quantityFocusNode: quantityFocusNode,
|
||||
isSubmitting: isSubmitting,
|
||||
quantityTooHigh: quantityTooHigh,
|
||||
canSubmit: canSubmit,
|
||||
onSubmitFromKeyboard: onSubmitFromKeyboard,
|
||||
onSubmit: onSubmit,
|
||||
onQuantityChanged: onQuantityChanged,
|
||||
onFinishBox: onFinishBox,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MultiHeaderBar extends StatelessWidget {
|
||||
final String? paichanNo;
|
||||
final int maxBoxNo;
|
||||
final List<BoxDetailData> existingBoxes;
|
||||
final TextEditingController boxNoController;
|
||||
final FocusNode boxNoFocusNode;
|
||||
final bool isSubmitting;
|
||||
final VoidCallback onSubmitFromKeyboard;
|
||||
final VoidCallback onOpenDetail;
|
||||
|
||||
const _MultiHeaderBar({
|
||||
required this.paichanNo,
|
||||
required this.maxBoxNo,
|
||||
required this.existingBoxes,
|
||||
required this.boxNoController,
|
||||
required this.boxNoFocusNode,
|
||||
required this.isSubmitting,
|
||||
required this.onSubmitFromKeyboard,
|
||||
required this.onOpenDetail,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasDetail = existingBoxes.isNotEmpty;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'箱号',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
SizedBox(
|
||||
width: 52,
|
||||
height: 32,
|
||||
child: TextField(
|
||||
controller: boxNoController,
|
||||
focusNode: boxNoFocusNode,
|
||||
enabled: !isSubmitting,
|
||||
keyboardType: TextInputType.none,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
textAlign: TextAlign.center,
|
||||
onEditingComplete: () {},
|
||||
onSubmitted: (_) => onSubmitFromKeyboard(),
|
||||
decoration: InputDecoration(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(color: Colors.grey.shade400),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: Colors.blue, width: 1.5),
|
||||
),
|
||||
),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 28,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 10),
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
paichanNo ?? '--',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
'已有 ${existingBoxes.length} 箱 · 最大箱号 $maxBoxNo',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey.shade500),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
BoxingDetailButton(
|
||||
enabled: hasDetail,
|
||||
colorScheme: Theme.of(context).colorScheme,
|
||||
onPressed: onOpenDetail,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MultiListHeader extends StatelessWidget {
|
||||
const _MultiListHeader();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 28,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade400)),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 26,
|
||||
child: Text(
|
||||
'总排号',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 30,
|
||||
child: Text(
|
||||
'工令号',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 23,
|
||||
child: Text(
|
||||
'数量',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 54,
|
||||
child: Text(
|
||||
'操作',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MultiBottomArea extends StatelessWidget {
|
||||
final bool hasScan;
|
||||
final bool hasItems;
|
||||
final String? zongpaiNo;
|
||||
final String? workOrderNo;
|
||||
final int? erpQuantity;
|
||||
final TextEditingController quantityController;
|
||||
final FocusNode quantityFocusNode;
|
||||
final bool isSubmitting;
|
||||
final bool quantityTooHigh;
|
||||
final bool canSubmit;
|
||||
final VoidCallback onSubmitFromKeyboard;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback onQuantityChanged;
|
||||
final VoidCallback onFinishBox;
|
||||
|
||||
const _MultiBottomArea({
|
||||
required this.hasScan,
|
||||
required this.hasItems,
|
||||
required this.zongpaiNo,
|
||||
required this.workOrderNo,
|
||||
required this.erpQuantity,
|
||||
required this.quantityController,
|
||||
required this.quantityFocusNode,
|
||||
required this.isSubmitting,
|
||||
required this.quantityTooHigh,
|
||||
required this.canSubmit,
|
||||
required this.onSubmitFromKeyboard,
|
||||
required this.onSubmit,
|
||||
required this.onQuantityChanged,
|
||||
required this.onFinishBox,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_MultiScanBar(
|
||||
hasScan: hasScan,
|
||||
zongpaiNo: zongpaiNo,
|
||||
workOrderNo: workOrderNo,
|
||||
erpQuantity: erpQuantity,
|
||||
),
|
||||
_MultiSubmitRow(
|
||||
hasScan: hasScan,
|
||||
hasItems: hasItems,
|
||||
quantityController: quantityController,
|
||||
quantityFocusNode: quantityFocusNode,
|
||||
isSubmitting: isSubmitting,
|
||||
quantityTooHigh: quantityTooHigh,
|
||||
canSubmit: canSubmit,
|
||||
onSubmitFromKeyboard: onSubmitFromKeyboard,
|
||||
onSubmit: onSubmit,
|
||||
onQuantityChanged: onQuantityChanged,
|
||||
onFinishBox: onFinishBox,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MultiScanBar extends StatelessWidget {
|
||||
final bool hasScan;
|
||||
final String? zongpaiNo;
|
||||
final String? workOrderNo;
|
||||
final int? erpQuantity;
|
||||
|
||||
const _MultiScanBar({
|
||||
required this.hasScan,
|
||||
required this.zongpaiNo,
|
||||
required this.workOrderNo,
|
||||
required this.erpQuantity,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!hasScan) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
color: Colors.grey.shade50,
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.qr_code_scanner, color: Colors.grey, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'请扫描执行卡二维码',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.green.shade200)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle, color: Colors.green, size: 15),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${zongpaiNo ?? ""} · ${workOrderNo ?? "--"} · ${erpQuantity ?? "--"}件',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.green.shade700,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MultiSubmitRow extends StatelessWidget {
|
||||
final bool hasScan;
|
||||
final bool hasItems;
|
||||
final TextEditingController quantityController;
|
||||
final FocusNode quantityFocusNode;
|
||||
final bool isSubmitting;
|
||||
final bool quantityTooHigh;
|
||||
final bool canSubmit;
|
||||
final VoidCallback onSubmitFromKeyboard;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback onQuantityChanged;
|
||||
final VoidCallback onFinishBox;
|
||||
|
||||
const _MultiSubmitRow({
|
||||
required this.hasScan,
|
||||
required this.hasItems,
|
||||
required this.quantityController,
|
||||
required this.quantityFocusNode,
|
||||
required this.isSubmitting,
|
||||
required this.quantityTooHigh,
|
||||
required this.canSubmit,
|
||||
required this.onSubmitFromKeyboard,
|
||||
required this.onSubmit,
|
||||
required this.onQuantityChanged,
|
||||
required this.onFinishBox,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 7, 12, 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'数量',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: hasScan ? Colors.black54 : Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
SizedBox(
|
||||
width: 52,
|
||||
height: 34,
|
||||
child: TextField(
|
||||
controller: quantityController,
|
||||
focusNode: quantityFocusNode,
|
||||
enabled: hasScan && !isSubmitting,
|
||||
keyboardType: TextInputType.none,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
textAlign: TextAlign.center,
|
||||
onChanged: (_) => onQuantityChanged(),
|
||||
onEditingComplete: () {},
|
||||
onSubmitted: (_) => onSubmitFromKeyboard(),
|
||||
decoration: InputDecoration(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(
|
||||
color: quantityTooHigh ? Colors.red : Colors.grey.shade300,
|
||||
),
|
||||
),
|
||||
disabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
filled: !hasScan,
|
||||
fillColor: Colors.grey.shade100,
|
||||
),
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
height: 34,
|
||||
child: ElevatedButton(
|
||||
onPressed: canSubmit ? onSubmit : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: canSubmit
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade200,
|
||||
foregroundColor: canSubmit
|
||||
? Colors.white
|
||||
: Colors.grey.shade400,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
child: isSubmitting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'确认',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 34,
|
||||
child: OutlinedButton(
|
||||
onPressed: hasItems ? onFinishBox : null,
|
||||
style: OutlinedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
side: BorderSide(
|
||||
color: hasItems
|
||||
? Colors.grey.shade600
|
||||
: Colors.grey.shade300,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'完成本箱',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: hasItems ? Colors.black87 : Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: hasItems
|
||||
? Colors.grey.shade200
|
||||
: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
'P3',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: hasItems
|
||||
? Colors.black54
|
||||
: Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
239
lib/pages/boxing/widgets/multi_packed_row.dart
Normal file
@@ -0,0 +1,239 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
||||
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
|
||||
|
||||
class MultiPackedRow extends StatelessWidget {
|
||||
final ManyToOnePackedItem item;
|
||||
final bool editing;
|
||||
final bool deleting;
|
||||
final bool isSubmitting;
|
||||
final String editingQuantity;
|
||||
final ValueChanged<String> onEditingQuantityChanged;
|
||||
final VoidCallback onSave;
|
||||
final VoidCallback onCancelEdit;
|
||||
final VoidCallback onStartEdit;
|
||||
final VoidCallback onStartDelete;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onCancelDelete;
|
||||
|
||||
const MultiPackedRow({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.editing,
|
||||
required this.deleting,
|
||||
required this.isSubmitting,
|
||||
required this.editingQuantity,
|
||||
required this.onEditingQuantityChanged,
|
||||
required this.onSave,
|
||||
required this.onCancelEdit,
|
||||
required this.onStartEdit,
|
||||
required this.onStartDelete,
|
||||
required this.onDelete,
|
||||
required this.onCancelDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final totalText = item.totalQuantity?.toString() ?? '--';
|
||||
final barColor = editing
|
||||
? Colors.amber
|
||||
: (deleting ? Colors.red : Colors.green);
|
||||
|
||||
final Color bgColor;
|
||||
if (editing) {
|
||||
bgColor = Colors.yellow.shade50;
|
||||
} else if (deleting) {
|
||||
bgColor = Colors.red.shade50;
|
||||
} else {
|
||||
bgColor = Colors.transparent;
|
||||
}
|
||||
|
||||
final rowStyle = TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
constraints: const BoxConstraints(minHeight: 36),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
border: Border(
|
||||
left: BorderSide(color: barColor, width: 3),
|
||||
bottom: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 26,
|
||||
child: Text(
|
||||
item.zongpaiNo,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 30,
|
||||
child: Text(
|
||||
item.workOrderNo ?? '--',
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 23,
|
||||
child: editing
|
||||
? SizedBox(
|
||||
width: 52,
|
||||
height: 26,
|
||||
child: TextField(
|
||||
keyboardType: TextInputType.none,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
textAlign: TextAlign.center,
|
||||
controller:
|
||||
TextEditingController(text: editingQuantity)
|
||||
..selection = TextSelection.collapsed(
|
||||
offset: editingQuantity.length,
|
||||
),
|
||||
onChanged: onEditingQuantityChanged,
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(
|
||||
color: Colors.amber,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'${item.quantity} / $totalText',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: deleting ? Colors.red : Colors.green.shade700,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 54,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (editing) ...[
|
||||
CompactIconButton(
|
||||
icon: Icons.check,
|
||||
color: Colors.green,
|
||||
onTap: isSubmitting ? null : onSave,
|
||||
),
|
||||
CompactIconButton(
|
||||
icon: Icons.close,
|
||||
color: Colors.grey,
|
||||
onTap: isSubmitting ? null : onCancelEdit,
|
||||
),
|
||||
] else ...[
|
||||
CompactIconButton(
|
||||
icon: Icons.edit,
|
||||
color: Colors.grey.shade700,
|
||||
onTap: isSubmitting ? null : onStartEdit,
|
||||
),
|
||||
CompactIconButton(
|
||||
icon: Icons.delete_outline,
|
||||
color: Colors.red,
|
||||
onTap: isSubmitting ? null : onStartDelete,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (deleting)
|
||||
Container(
|
||||
height: 28,
|
||||
padding: const EdgeInsets.only(left: 15, right: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.red.shade200)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'确认删除?',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.red,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: isSubmitting ? null : onDelete,
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
backgroundColor: Colors.red.shade100,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'删除',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
TextButton(
|
||||
onPressed: isSubmitting ? null : onCancelDelete,
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text(
|
||||
'取消',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
231
lib/pages/boxing/widgets/single_assigned_row.dart
Normal file
@@ -0,0 +1,231 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
class SingleAssignedRow extends StatelessWidget {
|
||||
final CurrentZongpaiBoxData item;
|
||||
final String? workOrderNo;
|
||||
final bool editing;
|
||||
final bool deleting;
|
||||
final bool isSubmitting;
|
||||
final String editingQuantity;
|
||||
final ValueChanged<String> onEditingQuantityChanged;
|
||||
final VoidCallback onSave;
|
||||
final VoidCallback onCancelEdit;
|
||||
final VoidCallback onStartEdit;
|
||||
final VoidCallback onStartDelete;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onCancelDelete;
|
||||
|
||||
const SingleAssignedRow({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.workOrderNo,
|
||||
required this.editing,
|
||||
required this.deleting,
|
||||
required this.isSubmitting,
|
||||
required this.editingQuantity,
|
||||
required this.onEditingQuantityChanged,
|
||||
required this.onSave,
|
||||
required this.onCancelEdit,
|
||||
required this.onStartEdit,
|
||||
required this.onStartDelete,
|
||||
required this.onDelete,
|
||||
required this.onCancelDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final barColor = editing
|
||||
? Colors.amber
|
||||
: (deleting ? Colors.red : Colors.green);
|
||||
|
||||
final Color bgColor;
|
||||
if (editing) {
|
||||
bgColor = Colors.yellow.shade50;
|
||||
} else if (deleting) {
|
||||
bgColor = Colors.red.shade50;
|
||||
} else {
|
||||
bgColor = Colors.transparent;
|
||||
}
|
||||
|
||||
final rowStyle = TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
constraints: const BoxConstraints(minHeight: 36),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
border: Border(
|
||||
left: BorderSide(color: barColor, width: 3),
|
||||
bottom: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 22,
|
||||
child: Text(
|
||||
'${item.boxNo} 号箱',
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 30,
|
||||
child: Text(
|
||||
workOrderNo ?? '--',
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 23,
|
||||
child: editing
|
||||
? SizedBox(
|
||||
width: 52,
|
||||
height: 28,
|
||||
child: TextField(
|
||||
keyboardType: TextInputType.none,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
textAlign: TextAlign.center,
|
||||
controller:
|
||||
TextEditingController(text: editingQuantity)
|
||||
..selection = TextSelection.collapsed(
|
||||
offset: editingQuantity.length,
|
||||
),
|
||||
onChanged: onEditingQuantityChanged,
|
||||
onSubmitted: (_) => onSave(),
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
borderSide: const BorderSide(
|
||||
color: Colors.amber,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'${item.quantity} 件',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: deleting ? Colors.red : Colors.black54,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 54,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (editing) ...[
|
||||
CompactIconButton(
|
||||
icon: Icons.check,
|
||||
color: Colors.green,
|
||||
onTap: isSubmitting ? null : onSave,
|
||||
),
|
||||
CompactIconButton(
|
||||
icon: Icons.close,
|
||||
color: Colors.grey,
|
||||
onTap: isSubmitting ? null : onCancelEdit,
|
||||
),
|
||||
] else ...[
|
||||
CompactIconButton(
|
||||
icon: Icons.edit,
|
||||
color: Colors.grey.shade700,
|
||||
onTap: isSubmitting ? null : onStartEdit,
|
||||
),
|
||||
CompactIconButton(
|
||||
icon: Icons.delete_outline,
|
||||
color: Colors.red,
|
||||
onTap: isSubmitting ? null : onStartDelete,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (deleting)
|
||||
Container(
|
||||
height: 28,
|
||||
padding: const EdgeInsets.only(left: 15, right: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.red.shade200)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'确认删除 ${item.boxNo} 号箱记录?',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.red),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: isSubmitting ? null : onDelete,
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 2,
|
||||
),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'删除',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
TextButton(
|
||||
onPressed: isSubmitting ? null : onCancelDelete,
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 2,
|
||||
),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text(
|
||||
'取消',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
578
lib/pages/boxing/widgets/single_code_body.dart
Normal file
@@ -0,0 +1,578 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
|
||||
import 'package:pad_scanner/pages/boxing/widgets/single_assigned_row.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
class SingleCodeBody extends StatelessWidget {
|
||||
final bool isWaiting;
|
||||
final bool isFinished;
|
||||
final String? zongpaiNo;
|
||||
final String? paichanNo;
|
||||
final String? workOrderNo;
|
||||
final int? erpQuantity;
|
||||
final int packedQuantity;
|
||||
final int maxBoxNo;
|
||||
final List<CurrentZongpaiBoxData> assignedItems;
|
||||
final List<BoxDetailData> existingBoxes;
|
||||
final TextEditingController boxNoController;
|
||||
final TextEditingController quantityController;
|
||||
final FocusNode boxNoFocusNode;
|
||||
final FocusNode quantityFocusNode;
|
||||
final bool isSubmitting;
|
||||
final bool isDuplicateBoxNo;
|
||||
final bool quantityTooHigh;
|
||||
final bool canSubmit;
|
||||
final int? editingAssignedItemId;
|
||||
final String editingAssignedQuantity;
|
||||
final int? deletingAssignedItemId;
|
||||
final VoidCallback onOpenDetail;
|
||||
final VoidCallback onCheckDuplicateBoxNo;
|
||||
final VoidCallback onSubmitFromKeyboard;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback onQuantityChanged;
|
||||
final ValueChanged<String> onEditingAssignedQuantityChanged;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onSaveAssignedItem;
|
||||
final VoidCallback onCancelEditAssigned;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onStartEditAssigned;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onStartDeleteAssigned;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onDeleteAssignedItem;
|
||||
final VoidCallback onCancelDeleteAssigned;
|
||||
|
||||
const SingleCodeBody({
|
||||
super.key,
|
||||
required this.isWaiting,
|
||||
required this.isFinished,
|
||||
required this.zongpaiNo,
|
||||
required this.paichanNo,
|
||||
required this.workOrderNo,
|
||||
required this.erpQuantity,
|
||||
required this.packedQuantity,
|
||||
required this.maxBoxNo,
|
||||
required this.assignedItems,
|
||||
required this.existingBoxes,
|
||||
required this.boxNoController,
|
||||
required this.quantityController,
|
||||
required this.boxNoFocusNode,
|
||||
required this.quantityFocusNode,
|
||||
required this.isSubmitting,
|
||||
required this.isDuplicateBoxNo,
|
||||
required this.quantityTooHigh,
|
||||
required this.canSubmit,
|
||||
required this.editingAssignedItemId,
|
||||
required this.editingAssignedQuantity,
|
||||
required this.deletingAssignedItemId,
|
||||
required this.onOpenDetail,
|
||||
required this.onCheckDuplicateBoxNo,
|
||||
required this.onSubmitFromKeyboard,
|
||||
required this.onSubmit,
|
||||
required this.onQuantityChanged,
|
||||
required this.onEditingAssignedQuantityChanged,
|
||||
required this.onSaveAssignedItem,
|
||||
required this.onCancelEditAssigned,
|
||||
required this.onStartEditAssigned,
|
||||
required this.onStartDeleteAssigned,
|
||||
required this.onDeleteAssignedItem,
|
||||
required this.onCancelDeleteAssigned,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_SingleInfoBar(
|
||||
isWaiting: isWaiting,
|
||||
paichanNo: paichanNo,
|
||||
existingBoxes: existingBoxes,
|
||||
maxBoxNo: maxBoxNo,
|
||||
colorScheme: colorScheme,
|
||||
onOpenDetail: onOpenDetail,
|
||||
),
|
||||
_SingleScanBar(
|
||||
isWaiting: isWaiting,
|
||||
zongpaiNo: zongpaiNo,
|
||||
workOrderNo: workOrderNo,
|
||||
erpQuantity: erpQuantity,
|
||||
packedQuantity: packedQuantity,
|
||||
),
|
||||
_SingleAssignedList(
|
||||
isWaiting: isWaiting,
|
||||
isFinished: isFinished,
|
||||
items: assignedItems,
|
||||
workOrderNo: workOrderNo,
|
||||
isSubmitting: isSubmitting,
|
||||
editingAssignedItemId: editingAssignedItemId,
|
||||
editingAssignedQuantity: editingAssignedQuantity,
|
||||
deletingAssignedItemId: deletingAssignedItemId,
|
||||
onEditingAssignedQuantityChanged: onEditingAssignedQuantityChanged,
|
||||
onSaveAssignedItem: onSaveAssignedItem,
|
||||
onCancelEditAssigned: onCancelEditAssigned,
|
||||
onStartEditAssigned: onStartEditAssigned,
|
||||
onStartDeleteAssigned: onStartDeleteAssigned,
|
||||
onDeleteAssignedItem: onDeleteAssignedItem,
|
||||
onCancelDeleteAssigned: onCancelDeleteAssigned,
|
||||
),
|
||||
_SingleBottomArea(
|
||||
disabled: isWaiting || isFinished,
|
||||
boxNoController: boxNoController,
|
||||
quantityController: quantityController,
|
||||
boxNoFocusNode: boxNoFocusNode,
|
||||
quantityFocusNode: quantityFocusNode,
|
||||
isSubmitting: isSubmitting,
|
||||
isDuplicateBoxNo: isDuplicateBoxNo,
|
||||
quantityTooHigh: quantityTooHigh,
|
||||
canSubmit: canSubmit,
|
||||
onCheckDuplicateBoxNo: onCheckDuplicateBoxNo,
|
||||
onSubmitFromKeyboard: onSubmitFromKeyboard,
|
||||
onSubmit: onSubmit,
|
||||
onQuantityChanged: onQuantityChanged,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleScanBar extends StatelessWidget {
|
||||
final bool isWaiting;
|
||||
final String? zongpaiNo;
|
||||
final String? workOrderNo;
|
||||
final int? erpQuantity;
|
||||
final int packedQuantity;
|
||||
|
||||
const _SingleScanBar({
|
||||
required this.isWaiting,
|
||||
required this.zongpaiNo,
|
||||
required this.workOrderNo,
|
||||
required this.erpQuantity,
|
||||
required this.packedQuantity,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isWaiting) {
|
||||
return Container(
|
||||
height: 35,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.qr_code_scanner, color: Colors.grey, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'请扫描执行卡二维码',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
height: 35,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.green.shade200)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle, color: Colors.green, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
zongpaiNo ?? '',
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${workOrderNo ?? "--"} / ${erpQuantity ?? 0}件 / 已装$packedQuantity',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleInfoBar extends StatelessWidget {
|
||||
final bool isWaiting;
|
||||
final String? paichanNo;
|
||||
final List<BoxDetailData> existingBoxes;
|
||||
final int maxBoxNo;
|
||||
final ColorScheme colorScheme;
|
||||
final VoidCallback onOpenDetail;
|
||||
|
||||
const _SingleInfoBar({
|
||||
required this.isWaiting,
|
||||
required this.paichanNo,
|
||||
required this.existingBoxes,
|
||||
required this.maxBoxNo,
|
||||
required this.colorScheme,
|
||||
required this.onOpenDetail,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final detailEnabled = !isWaiting && existingBoxes.isNotEmpty;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
isWaiting ? '排产号 --' : (paichanNo ?? '--'),
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isWaiting
|
||||
? Colors.grey.shade400
|
||||
: Colors.black87,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
isWaiting
|
||||
? '已有 -- 箱'
|
||||
: '已有 ${existingBoxes.length} 箱',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isWaiting
|
||||
? Colors.grey.shade400
|
||||
: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
isWaiting
|
||||
? '最大箱号 --'
|
||||
: '最大箱号 $maxBoxNo',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isWaiting
|
||||
? Colors.grey.shade400
|
||||
: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
BoxingDetailButton(
|
||||
enabled: detailEnabled,
|
||||
colorScheme: colorScheme,
|
||||
onPressed: onOpenDetail,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleAssignedList extends StatelessWidget {
|
||||
final bool isWaiting;
|
||||
final bool isFinished;
|
||||
final List<CurrentZongpaiBoxData> items;
|
||||
final String? workOrderNo;
|
||||
final bool isSubmitting;
|
||||
final int? editingAssignedItemId;
|
||||
final String editingAssignedQuantity;
|
||||
final int? deletingAssignedItemId;
|
||||
final ValueChanged<String> onEditingAssignedQuantityChanged;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onSaveAssignedItem;
|
||||
final VoidCallback onCancelEditAssigned;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onStartEditAssigned;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onStartDeleteAssigned;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onDeleteAssignedItem;
|
||||
final VoidCallback onCancelDeleteAssigned;
|
||||
|
||||
const _SingleAssignedList({
|
||||
required this.isWaiting,
|
||||
required this.isFinished,
|
||||
required this.items,
|
||||
required this.workOrderNo,
|
||||
required this.isSubmitting,
|
||||
required this.editingAssignedItemId,
|
||||
required this.editingAssignedQuantity,
|
||||
required this.deletingAssignedItemId,
|
||||
required this.onEditingAssignedQuantityChanged,
|
||||
required this.onSaveAssignedItem,
|
||||
required this.onCancelEditAssigned,
|
||||
required this.onStartEditAssigned,
|
||||
required this.onStartDeleteAssigned,
|
||||
required this.onDeleteAssignedItem,
|
||||
required this.onCancelDeleteAssigned,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
const _SingleAssignedHeader(),
|
||||
Expanded(
|
||||
child: items.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
isFinished
|
||||
? '该总排号已全部装箱完毕'
|
||||
: (isWaiting ? '扫码后显示已分配箱号记录' : '本次扫码尚未分配箱号'),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isFinished
|
||||
? Colors.green.shade700
|
||||
: Colors.grey.shade500,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return SingleAssignedRow(
|
||||
item: item,
|
||||
workOrderNo: workOrderNo,
|
||||
editing: editingAssignedItemId == item.boxItemId,
|
||||
deleting: deletingAssignedItemId == item.boxItemId,
|
||||
isSubmitting: isSubmitting,
|
||||
editingQuantity: editingAssignedQuantity,
|
||||
onEditingQuantityChanged:
|
||||
onEditingAssignedQuantityChanged,
|
||||
onSave: () => onSaveAssignedItem(item),
|
||||
onCancelEdit: onCancelEditAssigned,
|
||||
onStartEdit: () => onStartEditAssigned(item),
|
||||
onStartDelete: () => onStartDeleteAssigned(item),
|
||||
onDelete: () => onDeleteAssignedItem(item),
|
||||
onCancelDelete: onCancelDeleteAssigned,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleAssignedHeader extends StatelessWidget {
|
||||
const _SingleAssignedHeader();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 28,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade400)),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 22,
|
||||
child: Text(
|
||||
'箱号',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 30,
|
||||
child: Text(
|
||||
'工令号',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 23,
|
||||
child: Text(
|
||||
'数量',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 54,
|
||||
child: Text(
|
||||
'操作',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleBottomArea extends StatelessWidget {
|
||||
final bool disabled;
|
||||
final TextEditingController boxNoController;
|
||||
final TextEditingController quantityController;
|
||||
final FocusNode boxNoFocusNode;
|
||||
final FocusNode quantityFocusNode;
|
||||
final bool isSubmitting;
|
||||
final bool isDuplicateBoxNo;
|
||||
final bool quantityTooHigh;
|
||||
final bool canSubmit;
|
||||
final VoidCallback onCheckDuplicateBoxNo;
|
||||
final VoidCallback onSubmitFromKeyboard;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback onQuantityChanged;
|
||||
|
||||
const _SingleBottomArea({
|
||||
required this.disabled,
|
||||
required this.boxNoController,
|
||||
required this.quantityController,
|
||||
required this.boxNoFocusNode,
|
||||
required this.quantityFocusNode,
|
||||
required this.isSubmitting,
|
||||
required this.isDuplicateBoxNo,
|
||||
required this.quantityTooHigh,
|
||||
required this.canSubmit,
|
||||
required this.onCheckDuplicateBoxNo,
|
||||
required this.onSubmitFromKeyboard,
|
||||
required this.onSubmit,
|
||||
required this.onQuantityChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'箱号',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: disabled ? Colors.grey.shade400 : Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
SizedBox(
|
||||
width: 52,
|
||||
height: 34,
|
||||
child: TextField(
|
||||
controller: boxNoController,
|
||||
focusNode: boxNoFocusNode,
|
||||
enabled: !disabled && !isSubmitting,
|
||||
keyboardType: TextInputType.none,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
textAlign: TextAlign.center,
|
||||
onChanged: (_) => onCheckDuplicateBoxNo(),
|
||||
onSubmitted: (_) => onSubmitFromKeyboard(),
|
||||
decoration: compactInputDecoration(
|
||||
disabled: disabled || isSubmitting,
|
||||
borderColor: isDuplicateBoxNo
|
||||
? Colors.amber
|
||||
: Colors.blue.shade700,
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'数量',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: disabled ? Colors.grey.shade400 : Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
SizedBox(
|
||||
width: 52,
|
||||
height: 34,
|
||||
child: TextField(
|
||||
controller: quantityController,
|
||||
focusNode: quantityFocusNode,
|
||||
enabled: !disabled && !isSubmitting,
|
||||
keyboardType: TextInputType.none,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
textAlign: TextAlign.center,
|
||||
onChanged: (_) => onQuantityChanged(),
|
||||
onSubmitted: (_) => onSubmitFromKeyboard(),
|
||||
decoration: compactInputDecoration(
|
||||
disabled: disabled || isSubmitting,
|
||||
borderColor: quantityTooHigh
|
||||
? Colors.red
|
||||
: Colors.blue.shade700,
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
height: 34,
|
||||
child: ElevatedButton(
|
||||
onPressed: canSubmit ? onSubmit : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: canSubmit
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade300,
|
||||
foregroundColor: canSubmit
|
||||
? Colors.white
|
||||
: Colors.grey.shade600,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
child: isSubmitting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'确认',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,16 @@ class BoxingDetailPage extends StatelessWidget {
|
||||
children: [
|
||||
const Text('装箱详情'),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
paichanNo,
|
||||
style:
|
||||
const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
||||
Expanded(
|
||||
child: Text(
|
||||
paichanNo,
|
||||
textAlign: TextAlign.end,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -55,14 +61,37 @@ class BoxingDetailPage extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildBoxGroup(BuildContext context, BoxDetailData box) {
|
||||
final boxTotalQuantity = box.items.fold<int>(
|
||||
0,
|
||||
(sum, item) => sum + item.quantity,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'箱号 ${box.boxNo}',
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'箱号 ${box.boxNo}',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'共 $boxTotalQuantity 件',
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
@@ -77,11 +106,29 @@ class BoxingDetailPage extends StatelessWidget {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(item.zongpaiNo, style: const TextStyle(fontSize: 14)),
|
||||
Text('数量:${item.quantity}',
|
||||
style: const TextStyle(fontSize: 14)),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Text(
|
||||
item.zongpaiNo,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
item.workOrderNo ?? '--',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${item.quantity}/${item.totalQuantity ?? '--'}',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:pad_scanner/services/app_config_service.dart';
|
||||
import 'package:pad_scanner/pages/settings_page.dart';
|
||||
import 'package:pad_scanner/pages/registration_page.dart';
|
||||
@@ -14,20 +15,18 @@ enum ModuleStatus { online, developing, planning }
|
||||
/// Function card data model per PRD §3.3
|
||||
class FeatureCard {
|
||||
final ModuleType type;
|
||||
final String shortcut;
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String description;
|
||||
final ModuleStatus status;
|
||||
final String? version;
|
||||
final String route;
|
||||
|
||||
const FeatureCard({
|
||||
required this.type,
|
||||
required this.shortcut,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.status,
|
||||
this.version,
|
||||
required this.route,
|
||||
});
|
||||
}
|
||||
@@ -41,6 +40,7 @@ class HomePage extends StatefulWidget {
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
final _apiService = ApiService();
|
||||
final _focusNode = FocusNode();
|
||||
bool _isCheckingConnection = false;
|
||||
bool _isConnected = false;
|
||||
String _apiAddress = '';
|
||||
@@ -49,29 +49,26 @@ class _HomePageState extends State<HomePage> {
|
||||
static const _modules = [
|
||||
FeatureCard(
|
||||
type: ModuleType.registration,
|
||||
shortcut: '1',
|
||||
icon: Icons.local_shipping_outlined,
|
||||
title: '上架登记',
|
||||
description: '扫码绑定总排号与货位',
|
||||
status: ModuleStatus.online,
|
||||
version: 'v1.0',
|
||||
route: '/registration',
|
||||
),
|
||||
FeatureCard(
|
||||
type: ModuleType.boxing,
|
||||
shortcut: '2',
|
||||
icon: Icons.view_list_outlined,
|
||||
title: '装箱编号',
|
||||
description: '查询箱号、录入装箱明细',
|
||||
status: ModuleStatus.online,
|
||||
version: 'v1.0',
|
||||
route: '/boxing',
|
||||
),
|
||||
FeatureCard(
|
||||
type: ModuleType.shelfQuery,
|
||||
shortcut: '3',
|
||||
icon: Icons.search_outlined,
|
||||
title: '货架查询',
|
||||
description: '按合同查询货架库存',
|
||||
status: ModuleStatus.planning,
|
||||
version: null,
|
||||
route: '',
|
||||
),
|
||||
];
|
||||
@@ -80,6 +77,60 @@ class _HomePageState extends State<HomePage> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_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;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _checkConnection() async {
|
||||
@@ -133,17 +184,22 @@ class _HomePageState extends State<HomePage> {
|
||||
case ModuleType.shelfQuery:
|
||||
return; // 规划中,不导航
|
||||
}
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => targetPage),
|
||||
).then((_) => _checkConnection());
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => targetPage)).then(
|
||||
(_) {
|
||||
_checkConnection();
|
||||
_requestFocus();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToSettings() {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const SettingsPage()),
|
||||
).then((_) => _checkConnection());
|
||||
).then((_) {
|
||||
_checkConnection();
|
||||
_requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
Color _getConnectionStatusColor() {
|
||||
@@ -161,87 +217,70 @@ class _HomePageState extends State<HomePage> {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('CargoTrace'),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: _navigateToSettings,
|
||||
return KeyboardListener(
|
||||
focusNode: _focusNode,
|
||||
onKeyEvent: _onKeyEvent,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const SizedBox.shrink(),
|
||||
toolbarHeight: 44,
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: _navigateToSettings,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Function cards area
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
itemCount: _modules.length,
|
||||
itemBuilder: (context, index) {
|
||||
final card = _modules[index];
|
||||
return _buildFeatureCard(context, card);
|
||||
},
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Function cards area
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
|
||||
itemCount: _modules.length,
|
||||
itemBuilder: (context, index) {
|
||||
final card = _modules[index];
|
||||
return _buildFeatureCard(context, card);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom connection status bar
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: _getConnectionStatusColor(),
|
||||
shape: BoxShape.circle,
|
||||
// Bottom connection status bar
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: _getConnectionStatusColor(),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_getConnectionStatusText(),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_getConnectionStatusText(),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
return Padding(
|
||||
@@ -250,7 +289,7 @@ class _HomePageState extends State<HomePage> {
|
||||
onTap: () => _onCardTap(card),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: canNavigate ? Colors.white : Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -262,80 +301,49 @@ class _HomePageState extends State<HomePage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: Row(
|
||||
children: [
|
||||
// First row: icon + module name
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
Icon(
|
||||
card.icon,
|
||||
size: 22,
|
||||
color: canNavigate ? Colors.blue : Colors.grey,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// Second row: description
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
card.description,
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// Third row: status badge (right aligned)
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
card.title,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: canNavigate ? Colors.black87 : Colors.grey,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
357
lib/pages/registration/dialogs/registration_dialogs_part.dart
Normal file
@@ -0,0 +1,357 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
part of '../../registration_page.dart';
|
||||
|
||||
extension _RegistrationDialogsPart on _RegistrationPageState {
|
||||
void _showLocationSwitchDialog(
|
||||
String newLocationCode,
|
||||
CodeType newLocationType,
|
||||
) {
|
||||
final count = _zongpaiNos.length;
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: Colors.orange.shade50,
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber, color: Colors.orange.shade800),
|
||||
const SizedBox(width: 8),
|
||||
Text('切换货架号', style: TextStyle(color: Colors.orange.shade900)),
|
||||
],
|
||||
),
|
||||
content: Text(
|
||||
'当前已有 $count 条待上架数据,切换货架号不会清除已扫描的总排号。',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
_feedbackService.trigger(FeedbackEvent.scanValid);
|
||||
setState(() {
|
||||
_locationCode = newLocationCode;
|
||||
_locationType = newLocationType;
|
||||
_clearStatusOverride();
|
||||
});
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.orange.shade700,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('确认切换'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDuplicateDialog(String zongpaiNo, Map<String, dynamic>? info) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: Colors.red.shade50,
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning, color: Colors.red),
|
||||
SizedBox(width: 8),
|
||||
Text('重复上架', style: TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('总排号:$zongpaiNo'),
|
||||
const SizedBox(height: 4),
|
||||
Text('已登记货位:${info?["location_code"] ?? "未知"}'),
|
||||
const SizedBox(height: 4),
|
||||
Text('登记时间:${info?["registered_at"] ?? "未知"}'),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'请核查实物,确认是否操作错误。',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
_feedbackService.stopAlert();
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showLocationConflictDialog({
|
||||
required List<PaichaOverviewItem> onShelfItems,
|
||||
required List<PaichaOverviewItem> transferredItems,
|
||||
List<PaichaOverviewItem> tempStoredItems = const [],
|
||||
}) {
|
||||
final contentParts = <Widget>[];
|
||||
|
||||
if (onShelfItems.isNotEmpty) {
|
||||
contentParts.add(
|
||||
Text(
|
||||
'重复上架',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.red.shade700,
|
||||
),
|
||||
),
|
||||
);
|
||||
for (final item in onShelfItems) {
|
||||
contentParts.addAll([
|
||||
Text('总排号:${item.zongpaiNo}'),
|
||||
Text('已登记货位:${item.locationCode ?? "未知"}'),
|
||||
const SizedBox(height: 6),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (transferredItems.isNotEmpty) {
|
||||
if (onShelfItems.isNotEmpty) {
|
||||
contentParts.add(const Divider());
|
||||
contentParts.add(const SizedBox(height: 4));
|
||||
}
|
||||
contentParts.add(
|
||||
Text(
|
||||
'货物已转运',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.orange.shade700,
|
||||
),
|
||||
),
|
||||
);
|
||||
for (final item in transferredItems) {
|
||||
contentParts.addAll([
|
||||
Text('总排号:${item.zongpaiNo}'),
|
||||
Text('转运货位:${item.locationCode ?? "未知"}'),
|
||||
const SizedBox(height: 6),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (tempStoredItems.isNotEmpty) {
|
||||
if (onShelfItems.isNotEmpty || transferredItems.isNotEmpty) {
|
||||
contentParts.add(const Divider());
|
||||
contentParts.add(const SizedBox(height: 4));
|
||||
}
|
||||
contentParts.add(
|
||||
Text(
|
||||
'暂存中',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: const Color(0xFF7B1FA2),
|
||||
),
|
||||
),
|
||||
);
|
||||
for (final item in tempStoredItems) {
|
||||
contentParts.addAll([
|
||||
Text('总排号:${item.zongpaiNo}'),
|
||||
Text('暂存货位:${item.locationCode ?? "未知"}'),
|
||||
const SizedBox(height: 6),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
contentParts.add(const SizedBox(height: 4));
|
||||
contentParts.add(
|
||||
const Text(
|
||||
'请核查实物,确认是否操作错误。',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: Colors.red.shade50,
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning, color: Colors.red),
|
||||
SizedBox(width: 8),
|
||||
Text('操作冲突', style: TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: contentParts,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
_feedbackService.stopAlert();
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showCrossPaichaDialog(String newZongpaiNo) async {
|
||||
final currentPaicha = _overview?.paichaNo ?? '--';
|
||||
final shouldSwitch = await _confirmCrossPaichaSwitch(
|
||||
newZongpaiNo: newZongpaiNo,
|
||||
currentPaicha: currentPaicha,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (shouldSwitch) {
|
||||
_switchToNewPaicha(newZongpaiNo);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_crossPaichaPending = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> _confirmCrossPaichaSwitch({
|
||||
required String newZongpaiNo,
|
||||
required String currentPaicha,
|
||||
}) async {
|
||||
final dialogFocus = FocusNode();
|
||||
var dismissed = false;
|
||||
var selectedIndex = 0; // 0 = "否" (default), 1 = "是"
|
||||
|
||||
void dismissAsNo() {
|
||||
if (dismissed) return;
|
||||
dismissed = true;
|
||||
Navigator.of(context).pop(false);
|
||||
}
|
||||
|
||||
void confirmSwitch() {
|
||||
if (dismissed) return;
|
||||
dismissed = true;
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
final dialogFuture = showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) {
|
||||
return KeyboardListener(
|
||||
focusNode: dialogFocus,
|
||||
onKeyEvent: (event) {
|
||||
if (event is! KeyDownEvent) return;
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
|
||||
setDialogState(() => selectedIndex = 0);
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
setDialogState(() => selectedIndex = 1);
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
selectedIndex == 0 ? dismissAsNo() : confirmSwitch();
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
dismissAsNo();
|
||||
}
|
||||
},
|
||||
child: PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) dismissAsNo();
|
||||
},
|
||||
child: AlertDialog(
|
||||
backgroundColor: Colors.orange.shade50,
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber, color: Colors.orange.shade800),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'跨排产号扫描',
|
||||
style: TextStyle(color: Colors.orange.shade900),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'总排号 $newZongpaiNo 不属于当前排产号($currentPaicha),'
|
||||
'是否切换到新的排产号?\n\n'
|
||||
'选择「是」将放弃当前已扫描的所有数据。',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_dialogOptionBtn(
|
||||
label: '否',
|
||||
selected: selectedIndex == 0,
|
||||
onTap: dismissAsNo,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_dialogOptionBtn(
|
||||
label: '是,切换排产号',
|
||||
selected: selectedIndex == 1,
|
||||
onTap: confirmSwitch,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
dialogFocus.requestFocus();
|
||||
});
|
||||
final shouldSwitch = await dialogFuture;
|
||||
return shouldSwitch ?? false;
|
||||
}
|
||||
|
||||
Widget _dialogOptionBtn({
|
||||
required String label,
|
||||
required bool selected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 40,
|
||||
child: ElevatedButton(
|
||||
onPressed: onTap,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: selected
|
||||
? Colors.blue.shade700
|
||||
: Colors.grey.shade200,
|
||||
foregroundColor: selected ? Colors.white : Colors.black87,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _switchToNewPaicha(String newZongpaiNo) {
|
||||
_feedbackService.trigger(FeedbackEvent.paichanSwitch);
|
||||
setState(() {
|
||||
_crossPaichaPending = false;
|
||||
_zongpaiNos.clear();
|
||||
_zongpaiNos.add(newZongpaiNo);
|
||||
_overview = null;
|
||||
_overviewNotFound = false;
|
||||
_overviewError = null;
|
||||
_clearStatusOverride();
|
||||
});
|
||||
_loadOverview(newZongpaiNo);
|
||||
}
|
||||
}
|
||||
21
lib/pages/registration/parts/registration_mode_part.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
part of '../../registration_page.dart';
|
||||
|
||||
extension _RegistrationModePart on _RegistrationPageState {
|
||||
void _cycleMode() {
|
||||
_feedbackService.trigger(FeedbackEvent.modeSwitch);
|
||||
setState(() {
|
||||
switch (_mode) {
|
||||
case RegistrationMode.singleCode:
|
||||
_mode = RegistrationMode.multiCode;
|
||||
case RegistrationMode.multiCode:
|
||||
_mode = RegistrationMode.singleCode;
|
||||
// 切换到单码时,只保留最后一个总排号
|
||||
if (_zongpaiNos.length > 1) {
|
||||
_zongpaiNos.removeRange(0, _zongpaiNos.length - 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
88
lib/pages/registration/parts/registration_overview_part.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
part of '../../registration_page.dart';
|
||||
|
||||
extension _RegistrationOverviewPart on _RegistrationPageState {
|
||||
/// Find all scanned items that are already on shelf.
|
||||
List<PaichaOverviewItem> _findOnShelfItemsInScanned() {
|
||||
return registration_calculations.findOnShelfItemsInScanned(
|
||||
overview: _overview,
|
||||
zongpaiNos: _zongpaiNos,
|
||||
);
|
||||
}
|
||||
|
||||
/// Find all scanned items that are already transferred.
|
||||
List<PaichaOverviewItem> _findTransferredItemsInScanned() {
|
||||
return registration_calculations.findTransferredItemsInScanned(
|
||||
overview: _overview,
|
||||
zongpaiNos: _zongpaiNos,
|
||||
);
|
||||
}
|
||||
|
||||
/// Find all scanned items that are in temp storage.
|
||||
List<PaichaOverviewItem> _findTempStoredItemsInScanned() {
|
||||
return registration_calculations.findTempStoredItemsInScanned(
|
||||
overview: _overview,
|
||||
zongpaiNos: _zongpaiNos,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _baseUrl() async {
|
||||
final configService = AppConfigService();
|
||||
final baseUrl = await configService.getString('api_url') ?? '';
|
||||
if (baseUrl.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
Future<void> _loadOverview(String zongpaiNo, {bool force = false}) async {
|
||||
if (!force && _overviewZongpaiNo == zongpaiNo && _overview != null) {
|
||||
return;
|
||||
}
|
||||
final requestId = ++_overviewRequestId;
|
||||
setState(() {
|
||||
_overviewZongpaiNo = zongpaiNo;
|
||||
_overviewLoading = true;
|
||||
_overviewNotFound = false;
|
||||
_overviewError = null;
|
||||
});
|
||||
|
||||
final baseUrl = await _baseUrl();
|
||||
if (!mounted || requestId != _overviewRequestId) return;
|
||||
if (baseUrl == null) {
|
||||
setState(() {
|
||||
_overviewLoading = false;
|
||||
_overviewError = '未配置 API 地址,请前往设置';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await _apiService.fetchPaichaOverview(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
);
|
||||
if (!mounted || requestId != _overviewRequestId) return;
|
||||
|
||||
setState(() {
|
||||
_overviewLoading = false;
|
||||
if (result.success) {
|
||||
_overview = result;
|
||||
_overviewNotFound = false;
|
||||
_overviewError = null;
|
||||
} else if (result.notFound) {
|
||||
_overview = null;
|
||||
_overviewNotFound = true;
|
||||
_overviewError = null;
|
||||
} else {
|
||||
_overviewError = result.errorMessage ?? '加载失败,点击重试';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _refreshCurrentOverview() async {
|
||||
final zongpaiNo = _overviewZongpaiNo;
|
||||
if (zongpaiNo == null) return;
|
||||
await _loadOverview(zongpaiNo, force: true);
|
||||
}
|
||||
}
|
||||
129
lib/pages/registration/parts/registration_scan_part.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
part of '../../registration_page.dart';
|
||||
|
||||
extension _RegistrationScanPart on _RegistrationPageState {
|
||||
void _startScanListening() {
|
||||
_scanSubscription ??= _scannerService.scanResults.listen(_onScan);
|
||||
}
|
||||
|
||||
Future<void> _stopScanListening() async {
|
||||
final subscription = _scanSubscription;
|
||||
_scanSubscription = null;
|
||||
await subscription?.cancel();
|
||||
}
|
||||
|
||||
void _restartScanListening() {
|
||||
_scanSubscription?.cancel();
|
||||
_scanSubscription = null;
|
||||
_startScanListening();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onKeyEvent(KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return;
|
||||
if (_isLockToggleKey(event)) {
|
||||
_cycleMode();
|
||||
return;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
_submit();
|
||||
}
|
||||
}
|
||||
|
||||
bool _isLockToggleKey(KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
return key == LogicalKeyboardKey.select ||
|
||||
key == LogicalKeyboardKey.gameButtonRight1;
|
||||
}
|
||||
|
||||
void _onScan(ScanResult result) {
|
||||
final parsed = CodeParser.parse(result.barcode);
|
||||
switch (parsed.type) {
|
||||
case CodeType.zongpaiNo:
|
||||
_handleZongpaiScan(parsed.value);
|
||||
case CodeType.locationNormal:
|
||||
case CodeType.locationTransit:
|
||||
case CodeType.locationTempStorage:
|
||||
_handleLocationScan(parsed.value, parsed.type);
|
||||
case CodeType.invalid:
|
||||
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
||||
_showStatusOverride(
|
||||
'无效码:${result.barcode}',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleLocationScan(String locationCode, CodeType locationType) {
|
||||
// In singleCode mode or no existing data or no previous shelf, switch directly
|
||||
if (_mode == RegistrationMode.singleCode ||
|
||||
_zongpaiNos.isEmpty ||
|
||||
_locationCode == null) {
|
||||
_feedbackService.trigger(FeedbackEvent.scanValid);
|
||||
setState(() {
|
||||
_locationCode = locationCode;
|
||||
_locationType = locationType;
|
||||
_clearStatusOverride();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// In multiCode mode with existing data, confirm before switching
|
||||
_showLocationSwitchDialog(locationCode, locationType);
|
||||
}
|
||||
|
||||
void _handleZongpaiScan(String zongpaiNo) {
|
||||
// If cross-paicha dialog is showing, just vibrate and discard
|
||||
if (_crossPaichaPending) {
|
||||
_triggerDoubleVibration();
|
||||
return;
|
||||
}
|
||||
|
||||
// In multiCode mode with existing data, check for cross-paicha scan
|
||||
if (_mode == RegistrationMode.multiCode &&
|
||||
_zongpaiNos.isNotEmpty &&
|
||||
_overview?.success == true &&
|
||||
!_overview!.items.any((item) => item.zongpaiNo == zongpaiNo)) {
|
||||
_crossPaichaPending = true;
|
||||
_triggerDoubleVibration();
|
||||
_showCrossPaichaDialog(zongpaiNo);
|
||||
return;
|
||||
}
|
||||
|
||||
final shouldRefresh = _overviewZongpaiNo != zongpaiNo;
|
||||
_feedbackService.trigger(FeedbackEvent.scanValid);
|
||||
setState(() {
|
||||
if (_mode == RegistrationMode.multiCode) {
|
||||
if (!_zongpaiNos.contains(zongpaiNo)) {
|
||||
_zongpaiNos.add(zongpaiNo);
|
||||
}
|
||||
} else {
|
||||
if (_zongpaiNos.isEmpty) {
|
||||
_zongpaiNos.add(zongpaiNo);
|
||||
} else {
|
||||
_zongpaiNos[0] = zongpaiNo;
|
||||
}
|
||||
}
|
||||
_clearStatusOverride();
|
||||
});
|
||||
if (shouldRefresh) {
|
||||
_loadOverview(zongpaiNo);
|
||||
}
|
||||
}
|
||||
|
||||
void _triggerDoubleVibration() {
|
||||
Vibration.vibrate(pattern: [0, 200, 100, 200]);
|
||||
}
|
||||
|
||||
void _removeZongpai(String zongpaiNo) {
|
||||
setState(() {
|
||||
_zongpaiNos.remove(zongpaiNo);
|
||||
});
|
||||
}
|
||||
}
|
||||
58
lib/pages/registration/parts/registration_status_part.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
part of '../../registration_page.dart';
|
||||
|
||||
extension _RegistrationStatusPart on _RegistrationPageState {
|
||||
void _clearStatusOverride() {
|
||||
_statusOverrideText = null;
|
||||
_statusOverrideDot = null;
|
||||
}
|
||||
|
||||
void _showStatusOverride(String text, StatusDotColor dot, Duration duration) {
|
||||
setState(() {
|
||||
_statusOverrideText = text;
|
||||
_statusOverrideDot = dot;
|
||||
});
|
||||
Future.delayed(duration, () {
|
||||
if (mounted) setState(() => _clearStatusOverride());
|
||||
});
|
||||
}
|
||||
|
||||
void _updateBaseStatus() {
|
||||
if (_isSubmitting) {
|
||||
_statusDot = StatusDotColor.orange;
|
||||
_statusText = '正在提交…';
|
||||
return;
|
||||
}
|
||||
if (_mode == RegistrationMode.multiCode &&
|
||||
_locationCode != null &&
|
||||
_zongpaiNos.isEmpty) {
|
||||
_statusDot = StatusDotColor.blue;
|
||||
_statusText = '多码上架模式,请扫描下一张执行卡';
|
||||
return;
|
||||
}
|
||||
final hasZongpai = _zongpaiNos.isNotEmpty;
|
||||
final hasLocation = _locationCode != null;
|
||||
if (hasZongpai && hasLocation) {
|
||||
_statusDot = StatusDotColor.blue;
|
||||
_statusText = '请确认信息并提交';
|
||||
} else if (hasZongpai) {
|
||||
_statusDot = StatusDotColor.blue;
|
||||
_statusText = '请扫描目标货位号';
|
||||
} else if (hasLocation) {
|
||||
_statusDot = StatusDotColor.blue;
|
||||
_statusText = '请扫描执行卡';
|
||||
} else {
|
||||
_statusDot = StatusDotColor.blue;
|
||||
_statusText = '等待扫描总排号或货位号…';
|
||||
}
|
||||
}
|
||||
|
||||
bool get _canSubmit =>
|
||||
_zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting;
|
||||
|
||||
bool get _isTransitTarget => isTransitTarget(_locationType, _locationCode);
|
||||
|
||||
bool get _isTempStorageTarget =>
|
||||
isTempStorageTarget(_locationType, _locationCode);
|
||||
}
|
||||
336
lib/pages/registration/parts/registration_submit_part.dart
Normal file
@@ -0,0 +1,336 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
part of '../../registration_page.dart';
|
||||
|
||||
extension _RegistrationSubmitPart on _RegistrationPageState {
|
||||
Future<void> _submit() async {
|
||||
if (!_canSubmit) return;
|
||||
|
||||
final baseUrl = await _baseUrl();
|
||||
if (baseUrl == null) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showStatusOverride(
|
||||
'未配置 API 地址,请前往设置',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
// For transit target: block if any scanned item is already transferred
|
||||
// (on_shelf items are allowed — transit registration acts as off-shelf)
|
||||
if (_isTransitTarget) {
|
||||
final transferredItems = _findTransferredItemsInScanned();
|
||||
if (transferredItems.isNotEmpty) {
|
||||
setState(() => _isSubmitting = false);
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showLocationConflictDialog(
|
||||
onShelfItems: [],
|
||||
transferredItems: transferredItems,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// For temp storage target: block if any scanned item is transferred or temp_stored
|
||||
// (on_shelf items are allowed — temp storage acts as shelf change)
|
||||
if (_isTempStorageTarget) {
|
||||
final transferredItems = _findTransferredItemsInScanned();
|
||||
final tempStoredItems = _findTempStoredItemsInScanned();
|
||||
if (transferredItems.isNotEmpty || tempStoredItems.isNotEmpty) {
|
||||
setState(() => _isSubmitting = false);
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showLocationConflictDialog(
|
||||
onShelfItems: [],
|
||||
transferredItems: transferredItems,
|
||||
tempStoredItems: tempStoredItems,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_mode == RegistrationMode.multiCode && _zongpaiNos.length > 1) {
|
||||
if (_isTransitTarget && !await _ensureBatchSamePaicha(baseUrl)) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isSubmitting = false);
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showStatusOverride(
|
||||
batchPaichaMismatchMessage,
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Pre-check: block entire batch if any item already has a location binding
|
||||
if (!_isTransitTarget && !_isTempStorageTarget) {
|
||||
final onShelfItems = _findOnShelfItemsInScanned();
|
||||
final transferredItems = _findTransferredItemsInScanned();
|
||||
if (onShelfItems.isNotEmpty || transferredItems.isNotEmpty) {
|
||||
setState(() => _isSubmitting = false);
|
||||
_feedbackService.trigger(FeedbackEvent.duplicateError);
|
||||
_showLocationConflictDialog(
|
||||
onShelfItems: onShelfItems,
|
||||
transferredItems: transferredItems,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await _submitBatch(baseUrl);
|
||||
} else {
|
||||
await _submitOne(baseUrl, _zongpaiNos.first);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _ensureBatchSamePaicha(String baseUrl) async {
|
||||
final paichaByCode = <String, String>{};
|
||||
final cachedOverview = _overview;
|
||||
if (cachedOverview?.success == true && cachedOverview!.paichaNo != null) {
|
||||
for (final item in cachedOverview.items) {
|
||||
paichaByCode[item.zongpaiNo] = cachedOverview.paichaNo!;
|
||||
}
|
||||
}
|
||||
|
||||
for (final zongpaiNo in _zongpaiNos) {
|
||||
if (paichaByCode.containsKey(zongpaiNo)) continue;
|
||||
final result = await _apiService.fetchPaichaOverview(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
);
|
||||
if (!result.success || result.paichaNo == null) {
|
||||
return false;
|
||||
}
|
||||
for (final item in result.items) {
|
||||
paichaByCode[item.zongpaiNo] = result.paichaNo!;
|
||||
}
|
||||
}
|
||||
|
||||
return allSamePaicha(_zongpaiNos.map((code) => paichaByCode[code]));
|
||||
}
|
||||
|
||||
Future<void> _submitOne(String baseUrl, String zongpaiNo) async {
|
||||
final result = await _apiService.registerLocation(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
locationCode: _locationCode!,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
|
||||
if (result.success) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||
if (_isTransitTarget) {
|
||||
setState(() => _isSubmitting = false);
|
||||
await _navigateToBoxing(
|
||||
mode: BoxingMode.singleCode,
|
||||
codes: [zongpaiNo],
|
||||
codesToClear: [zongpaiNo],
|
||||
clearLocation: _mode == RegistrationMode.singleCode,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_zongpaiNos.remove(zongpaiNo);
|
||||
if (_mode == RegistrationMode.singleCode && !_isTempStorageTarget) {
|
||||
_locationCode = null;
|
||||
_locationType = null;
|
||||
}
|
||||
});
|
||||
final msg = result.isOffShelfSuccess
|
||||
? '下架成功'
|
||||
: _isTempStorageTarget
|
||||
? '变更货架成功'
|
||||
: (_mode == RegistrationMode.multiCode ? '多码上架模式,请扫描下一张执行卡' : '上架成功');
|
||||
_showStatusOverride(
|
||||
msg,
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
await _refreshCurrentOverview();
|
||||
} else if (result.isDuplicate) {
|
||||
_feedbackService.trigger(FeedbackEvent.duplicateError);
|
||||
_showDuplicateDialog(zongpaiNo, result.duplicateInfo);
|
||||
} else if (result.isAlreadyOffShelf) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showStatusOverride(
|
||||
result.errorMessage ?? '该总排号已下架至转运区域,不可重新上架',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
} else {
|
||||
final isNetwork = result.errorMessage == '网络异常,请检查网络连接';
|
||||
_feedbackService.trigger(
|
||||
isNetwork ? FeedbackEvent.networkError : FeedbackEvent.submitFailure,
|
||||
);
|
||||
_showStatusOverride(
|
||||
result.errorMessage ?? '提交失败',
|
||||
isNetwork ? StatusDotColor.yellow : StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submitBatch(String baseUrl) async {
|
||||
int successCount = 0;
|
||||
int offShelfCount = 0;
|
||||
final failed = <String>[];
|
||||
final toRemove = <String>[];
|
||||
final transitCodes = <String>[];
|
||||
|
||||
for (final zongpaiNo in List.of(_zongpaiNos)) {
|
||||
final result = await _apiService.registerLocation(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
locationCode: _locationCode!,
|
||||
);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
if (result.isOffShelfSuccess) {
|
||||
offShelfCount++;
|
||||
}
|
||||
toRemove.add(zongpaiNo);
|
||||
if (_isTransitTarget) {
|
||||
transitCodes.add(zongpaiNo);
|
||||
}
|
||||
} else {
|
||||
failed.add(zongpaiNo);
|
||||
if (result.isDuplicate) {
|
||||
if (_isTransitTarget && transitCodes.isNotEmpty) {
|
||||
setState(() {
|
||||
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
||||
_isSubmitting = false;
|
||||
});
|
||||
await _navigateToBoxing(
|
||||
mode: BoxingMode.multiCode,
|
||||
codes: transitCodes,
|
||||
codesToClear: toRemove,
|
||||
clearLocation: false,
|
||||
returnMessage: '成功 $successCount 条,失败 ${failed.length} 条',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
||||
_isSubmitting = false;
|
||||
});
|
||||
_feedbackService.trigger(FeedbackEvent.duplicateError);
|
||||
_showDuplicateDialog(zongpaiNo, result.duplicateInfo);
|
||||
return;
|
||||
}
|
||||
if (result.isAlreadyOffShelf) {
|
||||
if (_isTransitTarget && transitCodes.isNotEmpty) {
|
||||
setState(() {
|
||||
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
||||
_isSubmitting = false;
|
||||
});
|
||||
await _navigateToBoxing(
|
||||
mode: BoxingMode.multiCode,
|
||||
codes: transitCodes,
|
||||
codesToClear: toRemove,
|
||||
clearLocation: false,
|
||||
returnMessage: '成功 $successCount 条,失败 ${failed.length} 条',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
||||
_isSubmitting = false;
|
||||
});
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showStatusOverride(
|
||||
result.errorMessage ?? '该总排号已下架至转运区域,不可重新上架',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
||||
_isSubmitting = false;
|
||||
});
|
||||
|
||||
if (_isTransitTarget && transitCodes.isNotEmpty) {
|
||||
await _navigateToBoxing(
|
||||
mode: BoxingMode.multiCode,
|
||||
codes: transitCodes,
|
||||
codesToClear: toRemove,
|
||||
clearLocation: failed.isEmpty,
|
||||
returnMessage: failed.isEmpty
|
||||
? null
|
||||
: '成功 $successCount 条,失败 ${failed.length} 条',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (failed.isEmpty) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||
final msg = offShelfCount == successCount
|
||||
? '批量下架成功($successCount 条)'
|
||||
: '批量上架成功($successCount 条)';
|
||||
_showStatusOverride(
|
||||
msg,
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
await _refreshCurrentOverview();
|
||||
} else {
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showStatusOverride(
|
||||
'成功 $successCount 条,失败 ${failed.length} 条',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _navigateToBoxing({
|
||||
required BoxingMode mode,
|
||||
required List<String> codes,
|
||||
required List<String> codesToClear,
|
||||
required bool clearLocation,
|
||||
String? returnMessage,
|
||||
}) async {
|
||||
await _refreshCurrentOverview();
|
||||
if (!mounted) return;
|
||||
final navigator = Navigator.of(context);
|
||||
await _stopScanListening();
|
||||
await navigator.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BoxingPage(
|
||||
arguments: BoxingPageArguments(
|
||||
initialMode: mode,
|
||||
autoScanCodes: codes,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 120));
|
||||
if (!mounted) return;
|
||||
_restartScanListening();
|
||||
setState(() {
|
||||
_zongpaiNos.removeWhere((item) => codesToClear.contains(item));
|
||||
if (clearLocation) {
|
||||
_locationCode = null;
|
||||
_locationType = null;
|
||||
}
|
||||
});
|
||||
await _refreshCurrentOverview();
|
||||
if (!mounted) return;
|
||||
if (returnMessage != null) {
|
||||
_showStatusOverride(
|
||||
returnMessage,
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
193
lib/pages/registration/registration_calculations.dart
Normal file
@@ -0,0 +1,193 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
class RegistrationOverviewStats {
|
||||
final int totalCount;
|
||||
final int shelvedCount;
|
||||
final int transferredCount;
|
||||
final int tempStoredCount;
|
||||
|
||||
const RegistrationOverviewStats({
|
||||
required this.totalCount,
|
||||
required this.shelvedCount,
|
||||
required this.transferredCount,
|
||||
required this.tempStoredCount,
|
||||
});
|
||||
}
|
||||
|
||||
class RegistrationOverviewSections {
|
||||
final List<PaichaOverviewItem> scanned;
|
||||
final List<PaichaOverviewItem> unscanned;
|
||||
|
||||
const RegistrationOverviewSections({
|
||||
required this.scanned,
|
||||
required this.unscanned,
|
||||
});
|
||||
|
||||
bool get hasDivider => scanned.isNotEmpty && unscanned.isNotEmpty;
|
||||
}
|
||||
|
||||
RegistrationOverviewStats overviewStats(PaichaOverviewResult? overview) {
|
||||
if (overview?.success != true) {
|
||||
return const RegistrationOverviewStats(
|
||||
totalCount: 0,
|
||||
shelvedCount: 0,
|
||||
transferredCount: 0,
|
||||
tempStoredCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
var shelvedCount = 0;
|
||||
var transferredCount = 0;
|
||||
var tempStoredCount = 0;
|
||||
for (final item in overview!.items) {
|
||||
switch (item.status) {
|
||||
case 'on_shelf':
|
||||
shelvedCount++;
|
||||
case 'transferred':
|
||||
transferredCount++;
|
||||
case 'temp_stored':
|
||||
tempStoredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return RegistrationOverviewStats(
|
||||
totalCount: overview.totalCount,
|
||||
shelvedCount: shelvedCount,
|
||||
transferredCount: transferredCount,
|
||||
tempStoredCount: tempStoredCount,
|
||||
);
|
||||
}
|
||||
|
||||
RegistrationOverviewSections splitOverviewItems({
|
||||
required PaichaOverviewResult overview,
|
||||
required Iterable<String> zongpaiNos,
|
||||
}) {
|
||||
final scannedSet = zongpaiNos.toSet();
|
||||
final scanned = <PaichaOverviewItem>[];
|
||||
final unscanned = <PaichaOverviewItem>[];
|
||||
for (final item in overview.items) {
|
||||
if (scannedSet.contains(item.zongpaiNo)) {
|
||||
scanned.add(item);
|
||||
} else {
|
||||
unscanned.add(item);
|
||||
}
|
||||
}
|
||||
return RegistrationOverviewSections(scanned: scanned, unscanned: unscanned);
|
||||
}
|
||||
|
||||
List<PaichaOverviewItem> findOnShelfItemsInScanned({
|
||||
required PaichaOverviewResult? overview,
|
||||
required Iterable<String> zongpaiNos,
|
||||
}) {
|
||||
return _findScannedItemsByStatus(
|
||||
overview: overview,
|
||||
zongpaiNos: zongpaiNos,
|
||||
status: 'on_shelf',
|
||||
);
|
||||
}
|
||||
|
||||
List<PaichaOverviewItem> findTransferredItemsInScanned({
|
||||
required PaichaOverviewResult? overview,
|
||||
required Iterable<String> zongpaiNos,
|
||||
}) {
|
||||
return _findScannedItemsByStatus(
|
||||
overview: overview,
|
||||
zongpaiNos: zongpaiNos,
|
||||
status: 'transferred',
|
||||
);
|
||||
}
|
||||
|
||||
List<PaichaOverviewItem> findTempStoredItemsInScanned({
|
||||
required PaichaOverviewResult? overview,
|
||||
required Iterable<String> zongpaiNos,
|
||||
}) {
|
||||
return _findScannedItemsByStatus(
|
||||
overview: overview,
|
||||
zongpaiNos: zongpaiNos,
|
||||
status: 'temp_stored',
|
||||
);
|
||||
}
|
||||
|
||||
bool hasAnyLocatedInScanned({
|
||||
required PaichaOverviewResult? overview,
|
||||
required Iterable<String> zongpaiNos,
|
||||
}) {
|
||||
if (overview?.success != true) return false;
|
||||
final scannedSet = zongpaiNos.toSet();
|
||||
return overview!.items.any(
|
||||
(item) =>
|
||||
scannedSet.contains(item.zongpaiNo) &&
|
||||
(item.status == 'on_shelf' ||
|
||||
item.status == 'transferred' ||
|
||||
item.status == 'temp_stored'),
|
||||
);
|
||||
}
|
||||
|
||||
Color registrationBarColor({
|
||||
required String status,
|
||||
required bool isScanned,
|
||||
required bool isTransitTarget,
|
||||
bool isTempStorageTarget = false,
|
||||
}) {
|
||||
if (isScanned) {
|
||||
final isConflict = isTransitTarget
|
||||
? status == 'transferred'
|
||||
: isTempStorageTarget
|
||||
? (status == 'transferred' || status == 'temp_stored')
|
||||
: (status == 'on_shelf' || status == 'transferred');
|
||||
return isConflict ? Colors.red : const Color(0xFF43A047);
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'on_shelf':
|
||||
return const Color(0xFF2196F3);
|
||||
case 'temp_stored':
|
||||
return const Color(0xFF7B1FA2);
|
||||
case 'transferred':
|
||||
return const Color(0xFFFF9800);
|
||||
default:
|
||||
return Colors.grey.shade400;
|
||||
}
|
||||
}
|
||||
|
||||
Color registrationRowBgColor({
|
||||
required String status,
|
||||
required bool isScanned,
|
||||
required bool isTransitTarget,
|
||||
bool isTempStorageTarget = false,
|
||||
}) {
|
||||
if (isScanned) {
|
||||
final isConflict = isTransitTarget
|
||||
? status == 'transferred'
|
||||
: isTempStorageTarget
|
||||
? (status == 'transferred' || status == 'temp_stored')
|
||||
: (status == 'on_shelf' || status == 'transferred');
|
||||
return isConflict ? Colors.red.shade50 : const Color(0xFFF1F8E9);
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'on_shelf':
|
||||
return const Color(0xFFE3F2FD);
|
||||
case 'temp_stored':
|
||||
return const Color(0xFFF3E5F5);
|
||||
case 'transferred':
|
||||
return const Color(0xFFFFF3E0);
|
||||
default:
|
||||
return const Color(0xFFF5F5F5);
|
||||
}
|
||||
}
|
||||
|
||||
List<PaichaOverviewItem> _findScannedItemsByStatus({
|
||||
required PaichaOverviewResult? overview,
|
||||
required Iterable<String> zongpaiNos,
|
||||
required String status,
|
||||
}) {
|
||||
if (overview?.success != true) return [];
|
||||
final scannedSet = zongpaiNos.toSet();
|
||||
return overview!.items
|
||||
.where(
|
||||
(item) => scannedSet.contains(item.zongpaiNo) && item.status == status,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
463
lib/pages/registration/widgets/registration_widgets_part.dart
Normal file
@@ -0,0 +1,463 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
part of '../../registration_page.dart';
|
||||
|
||||
extension _RegistrationWidgetsPart on _RegistrationPageState {
|
||||
String _locationLabel(CodeType? type) {
|
||||
if (type == CodeType.locationTransit) return '转运区域';
|
||||
if (type == CodeType.locationTempStorage) return '暂存货架';
|
||||
return '普通货架';
|
||||
}
|
||||
|
||||
Color _locationLabelColor(CodeType? type) {
|
||||
if (type == CodeType.locationTransit) return Colors.orange;
|
||||
if (type == CodeType.locationTempStorage) return const Color(0xFF7B1FA2);
|
||||
return Colors.blue;
|
||||
}
|
||||
|
||||
Widget _buildModePill() {
|
||||
return BoxingModePill(
|
||||
isSingle: _mode == RegistrationMode.singleCode,
|
||||
label: _mode == RegistrationMode.singleCode ? '单码上架' : '多码上架',
|
||||
enabled: !_isSubmitting,
|
||||
onTap: _cycleMode,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLocationChip() {
|
||||
final color = _locationLabelColor(_locationType);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
_locationLabel(_locationType),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// === Paicha Header ===
|
||||
|
||||
Widget _buildPaichaHeader() {
|
||||
final overview = _overview;
|
||||
final hasData = overview?.success == true;
|
||||
final hasLoc = _locationCode != null;
|
||||
final stats = registration_calculations.overviewStats(overview);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
hasData ? (overview!.paichaNo ?? '--') : '--',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: hasData ? Colors.black87 : Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (hasLoc)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_locationCode!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
_buildLocationChip(),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildStatTag(
|
||||
'共 ${stats.totalCount}',
|
||||
const Color(0xFFE3F2FD),
|
||||
const Color(0xFF1565C0),
|
||||
hasData,
|
||||
),
|
||||
_buildStatTag(
|
||||
'上架 ${stats.shelvedCount}',
|
||||
const Color(0xFFE8F5E9),
|
||||
const Color(0xFF2E7D32),
|
||||
hasData,
|
||||
),
|
||||
_buildStatTag(
|
||||
'转运 ${stats.transferredCount}',
|
||||
const Color(0xFFFFF3E0),
|
||||
const Color(0xFFE65100),
|
||||
hasData,
|
||||
),
|
||||
_buildStatTag(
|
||||
'暂存 ${stats.tempStoredCount}',
|
||||
const Color(0xFFF3E5F5),
|
||||
const Color(0xFF7B1FA2),
|
||||
hasData,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatTag(String text, Color bg, Color fg, bool hasData) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
|
||||
margin: const EdgeInsets.only(left: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: hasData ? bg : const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: hasData ? fg : Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// === Table Head ===
|
||||
|
||||
Widget _buildTableHead() {
|
||||
return Container(
|
||||
height: 28,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade400)),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 22,
|
||||
child: Text(
|
||||
'总排号',
|
||||
textAlign: TextAlign.center,
|
||||
style: _headerStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 18,
|
||||
child: Text(
|
||||
'工令号',
|
||||
textAlign: TextAlign.center,
|
||||
style: _headerStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 12,
|
||||
child: Text('数量', textAlign: TextAlign.center, style: _headerStyle),
|
||||
),
|
||||
Expanded(
|
||||
flex: 28,
|
||||
child: Text(
|
||||
'货位号',
|
||||
textAlign: TextAlign.center,
|
||||
style: _headerStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// === List Body ===
|
||||
|
||||
Widget _buildListBody() {
|
||||
if (_overviewZongpaiNo == null) {
|
||||
return _buildListMessage('扫描总排号后自动显示排产号上架情况');
|
||||
}
|
||||
if (_overviewLoading && _overview == null) {
|
||||
return _buildListMessage('正在加载排产号上架情况…');
|
||||
}
|
||||
if (_overviewNotFound) {
|
||||
return _buildListMessage('暂无排产信息');
|
||||
}
|
||||
if (_overviewError != null && _overview == null) {
|
||||
return _buildListError(_overviewError!);
|
||||
}
|
||||
final overview = _overview;
|
||||
if (overview == null || !overview.success) {
|
||||
return _buildListMessage('扫描总排号后自动显示排产号上架情况');
|
||||
}
|
||||
|
||||
final sections = registration_calculations.splitOverviewItems(
|
||||
overview: overview,
|
||||
zongpaiNos: _zongpaiNos,
|
||||
);
|
||||
final scanned = sections.scanned;
|
||||
final unscanned = sections.unscanned;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (_overviewError != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
color: Colors.red.shade50,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_overviewError!,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.red),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _overviewZongpaiNo == null
|
||||
? null
|
||||
: () => _loadOverview(_overviewZongpaiNo!, force: true),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount:
|
||||
scanned.length +
|
||||
(sections.hasDivider ? 1 : 0) +
|
||||
unscanned.length,
|
||||
itemBuilder: (context, index) {
|
||||
if (index < scanned.length) {
|
||||
return _buildListRow(scanned[index], isScanned: true);
|
||||
}
|
||||
if (index == scanned.length && sections.hasDivider) {
|
||||
return _buildSectionDivider(unscanned.length);
|
||||
}
|
||||
final unscannedIdx =
|
||||
index - scanned.length - (sections.hasDivider ? 1 : 0);
|
||||
return _buildListRow(unscanned[unscannedIdx], isScanned: false);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionDivider(int count) {
|
||||
return Container(
|
||||
height: 24,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade200)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(child: Divider(height: 0)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
'以下 $count 项未操作',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Expanded(child: Divider(height: 0)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListRow(PaichaOverviewItem item, {required bool isScanned}) {
|
||||
final rowStyle = TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: isScanned ? FontWeight.w700 : FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
);
|
||||
|
||||
final barColor = registration_calculations.registrationBarColor(
|
||||
status: item.status,
|
||||
isScanned: isScanned,
|
||||
isTransitTarget: _isTransitTarget,
|
||||
isTempStorageTarget: _isTempStorageTarget,
|
||||
);
|
||||
final bgColor = registration_calculations.registrationRowBgColor(
|
||||
status: item.status,
|
||||
isScanned: isScanned,
|
||||
isTransitTarget: _isTransitTarget,
|
||||
isTempStorageTarget: _isTempStorageTarget,
|
||||
);
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(minHeight: 36),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
border: Border(
|
||||
left: BorderSide(color: barColor, width: 3),
|
||||
bottom: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 22,
|
||||
child: Text(
|
||||
item.zongpaiNo,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 18,
|
||||
child: Text(
|
||||
item.workOrderNo ?? '--',
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 12,
|
||||
child: Text(
|
||||
item.quantity.toString(),
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 28,
|
||||
child: Text(
|
||||
item.locationCode ?? '\u2014',
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle,
|
||||
),
|
||||
),
|
||||
if (isScanned && _mode == RegistrationMode.multiCode) ...[
|
||||
const SizedBox(width: 6),
|
||||
SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: IconButton(
|
||||
icon: Icon(Icons.delete_outline, size: 16),
|
||||
color: Colors.red,
|
||||
onPressed: () => _removeZongpai(item.zongpaiNo),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListMessage(String text) {
|
||||
return Center(
|
||||
child: Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListError(String text) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 13, color: Colors.red),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
TextButton(
|
||||
onPressed: _overviewZongpaiNo == null
|
||||
? null
|
||||
: () => _loadOverview(_overviewZongpaiNo!, force: true),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// === Bottom Bar ===
|
||||
|
||||
Widget _buildBottomBar(ColorScheme colorScheme) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 34,
|
||||
child: ElevatedButton(
|
||||
onPressed: _canSubmit ? _submit : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: registrationSubmitColor(
|
||||
canSubmit: _canSubmit,
|
||||
isTransitTarget: _isTransitTarget,
|
||||
primaryColor: colorScheme.primary,
|
||||
),
|
||||
foregroundColor: _canSubmit ? Colors.white : Colors.grey.shade600,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
child: _isSubmitting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
registrationSubmitLabel(
|
||||
isTransitTarget: _isTransitTarget,
|
||||
isMultiCode: _mode == RegistrationMode.multiCode,
|
||||
zongpaiCount: _zongpaiNos.length,
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _headerStyle = TextStyle(fontSize: 10, fontWeight: FontWeight.w700);
|
||||
@@ -1,11 +1,32 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:vibration/vibration.dart';
|
||||
import 'package:pad_scanner/services/app_config_service.dart';
|
||||
import 'package:pad_scanner/services/scanner_service.dart';
|
||||
import 'package:pad_scanner/services/code_parser.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
import 'package:pad_scanner/services/sound_service.dart';
|
||||
import 'package:pad_scanner/services/feedback_service.dart';
|
||||
import 'package:pad_scanner/services/registration_linking.dart';
|
||||
import 'package:vibration/vibration.dart';
|
||||
import 'package:pad_scanner/pages/boxing_page.dart';
|
||||
import 'package:pad_scanner/widgets/status_bar.dart';
|
||||
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
|
||||
import 'package:pad_scanner/pages/registration/registration_calculations.dart'
|
||||
as registration_calculations;
|
||||
|
||||
part 'registration/parts/registration_scan_part.dart';
|
||||
part 'registration/parts/registration_status_part.dart';
|
||||
part 'registration/parts/registration_overview_part.dart';
|
||||
part 'registration/parts/registration_submit_part.dart';
|
||||
part 'registration/parts/registration_mode_part.dart';
|
||||
part 'registration/dialogs/registration_dialogs_part.dart';
|
||||
part 'registration/widgets/registration_widgets_part.dart';
|
||||
|
||||
enum RegistrationMode {
|
||||
singleCode, // 单码上架
|
||||
multiCode, // 多码上架
|
||||
}
|
||||
|
||||
class RegistrationPage extends StatefulWidget {
|
||||
const RegistrationPage({super.key});
|
||||
@@ -17,23 +38,35 @@ class RegistrationPage extends StatefulWidget {
|
||||
class _RegistrationPageState extends State<RegistrationPage> {
|
||||
final _scannerService = ScannerService();
|
||||
final _apiService = ApiService();
|
||||
final _soundService = SoundService();
|
||||
final _feedbackService = FeedbackService();
|
||||
final _focusNode = FocusNode();
|
||||
|
||||
StreamSubscription<ScanResult>? _scanSubscription;
|
||||
final _zongpaiNos = <String>[];
|
||||
String? _locationCode;
|
||||
CodeType? _locationType;
|
||||
bool _isLocked = false;
|
||||
RegistrationMode _mode = RegistrationMode.singleCode;
|
||||
bool _isSubmitting = false;
|
||||
|
||||
String? _successMessage;
|
||||
String? _snackbarMessage;
|
||||
Color? _snackbarColor;
|
||||
StatusDotColor _statusDot = StatusDotColor.blue;
|
||||
String _statusText = '等待扫描总排号或货位号…';
|
||||
String? _statusOverrideText;
|
||||
StatusDotColor? _statusOverrideDot;
|
||||
|
||||
bool _overviewLoading = false;
|
||||
bool _overviewNotFound = false;
|
||||
String? _overviewError;
|
||||
String? _overviewZongpaiNo;
|
||||
PaichaOverviewResult? _overview;
|
||||
int _overviewRequestId = 0;
|
||||
|
||||
/// Whether a cross-paicha warning dialog is currently showing.
|
||||
bool _crossPaichaPending = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scannerService.scanResults.listen(_onScan);
|
||||
_startScanListening();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusNode.requestFocus();
|
||||
});
|
||||
@@ -41,604 +74,43 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scanSubscription?.cancel();
|
||||
_focusNode.dispose();
|
||||
_soundService.dispose();
|
||||
_feedbackService.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onKeyEvent(KeyEvent event) {
|
||||
if (event is KeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
_submit();
|
||||
}
|
||||
}
|
||||
|
||||
void _onScan(ScanResult result) {
|
||||
final parsed = CodeParser.parse(result.barcode);
|
||||
switch (parsed.type) {
|
||||
case CodeType.zongpaiNo:
|
||||
setState(() {
|
||||
if (_isLocked) {
|
||||
// 锁定模式:追加到列表(重复则覆盖)
|
||||
final idx = _zongpaiNos.indexOf(parsed.value);
|
||||
if (idx >= 0) {
|
||||
_zongpaiNos[idx] = parsed.value;
|
||||
} else {
|
||||
_zongpaiNos.add(parsed.value);
|
||||
}
|
||||
} else {
|
||||
// 单次模式:只有一条,覆盖
|
||||
if (_zongpaiNos.isNotEmpty) {
|
||||
_zongpaiNos[0] = parsed.value;
|
||||
} else {
|
||||
_zongpaiNos.add(parsed.value);
|
||||
}
|
||||
}
|
||||
_snackbarMessage = null;
|
||||
_successMessage = null;
|
||||
});
|
||||
case CodeType.locationNormal:
|
||||
case CodeType.locationTransit:
|
||||
if (!_isLocked) {
|
||||
setState(() {
|
||||
_locationCode = parsed.value;
|
||||
_locationType = parsed.type;
|
||||
_snackbarMessage = null;
|
||||
_successMessage = null;
|
||||
});
|
||||
}
|
||||
case CodeType.invalid:
|
||||
_showFeedback('无效码:${result.barcode}', isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
void _removeZongpai(int index) {
|
||||
setState(() {
|
||||
_zongpaiNos.removeAt(index);
|
||||
});
|
||||
}
|
||||
|
||||
void _showFeedback(String message, {bool isError = false}) {
|
||||
setState(() {
|
||||
_snackbarMessage = message;
|
||||
_snackbarColor = isError ? Colors.red.shade700 : Colors.green.shade700;
|
||||
});
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (mounted) setState(() => _snackbarMessage = null);
|
||||
});
|
||||
}
|
||||
|
||||
String get _statusText {
|
||||
if (_isSubmitting) return '正在提交…';
|
||||
if (_successMessage != null) return _successMessage!;
|
||||
if (_isLocked && _locationCode != null && _zongpaiNos.isEmpty) {
|
||||
return '货位已锁定,请扫描下一张执行卡';
|
||||
}
|
||||
final hasZ = _zongpaiNos.isNotEmpty;
|
||||
final hasL = _locationCode != null;
|
||||
if (hasZ && hasL) return '请确认信息并提交';
|
||||
if (hasZ && !hasL) return '请扫描目标货位号';
|
||||
if (!hasZ && hasL) return '请扫描执行卡';
|
||||
return '等待扫描总排号或货位号…';
|
||||
}
|
||||
|
||||
bool get _canSubmit =>
|
||||
_zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting;
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_canSubmit) return;
|
||||
|
||||
final configService = AppConfigService();
|
||||
final baseUrl = await configService.getString('api_url') ?? '';
|
||||
if (baseUrl.isEmpty) {
|
||||
_showFeedback('未配置 API 地址,请前往设置', isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
if (_isLocked && _zongpaiNos.length > 1) {
|
||||
// 批量提交:逐个提交列表中的总排号
|
||||
await _submitBatch(baseUrl);
|
||||
} else {
|
||||
// 单条提交
|
||||
await _submitOne(baseUrl, _zongpaiNos.first);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submitOne(String baseUrl, String zongpaiNo) async {
|
||||
final result = await _apiService.registerLocation(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
locationCode: _locationCode!,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
|
||||
if (result.success) {
|
||||
_vibrateSuccess();
|
||||
setState(() {
|
||||
_zongpaiNos.remove(zongpaiNo);
|
||||
if (!_isLocked) {
|
||||
_locationCode = null;
|
||||
_locationType = null;
|
||||
}
|
||||
_successMessage = _isLocked ? '货位已锁定,请扫描下一张执行卡' : '上架成功';
|
||||
});
|
||||
_showFeedback('上架成功', isError: false);
|
||||
Future.delayed(const Duration(milliseconds: 1500), () {
|
||||
if (mounted) setState(() => _successMessage = null);
|
||||
});
|
||||
} else if (result.isDuplicate) {
|
||||
_vibrateError();
|
||||
_showDuplicateDialog(zongpaiNo, result.duplicateInfo);
|
||||
} else {
|
||||
_vibrateError();
|
||||
_showFeedback(result.errorMessage ?? '提交失败', isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submitBatch(String baseUrl) async {
|
||||
int successCount = 0;
|
||||
final failed = <String>[];
|
||||
final toRemove = <String>[];
|
||||
|
||||
for (final zp in List.of(_zongpaiNos)) {
|
||||
final result = await _apiService.registerLocation(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zp,
|
||||
locationCode: _locationCode!,
|
||||
);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
toRemove.add(zp);
|
||||
} else {
|
||||
failed.add(zp);
|
||||
if (result.isDuplicate) {
|
||||
// 重复上架弹窗中断批量流程
|
||||
setState(() {
|
||||
_zongpaiNos.removeWhere((e) => toRemove.contains(e));
|
||||
_isSubmitting = false;
|
||||
});
|
||||
_showDuplicateDialog(zp, result.duplicateInfo);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_zongpaiNos.removeWhere((e) => toRemove.contains(e));
|
||||
_isSubmitting = false;
|
||||
});
|
||||
|
||||
if (failed.isEmpty) {
|
||||
_vibrateSuccess();
|
||||
_showFeedback('批量上架成功($successCount 条)', isError: false);
|
||||
} else {
|
||||
_vibrateError();
|
||||
_showFeedback('成功 $successCount 条,失败 ${failed.length} 条', isError: true);
|
||||
}
|
||||
}
|
||||
|
||||
void _vibrateSuccess() {
|
||||
Vibration.vibrate(duration: 200);
|
||||
_soundService.playSuccess();
|
||||
}
|
||||
|
||||
void _vibrateError() {
|
||||
Vibration.vibrate(duration: 1000);
|
||||
_soundService.playFailure();
|
||||
}
|
||||
|
||||
void _showDuplicateDialog(String zongpaiNo, Map<String, dynamic>? info) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: Colors.red.shade50,
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning, color: Colors.red),
|
||||
SizedBox(width: 8),
|
||||
Text('重复上架', style: TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('总排号:$zongpaiNo'),
|
||||
const SizedBox(height: 4),
|
||||
Text('已登记货位:${info?["location_code"] ?? "未知"}'),
|
||||
const SizedBox(height: 4),
|
||||
Text('登记时间:${info?["registered_at"] ?? "未知"}'),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'请核查实物,确认是否操作错误。',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleLock(bool value) {
|
||||
if (value && _locationCode == null) {
|
||||
_showFeedback('请先扫描货位号', isError: true);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isLocked = value;
|
||||
if (!value) {
|
||||
// 退出锁定模式,只保留最后一条总排号
|
||||
if (_zongpaiNos.length > 1) {
|
||||
_zongpaiNos.removeRange(0, _zongpaiNos.length - 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String _locationLabel(CodeType? type) {
|
||||
if (type == CodeType.locationTransit) return '转运区域';
|
||||
return '普通货架';
|
||||
}
|
||||
|
||||
Color _locationLabelColor(CodeType? type) {
|
||||
if (type == CodeType.locationTransit) return Colors.orange;
|
||||
return Colors.blue;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
_updateBaseStatus();
|
||||
final effectiveDot = _statusOverrideDot ?? _statusDot;
|
||||
final effectiveText = _statusOverrideText ?? _statusText;
|
||||
|
||||
return KeyboardListener(
|
||||
focusNode: _focusNode,
|
||||
onKeyEvent: _onKeyEvent,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('上架登记'),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('锁定货位', style: TextStyle(fontSize: 13)),
|
||||
Switch(value: _isLocked, onChanged: _toggleLock),
|
||||
],
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
const Text('上架登记'),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _buildModePill()),
|
||||
],
|
||||
),
|
||||
],
|
||||
titleSpacing: 12,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildPaichaHeader(),
|
||||
if (_overview?.success == true) _buildTableHead(),
|
||||
Expanded(child: _buildListBody()),
|
||||
_buildBottomBar(colorScheme),
|
||||
StatusBar(dotColor: effectiveDot, text: effectiveText),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Feedback banner
|
||||
if (_snackbarMessage != null)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
|
||||
color: _snackbarColor,
|
||||
child: Text(
|
||||
_snackbarMessage!,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
// Main form area
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// ---- 目标货位 (上方) ----
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'目标货位',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (_locationCode != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _locationLabelColor(
|
||||
_locationType,
|
||||
).withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
_locationLabel(_locationType),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _locationLabelColor(_locationType),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 14,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: _locationCode != null
|
||||
? Colors.green
|
||||
: Colors.grey.shade400,
|
||||
width: _locationCode != null ? 2 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_locationCode ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _locationCode != null
|
||||
? Colors.black87
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isLocked)
|
||||
const Icon(
|
||||
Icons.lock,
|
||||
color: Colors.orange,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ---- 总排号 (下方) ----
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'总排号',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (_isLocked && _zongpaiNos.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'${_zongpaiNos.length} 条',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
if (_isLocked) ...[
|
||||
// 锁定模式:列表显示多条总排号
|
||||
Expanded(
|
||||
child: _zongpaiNos.isEmpty
|
||||
? Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: _zongpaiNos.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const SizedBox(height: 6),
|
||||
itemBuilder: (context, index) {
|
||||
return Dismissible(
|
||||
key: ValueKey('${_zongpaiNos[index]}-$index'),
|
||||
direction: DismissDirection.endToStart,
|
||||
onDismissed: (_) => _removeZongpai(index),
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade100,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.delete,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Colors.green,
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_zongpaiNos[index],
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.close,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () =>
|
||||
_removeZongpai(index),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
// 单次模式:单个显示
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 14,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: _zongpaiNos.isNotEmpty
|
||||
? Colors.green
|
||||
: Colors.grey.shade400,
|
||||
width: _zongpaiNos.isNotEmpty ? 2 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_zongpaiNos.isNotEmpty ? _zongpaiNos.first : '',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _zongpaiNos.isNotEmpty
|
||||
? Colors.black87
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Submit button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton(
|
||||
onPressed: _canSubmit ? _submit : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _canSubmit
|
||||
? colorScheme.primary
|
||||
: Colors.grey.shade300,
|
||||
foregroundColor: _canSubmit
|
||||
? Colors.white
|
||||
: Colors.grey.shade600,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: _isSubmitting
|
||||
? const SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
_isLocked && _zongpaiNos.length > 1
|
||||
? '批量上架(${_zongpaiNos.length} 条)'
|
||||
: '确认上架(P1)',
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Status bar at bottom
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: _isSubmitting
|
||||
? Colors.orange
|
||||
: _successMessage != null
|
||||
? Colors.green
|
||||
: Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_statusText,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
|
||||
String? _successPath;
|
||||
String? _failurePath;
|
||||
String? _beepPath;
|
||||
String? _errorPath;
|
||||
String? _alertPath;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -35,25 +38,61 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
|
||||
Future<void> _loadSoundPaths() async {
|
||||
final s = await _soundService.getSuccessPath();
|
||||
final f = await _soundService.getFailurePath();
|
||||
final results = await Future.wait([
|
||||
_soundService.getSuccessPath(),
|
||||
_soundService.getFailurePath(),
|
||||
_soundService.getBeepPath(),
|
||||
_soundService.getErrorPath(),
|
||||
_soundService.getAlertPath(),
|
||||
]);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_successPath = s;
|
||||
_failurePath = f;
|
||||
_successPath = results[0];
|
||||
_failurePath = results[1];
|
||||
_beepPath = results[2];
|
||||
_errorPath = results[3];
|
||||
_alertPath = results[4];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveUrl() async {
|
||||
final url = _controller.text.trim();
|
||||
final url = _controller.text.trim().replaceAll(RegExp(r'/+$'), '');
|
||||
if (url.isEmpty) {
|
||||
_showSnackBar('请输入 API 地址', isError: true);
|
||||
return;
|
||||
}
|
||||
setState(() => _saving = true);
|
||||
// Load config once, apply all changes, then save once to avoid race conditions
|
||||
final configService = AppConfigService();
|
||||
await configService.setString('api_url', url);
|
||||
final config = await configService.loadConfig();
|
||||
config['api_url'] = url;
|
||||
if (_successPath != null) {
|
||||
config['sound_success'] = _successPath;
|
||||
} else {
|
||||
config.remove('sound_success');
|
||||
}
|
||||
if (_failurePath != null) {
|
||||
config['sound_failure'] = _failurePath;
|
||||
} else {
|
||||
config.remove('sound_failure');
|
||||
}
|
||||
if (_beepPath != null) {
|
||||
config['sound_beep'] = _beepPath;
|
||||
} else {
|
||||
config.remove('sound_beep');
|
||||
}
|
||||
if (_errorPath != null) {
|
||||
config['sound_error'] = _errorPath;
|
||||
} else {
|
||||
config.remove('sound_error');
|
||||
}
|
||||
if (_alertPath != null) {
|
||||
config['sound_alert'] = _alertPath;
|
||||
} else {
|
||||
config.remove('sound_alert');
|
||||
}
|
||||
await configService.saveConfig(config);
|
||||
setState(() => _saving = false);
|
||||
if (mounted) {
|
||||
_showSnackBar('设置已保存');
|
||||
@@ -74,38 +113,42 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickSound(bool isSuccess) async {
|
||||
Future<void> _pickSound(String key) async {
|
||||
final result = await FilePicker.pickFiles(
|
||||
type: FileType.audio,
|
||||
);
|
||||
if (result != null && result.files.single.path != null) {
|
||||
final path = result.files.single.path!;
|
||||
if (isSuccess) {
|
||||
await _soundService.setSuccessPath(path);
|
||||
} else {
|
||||
await _soundService.setFailurePath(path);
|
||||
}
|
||||
setState(() {
|
||||
if (isSuccess) {
|
||||
_successPath = path;
|
||||
} else {
|
||||
_failurePath = path;
|
||||
switch (key) {
|
||||
case 'success':
|
||||
_successPath = path;
|
||||
case 'failure':
|
||||
_failurePath = path;
|
||||
case 'beep':
|
||||
_beepPath = path;
|
||||
case 'error':
|
||||
_errorPath = path;
|
||||
case 'alert':
|
||||
_alertPath = path;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearSound(bool isSuccess) async {
|
||||
if (isSuccess) {
|
||||
await _soundService.setSuccessPath(null);
|
||||
} else {
|
||||
await _soundService.setFailurePath(null);
|
||||
}
|
||||
Future<void> _clearSound(String key) async {
|
||||
setState(() {
|
||||
if (isSuccess) {
|
||||
_successPath = null;
|
||||
} else {
|
||||
_failurePath = null;
|
||||
switch (key) {
|
||||
case 'success':
|
||||
_successPath = null;
|
||||
case 'failure':
|
||||
_failurePath = null;
|
||||
case 'beep':
|
||||
_beepPath = null;
|
||||
case 'error':
|
||||
_errorPath = null;
|
||||
case 'alert':
|
||||
_alertPath = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -191,29 +234,67 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 上架成功铃声
|
||||
// 扫码提示音
|
||||
_buildSoundRow(
|
||||
label: '上架成功铃声',
|
||||
path: _successPath,
|
||||
onPick: () => _pickSound(true),
|
||||
onPreview: _successPath != null
|
||||
? () => _previewSound(_successPath!)
|
||||
label: '扫码提示音',
|
||||
path: _beepPath,
|
||||
onPick: () => _pickSound('beep'),
|
||||
onPreview: _beepPath != null
|
||||
? () => _previewSound(_beepPath!)
|
||||
: null,
|
||||
onClear: _successPath != null ? () => _clearSound(true) : null,
|
||||
onClear: _beepPath != null ? () => _clearSound('beep') : null,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 上架失败铃声
|
||||
// 成功铃声
|
||||
_buildSoundRow(
|
||||
label: '上架失败铃声',
|
||||
label: '成功铃声',
|
||||
path: _successPath,
|
||||
onPick: () => _pickSound('success'),
|
||||
onPreview: _successPath != null
|
||||
? () => _previewSound(_successPath!)
|
||||
: null,
|
||||
onClear: _successPath != null ? () => _clearSound('success') : null,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 失败铃声
|
||||
_buildSoundRow(
|
||||
label: '失败铃声',
|
||||
path: _failurePath,
|
||||
onPick: () => _pickSound(false),
|
||||
onPick: () => _pickSound('failure'),
|
||||
onPreview: _failurePath != null
|
||||
? () => _previewSound(_failurePath!)
|
||||
: null,
|
||||
onClear:
|
||||
_failurePath != null ? () => _clearSound(false) : null,
|
||||
onClear: _failurePath != null ? () => _clearSound('failure') : null,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 错误铃声(无效码)
|
||||
_buildSoundRow(
|
||||
label: '错误铃声(无效码)',
|
||||
path: _errorPath,
|
||||
onPick: () => _pickSound('error'),
|
||||
onPreview: _errorPath != null
|
||||
? () => _previewSound(_errorPath!)
|
||||
: null,
|
||||
onClear: _errorPath != null ? () => _clearSound('error') : null,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 警报铃声(重复上架)
|
||||
_buildSoundRow(
|
||||
label: '警报铃声(重复上架)',
|
||||
path: _alertPath,
|
||||
onPick: () => _pickSound('alert'),
|
||||
onPreview: _alertPath != null
|
||||
? () => _previewSound(_alertPath!)
|
||||
: null,
|
||||
onClear: _alertPath != null ? () => _clearSound('alert') : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -5,39 +5,142 @@ import 'package:http/http.dart' as http;
|
||||
class RegistrationResult {
|
||||
final bool success;
|
||||
final bool isDuplicate;
|
||||
final bool isAlreadyOffShelf;
|
||||
final bool isOffShelfSuccess;
|
||||
final String? errorMessage;
|
||||
final Map<String, dynamic>? duplicateInfo;
|
||||
final Map<String, dynamic>? conflictInfo;
|
||||
|
||||
RegistrationResult({
|
||||
required this.success,
|
||||
this.isDuplicate = false,
|
||||
this.isAlreadyOffShelf = false,
|
||||
this.isOffShelfSuccess = false,
|
||||
this.errorMessage,
|
||||
this.duplicateInfo,
|
||||
this.conflictInfo,
|
||||
});
|
||||
|
||||
factory RegistrationResult.ok() =>
|
||||
RegistrationResult(success: true);
|
||||
factory RegistrationResult.ok() => RegistrationResult(success: true);
|
||||
|
||||
factory RegistrationResult.offShelfSuccess() =>
|
||||
RegistrationResult(success: true, isOffShelfSuccess: true);
|
||||
|
||||
factory RegistrationResult.duplicate(Map<String, dynamic> info) =>
|
||||
RegistrationResult(success: false, isDuplicate: true, duplicateInfo: info);
|
||||
RegistrationResult(
|
||||
success: false,
|
||||
isDuplicate: true,
|
||||
duplicateInfo: info,
|
||||
conflictInfo: info,
|
||||
);
|
||||
|
||||
factory RegistrationResult.alreadyOffShelf(Map<String, dynamic> info) =>
|
||||
RegistrationResult(
|
||||
success: false,
|
||||
isAlreadyOffShelf: true,
|
||||
errorMessage: info['message']?.toString() ?? '该总排号已下架至转运区域,不可重新上架',
|
||||
conflictInfo: info,
|
||||
);
|
||||
|
||||
factory RegistrationResult.error(String message) =>
|
||||
RegistrationResult(success: false, errorMessage: message);
|
||||
}
|
||||
|
||||
class PaichaOverviewItem {
|
||||
final String zongpaiNo;
|
||||
final String? workOrderNo;
|
||||
final int quantity;
|
||||
final String? locationCode;
|
||||
final String status;
|
||||
|
||||
PaichaOverviewItem({
|
||||
required this.zongpaiNo,
|
||||
this.workOrderNo,
|
||||
required this.quantity,
|
||||
this.locationCode,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
factory PaichaOverviewItem.fromJson(Map<String, dynamic> json) {
|
||||
return PaichaOverviewItem(
|
||||
zongpaiNo: json['zongpai_no'] as String,
|
||||
workOrderNo: json['work_order_no']?.toString(),
|
||||
quantity: json['quantity'] as int? ?? 0,
|
||||
locationCode: json['location_code']?.toString(),
|
||||
status: json['status']?.toString() ?? 'not_shelved',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PaichaOverviewResult {
|
||||
final bool success;
|
||||
final bool notFound;
|
||||
final String? errorMessage;
|
||||
final String? paichaNo;
|
||||
final int totalCount;
|
||||
final List<PaichaOverviewItem> items;
|
||||
|
||||
PaichaOverviewResult({
|
||||
required this.success,
|
||||
this.notFound = false,
|
||||
this.errorMessage,
|
||||
this.paichaNo,
|
||||
this.totalCount = 0,
|
||||
this.items = const [],
|
||||
});
|
||||
|
||||
factory PaichaOverviewResult.ok(Map<String, dynamic> json) {
|
||||
final items =
|
||||
(json['items'] as List<dynamic>?)
|
||||
?.map(
|
||||
(item) =>
|
||||
PaichaOverviewItem.fromJson(item as Map<String, dynamic>),
|
||||
)
|
||||
.toList() ??
|
||||
[];
|
||||
return PaichaOverviewResult(
|
||||
success: true,
|
||||
paichaNo: json['paicha_no'] as String?,
|
||||
totalCount: json['total_count'] as int? ?? items.length,
|
||||
items: items,
|
||||
);
|
||||
}
|
||||
|
||||
factory PaichaOverviewResult.notFoundResult() => PaichaOverviewResult(
|
||||
success: false,
|
||||
notFound: true,
|
||||
errorMessage: '暂无排产信息',
|
||||
);
|
||||
|
||||
factory PaichaOverviewResult.error(String message) =>
|
||||
PaichaOverviewResult(success: false, errorMessage: message);
|
||||
}
|
||||
|
||||
// === 装箱模块数据类 ===
|
||||
|
||||
/// 箱号内单个总排号明细
|
||||
class BoxItemData {
|
||||
final int? boxItemId;
|
||||
final String zongpaiNo;
|
||||
final String? workOrderNo;
|
||||
final int quantity;
|
||||
final int? totalQuantity;
|
||||
|
||||
BoxItemData({required this.zongpaiNo, required this.quantity});
|
||||
BoxItemData({
|
||||
this.boxItemId,
|
||||
required this.zongpaiNo,
|
||||
this.workOrderNo,
|
||||
required this.quantity,
|
||||
this.totalQuantity,
|
||||
});
|
||||
|
||||
factory BoxItemData.fromJson(Map<String, dynamic> json) {
|
||||
return BoxItemData(
|
||||
boxItemId: json['box_item_id'] as int?,
|
||||
zongpaiNo: json['zongpai_no'] as String,
|
||||
workOrderNo: json['work_order_no']?.toString(),
|
||||
quantity: json['quantity'] as int,
|
||||
totalQuantity: json['total_quantity'] as int?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -50,7 +153,8 @@ class BoxDetailData {
|
||||
BoxDetailData({required this.boxNo, required this.items});
|
||||
|
||||
factory BoxDetailData.fromJson(Map<String, dynamic> json) {
|
||||
final items = (json['items'] as List<dynamic>?)
|
||||
final items =
|
||||
(json['items'] as List<dynamic>?)
|
||||
?.map((i) => BoxItemData.fromJson(i as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
@@ -58,13 +162,36 @@ class BoxDetailData {
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前总排号已分配的装箱明细
|
||||
class CurrentZongpaiBoxData {
|
||||
final int boxItemId;
|
||||
final int boxNo;
|
||||
final int quantity;
|
||||
|
||||
CurrentZongpaiBoxData({
|
||||
required this.boxItemId,
|
||||
required this.boxNo,
|
||||
required this.quantity,
|
||||
});
|
||||
|
||||
factory CurrentZongpaiBoxData.fromJson(Map<String, dynamic> json) {
|
||||
return CurrentZongpaiBoxData(
|
||||
boxItemId: json['box_item_id'] as int,
|
||||
boxNo: json['box_no'] as int,
|
||||
quantity: json['quantity'] as int,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 装箱信息查询结果
|
||||
class BoxInfoResult {
|
||||
final bool success;
|
||||
final String? errorMessage;
|
||||
final String? zongpaiNo;
|
||||
final String? paichanNo;
|
||||
final String? workOrderNo;
|
||||
final int? quantity;
|
||||
final List<CurrentZongpaiBoxData> currentZongpaiBoxes;
|
||||
final List<BoxDetailData> existingBoxes;
|
||||
final int maxBoxNo;
|
||||
final int suggestedBoxNo;
|
||||
@@ -74,22 +201,34 @@ class BoxInfoResult {
|
||||
this.errorMessage,
|
||||
this.zongpaiNo,
|
||||
this.paichanNo,
|
||||
this.workOrderNo,
|
||||
this.quantity,
|
||||
this.currentZongpaiBoxes = const [],
|
||||
this.existingBoxes = const [],
|
||||
this.maxBoxNo = 0,
|
||||
this.suggestedBoxNo = 1,
|
||||
});
|
||||
|
||||
factory BoxInfoResult.ok(Map<String, dynamic> json) {
|
||||
final boxes = (json['existing_boxes'] as List<dynamic>?)
|
||||
final boxes =
|
||||
(json['existing_boxes'] as List<dynamic>?)
|
||||
?.map((b) => BoxDetailData.fromJson(b as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
final currentBoxes =
|
||||
(json['current_zongpai_boxes'] as List<dynamic>?)
|
||||
?.map(
|
||||
(b) => CurrentZongpaiBoxData.fromJson(b as Map<String, dynamic>),
|
||||
)
|
||||
.toList() ??
|
||||
[];
|
||||
return BoxInfoResult(
|
||||
success: true,
|
||||
zongpaiNo: json['zongpai_no'] as String?,
|
||||
paichanNo: json['paichan_no'] as String?,
|
||||
workOrderNo: json['work_order_no']?.toString(),
|
||||
quantity: json['quantity'] as int?,
|
||||
currentZongpaiBoxes: currentBoxes,
|
||||
existingBoxes: boxes,
|
||||
maxBoxNo: json['max_box_no'] as int? ?? 0,
|
||||
suggestedBoxNo: json['suggested_box_no'] as int? ?? 1,
|
||||
@@ -106,22 +245,33 @@ class BoxSaveResult {
|
||||
final bool success;
|
||||
final bool isDuplicate;
|
||||
final String? errorMessage;
|
||||
final String? errorCode;
|
||||
final int? boxItemId;
|
||||
final String? paichanNo;
|
||||
final String? zongpaiNo;
|
||||
final int? boxNo;
|
||||
final int? quantity;
|
||||
|
||||
BoxSaveResult({
|
||||
required this.success,
|
||||
this.isDuplicate = false,
|
||||
this.errorMessage,
|
||||
this.errorCode,
|
||||
this.boxItemId,
|
||||
this.paichanNo,
|
||||
this.zongpaiNo,
|
||||
this.boxNo,
|
||||
this.quantity,
|
||||
});
|
||||
|
||||
factory BoxSaveResult.ok(Map<String, dynamic> json) {
|
||||
return BoxSaveResult(
|
||||
success: true,
|
||||
boxItemId: json['box_item_id'] as int?,
|
||||
paichanNo: json['paichan_no'] as String?,
|
||||
zongpaiNo: json['zongpai_no'] as String?,
|
||||
boxNo: json['box_no'] as int?,
|
||||
quantity: json['quantity'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,7 +279,10 @@ class BoxSaveResult {
|
||||
return BoxSaveResult(
|
||||
success: false,
|
||||
isDuplicate: true,
|
||||
errorCode: json['error_code']?.toString(),
|
||||
errorMessage: json['message']?.toString(),
|
||||
paichanNo: json['paichan_no'] as String?,
|
||||
zongpaiNo: json['zongpai_no'] as String?,
|
||||
boxNo: json['box_no'] as int?,
|
||||
);
|
||||
}
|
||||
@@ -139,12 +292,19 @@ class BoxSaveResult {
|
||||
}
|
||||
}
|
||||
|
||||
class BoxDeleteResult {
|
||||
final bool success;
|
||||
final String? errorMessage;
|
||||
|
||||
BoxDeleteResult({required this.success, this.errorMessage});
|
||||
}
|
||||
|
||||
class ApiService {
|
||||
final http.Client _client;
|
||||
final Duration timeout;
|
||||
|
||||
ApiService({http.Client? client, this.timeout = const Duration(seconds: 5)})
|
||||
: _client = client ?? http.Client();
|
||||
: _client = client ?? http.Client();
|
||||
|
||||
/// Submit a shelf registration (上架登记).
|
||||
Future<RegistrationResult> registerLocation({
|
||||
@@ -167,6 +327,10 @@ class ApiService {
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 200:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
if (body['previous_location'] != null) {
|
||||
return RegistrationResult.offShelfSuccess();
|
||||
}
|
||||
return RegistrationResult.ok();
|
||||
case 400:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
@@ -174,10 +338,21 @@ class ApiService {
|
||||
return RegistrationResult.error(msg);
|
||||
case 409:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return RegistrationResult.duplicate(body);
|
||||
final errorCode = body['error_code']?.toString();
|
||||
if (errorCode == 'DUPLICATE_LOCATION') {
|
||||
return RegistrationResult.duplicate(body);
|
||||
}
|
||||
if (errorCode == 'ALREADY_OFF_SHELF') {
|
||||
return RegistrationResult.alreadyOffShelf(body);
|
||||
}
|
||||
final msg = body['message']?.toString() ?? '提交失败';
|
||||
return RegistrationResult.error(msg);
|
||||
default:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final msg = body['message'] ?? body['error'] ?? 'Unknown error (${response.statusCode})';
|
||||
final msg =
|
||||
body['message'] ??
|
||||
body['error'] ??
|
||||
'Unknown error (${response.statusCode})';
|
||||
return RegistrationResult.error(msg.toString());
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -185,14 +360,42 @@ class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<PaichaOverviewResult> fetchPaichaOverview({
|
||||
required String baseUrl,
|
||||
required String zongpaiNo,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'$baseUrl/CargoTrace/location/paicha-overview',
|
||||
).replace(queryParameters: {'zongpai_no': zongpaiNo});
|
||||
try {
|
||||
final response = await _client
|
||||
.get(uri, headers: {'Content-Type': 'application/json'})
|
||||
.timeout(timeout);
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 200:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return PaichaOverviewResult.ok(body);
|
||||
case 400:
|
||||
return PaichaOverviewResult.error('无效的总排号格式');
|
||||
case 404:
|
||||
return PaichaOverviewResult.notFoundResult();
|
||||
default:
|
||||
return PaichaOverviewResult.error('加载失败,点击重试');
|
||||
}
|
||||
} catch (e) {
|
||||
return PaichaOverviewResult.error('加载失败,点击重试');
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询装箱信息 — GET /CargoTrace/box/info
|
||||
Future<BoxInfoResult> fetchBoxInfo({
|
||||
required String baseUrl,
|
||||
required String zongpaiNo,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/CargoTrace/box/info').replace(
|
||||
queryParameters: {'zongpai_no': zongpaiNo},
|
||||
);
|
||||
final uri = Uri.parse(
|
||||
'$baseUrl/CargoTrace/box/info',
|
||||
).replace(queryParameters: {'zongpai_no': zongpaiNo});
|
||||
try {
|
||||
final response = await _client
|
||||
.get(uri, headers: {'Content-Type': 'application/json'})
|
||||
@@ -256,6 +459,71 @@ class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新装箱明细 — PATCH /CargoTrace/box/{boxItemId}
|
||||
Future<BoxSaveResult> updateBoxRecord({
|
||||
required String baseUrl,
|
||||
required int boxItemId,
|
||||
required int boxNo,
|
||||
required int quantity,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/CargoTrace/box/$boxItemId');
|
||||
try {
|
||||
final response = await _client
|
||||
.patch(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'box_no': boxNo, 'quantity': quantity}),
|
||||
)
|
||||
.timeout(timeout);
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 200:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return BoxSaveResult.ok(body);
|
||||
case 400:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final msg = body['message']?.toString() ?? '请求参数错误';
|
||||
return BoxSaveResult.error(msg);
|
||||
case 409:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return BoxSaveResult.duplicate(body);
|
||||
case 404:
|
||||
return BoxSaveResult.error('指定装箱明细不存在');
|
||||
default:
|
||||
return BoxSaveResult.error('修改失败 (${response.statusCode})');
|
||||
}
|
||||
} catch (e) {
|
||||
return BoxSaveResult.error('网络异常,请检查网络连接');
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除装箱明细 — DELETE /CargoTrace/box/{boxItemId}
|
||||
Future<BoxDeleteResult> deleteBoxRecord({
|
||||
required String baseUrl,
|
||||
required int boxItemId,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/CargoTrace/box/$boxItemId');
|
||||
try {
|
||||
final response = await _client
|
||||
.delete(uri, headers: {'Content-Type': 'application/json'})
|
||||
.timeout(timeout);
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 200:
|
||||
return BoxDeleteResult(success: true);
|
||||
case 404:
|
||||
return BoxDeleteResult(success: false, errorMessage: '指定装箱明细不存在');
|
||||
default:
|
||||
return BoxDeleteResult(
|
||||
success: false,
|
||||
errorMessage: '删除失败 (${response.statusCode})',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return BoxDeleteResult(success: false, errorMessage: '网络异常,请检查网络连接');
|
||||
}
|
||||
}
|
||||
|
||||
/// Test connectivity by making a HEAD request to the base URL.
|
||||
Future<bool> testConnection(String baseUrl) async {
|
||||
try {
|
||||
|
||||
@@ -38,11 +38,11 @@ class AppConfigService {
|
||||
Future<void> setString(String key, String value) async {
|
||||
final config = await loadConfig();
|
||||
config[key.toString()] = value.toString();
|
||||
await _saveConfig(config);
|
||||
await saveConfig(config);
|
||||
}
|
||||
|
||||
/// 完整写入 JSON 文件
|
||||
Future<void> _saveConfig(Map<String, dynamic> config) async {
|
||||
Future<void> saveConfig(Map<String, dynamic> config) async {
|
||||
try {
|
||||
final file = File(await _filePath);
|
||||
await file.writeAsString(jsonEncode(config));
|
||||
|
||||
38
lib/services/boxing_context.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
enum PaichanContextChange { firstScan, samePaichan, switchedPaichan }
|
||||
|
||||
class PaichanContextDecision {
|
||||
final PaichanContextChange change;
|
||||
final int? boxNoToApply;
|
||||
|
||||
const PaichanContextDecision({required this.change, this.boxNoToApply});
|
||||
|
||||
bool get isSwitched => change == PaichanContextChange.switchedPaichan;
|
||||
}
|
||||
|
||||
PaichanContextDecision decideMultiCodePaichanContext({
|
||||
required String? lastPaichanNo,
|
||||
required String? currentPaichanNo,
|
||||
required String currentBoxNoText,
|
||||
required int maxBoxNo,
|
||||
}) {
|
||||
final recommendedBoxNo = maxBoxNo + 1;
|
||||
if (currentPaichanNo == null || currentPaichanNo.isEmpty) {
|
||||
return const PaichanContextDecision(change: PaichanContextChange.firstScan);
|
||||
}
|
||||
if (lastPaichanNo == null || lastPaichanNo.isEmpty) {
|
||||
return PaichanContextDecision(
|
||||
change: PaichanContextChange.firstScan,
|
||||
boxNoToApply: currentBoxNoText.trim().isEmpty ? recommendedBoxNo : null,
|
||||
);
|
||||
}
|
||||
if (lastPaichanNo == currentPaichanNo) {
|
||||
return PaichanContextDecision(
|
||||
change: PaichanContextChange.samePaichan,
|
||||
boxNoToApply: currentBoxNoText.trim().isEmpty ? recommendedBoxNo : null,
|
||||
);
|
||||
}
|
||||
return PaichanContextDecision(
|
||||
change: PaichanContextChange.switchedPaichan,
|
||||
boxNoToApply: recommendedBoxNo,
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
// lib/services/code_parser.dart
|
||||
class CodeParser {
|
||||
static final _zongpaiRegex = RegExp(
|
||||
r'^\d{2}(B|C|T)\d+$|^\d{2}(BW|CW)\d{4}$',
|
||||
);
|
||||
static final _locationRegex = RegExp(
|
||||
r'^[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$',
|
||||
);
|
||||
static final _transitRegex = RegExp(
|
||||
r'^TRANS-',
|
||||
);
|
||||
static final _zongpaiRegex = RegExp(r'^\d{2}(B|C|T)\d+$|^\d{2}(BW|CW)\d{4}$');
|
||||
static final _locationRegex = RegExp(r'^[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$');
|
||||
static final _transitRegex = RegExp(r'^TRANS-');
|
||||
|
||||
static ParseResult parse(String code) {
|
||||
final trimmed = code.trim();
|
||||
@@ -23,16 +17,30 @@ class CodeParser {
|
||||
return ParseResult(type: CodeType.zongpaiNo, value: normalized);
|
||||
}
|
||||
if (_locationRegex.hasMatch(normalized)) {
|
||||
if (normalized.startsWith('B')) {
|
||||
return ParseResult(
|
||||
type: CodeType.locationTempStorage,
|
||||
value: normalized,
|
||||
);
|
||||
}
|
||||
return ParseResult(type: CodeType.locationNormal, value: normalized);
|
||||
}
|
||||
return ParseResult(type: CodeType.invalid, value: code);
|
||||
}
|
||||
|
||||
static bool isLocation(CodeType type) =>
|
||||
type == CodeType.locationNormal || type == CodeType.locationTransit;
|
||||
type == CodeType.locationNormal ||
|
||||
type == CodeType.locationTransit ||
|
||||
type == CodeType.locationTempStorage;
|
||||
}
|
||||
|
||||
enum CodeType { zongpaiNo, locationNormal, locationTransit, invalid }
|
||||
enum CodeType {
|
||||
zongpaiNo,
|
||||
locationNormal,
|
||||
locationTransit,
|
||||
locationTempStorage,
|
||||
invalid,
|
||||
}
|
||||
|
||||
class ParseResult {
|
||||
final CodeType type;
|
||||
|
||||
98
lib/services/feedback_service.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
import 'package:vibration/vibration.dart';
|
||||
import 'package:pad_scanner/services/sound_service.dart';
|
||||
|
||||
/// Unified feedback events used across the app.
|
||||
enum FeedbackEvent {
|
||||
scanValid, // Valid barcode scanned → beep + short vibrate
|
||||
scanInvalid, // Invalid barcode → error sound + short vibrate
|
||||
submitSuccess, // Submit succeeded → success sound + long vibrate
|
||||
submitFailure, // Submit failed → failure sound + short vibrate
|
||||
duplicateError, // Duplicate (shelf) → alert loop + continuous vibrate
|
||||
networkError, // Network error → failure sound + short vibrate
|
||||
duplicateBoxNo, // Duplicate box no → failure sound + short vibrate
|
||||
alreadyCompleted, // Zongpai already boxed → beep + double vibrate
|
||||
modeSwitch, // Mode toggle → beep only
|
||||
paichanSwitch, // Paichan changed → beep + short vibrate
|
||||
}
|
||||
|
||||
/// Centralized feedback dispatcher.
|
||||
///
|
||||
/// Each page manages its own bottom status bar state; this service handles
|
||||
/// the sound, vibration channels only.
|
||||
class FeedbackService {
|
||||
final SoundService _soundService;
|
||||
|
||||
FeedbackService({SoundService? soundService})
|
||||
: _soundService = soundService ?? SoundService();
|
||||
|
||||
/// Trigger feedback for the given [event].
|
||||
Future<void> trigger(FeedbackEvent event) async {
|
||||
switch (event) {
|
||||
case FeedbackEvent.scanValid:
|
||||
_soundService.playBeep();
|
||||
_vibrateShort();
|
||||
|
||||
case FeedbackEvent.scanInvalid:
|
||||
_soundService.playError();
|
||||
_vibrateShort();
|
||||
|
||||
case FeedbackEvent.submitSuccess:
|
||||
_soundService.playSuccess();
|
||||
_vibrateLong();
|
||||
|
||||
case FeedbackEvent.submitFailure:
|
||||
_soundService.playFailure();
|
||||
_vibrateShort();
|
||||
|
||||
case FeedbackEvent.duplicateError:
|
||||
_soundService.playAlertLoop();
|
||||
_vibratePattern();
|
||||
|
||||
case FeedbackEvent.networkError:
|
||||
_soundService.playFailure();
|
||||
_vibrateShort();
|
||||
|
||||
case FeedbackEvent.duplicateBoxNo:
|
||||
_soundService.playFailure();
|
||||
_vibrateShort();
|
||||
|
||||
case FeedbackEvent.alreadyCompleted:
|
||||
_soundService.playBeep();
|
||||
_vibrateDouble();
|
||||
|
||||
case FeedbackEvent.modeSwitch:
|
||||
_soundService.playBeep();
|
||||
|
||||
case FeedbackEvent.paichanSwitch:
|
||||
_soundService.playBeep();
|
||||
_vibrateShort();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop any ongoing alert (e.g. when duplicate dialog is closed).
|
||||
Future<void> stopAlert() async {
|
||||
_soundService.stopAlert();
|
||||
}
|
||||
|
||||
void _vibrateShort() {
|
||||
Vibration.vibrate(duration: 100);
|
||||
}
|
||||
|
||||
void _vibrateLong() {
|
||||
Vibration.vibrate(duration: 200);
|
||||
}
|
||||
|
||||
/// Continuous vibration pattern: 3 bursts to signal critical error.
|
||||
void _vibratePattern() {
|
||||
Vibration.vibrate(pattern: [0, 300, 100, 300, 100, 300]);
|
||||
}
|
||||
|
||||
/// Two short bursts to signal an already-completed item.
|
||||
void _vibrateDouble() {
|
||||
Vibration.vibrate(pattern: [0, 200, 100, 200]);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _soundService.dispose();
|
||||
}
|
||||
}
|
||||
46
lib/services/registration_linking.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
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);
|
||||
}
|
||||
|
||||
bool isTempStorageTarget(CodeType? locationType, String? locationCode) {
|
||||
return locationType == CodeType.locationTempStorage ||
|
||||
(locationCode?.startsWith('B') ?? false);
|
||||
}
|
||||
|
||||
String registrationSubmitLabel({
|
||||
required bool isTransitTarget,
|
||||
required bool isMultiCode,
|
||||
required int zongpaiCount,
|
||||
}) {
|
||||
if (!isTransitTarget) {
|
||||
return isMultiCode && zongpaiCount > 1
|
||||
? '批量上架($zongpaiCount 条)'
|
||||
: '确 认 上 架';
|
||||
}
|
||||
return isMultiCode && 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 {
|
||||
static const _eventChannel =
|
||||
EventChannel('com.example.pad_scanner/scan');
|
||||
|
||||
Stream<ScanResult>? _scanStream;
|
||||
static const _eventChannel = EventChannel('com.example.pad_scanner/scan');
|
||||
static Stream<ScanResult>? _sharedScanStream;
|
||||
|
||||
Stream<ScanResult> get scanResults {
|
||||
_scanStream ??= _eventChannel
|
||||
_sharedScanStream ??= _createSharedScanStream();
|
||||
return _sharedScanStream!;
|
||||
}
|
||||
|
||||
static Stream<ScanResult> _createSharedScanStream() {
|
||||
return _eventChannel
|
||||
.receiveBroadcastStream()
|
||||
.map((event) => event as Map)
|
||||
.map((event) => ScanResult(
|
||||
barcode: event['barcode'] as String? ?? '',
|
||||
codeType: event['codeType'] as String? ?? 'UNKNOWN',
|
||||
));
|
||||
return _scanStream!;
|
||||
.map(
|
||||
(event) => ScanResult(
|
||||
barcode: event['barcode'] as String? ?? '',
|
||||
codeType: event['codeType'] as String? ?? 'UNKNOWN',
|
||||
),
|
||||
)
|
||||
.asBroadcastStream();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,64 @@
|
||||
import 'dart:async';
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:pad_scanner/services/app_config_service.dart';
|
||||
|
||||
class SoundService {
|
||||
static const _keySuccess = 'sound_success';
|
||||
static const _keyFailure = 'sound_failure';
|
||||
static const _keyBeep = 'sound_beep';
|
||||
static const _keyError = 'sound_error';
|
||||
static const _keyAlert = 'sound_alert';
|
||||
|
||||
final _player = AudioPlayer();
|
||||
final _alertPlayer = AudioPlayer();
|
||||
final _configService = AppConfigService();
|
||||
|
||||
Future<String?> getSuccessPath() async {
|
||||
return _configService.getString(_keySuccess);
|
||||
}
|
||||
Timer? _alertTimer;
|
||||
|
||||
Future<String?> getFailurePath() async {
|
||||
return _configService.getString(_keyFailure);
|
||||
}
|
||||
// --- Path getters ---
|
||||
|
||||
Future<void> setSuccessPath(String? path) async {
|
||||
Future<String?> getSuccessPath() =>
|
||||
_configService.getString(_keySuccess);
|
||||
|
||||
Future<String?> getFailurePath() =>
|
||||
_configService.getString(_keyFailure);
|
||||
|
||||
Future<String?> getBeepPath() =>
|
||||
_configService.getString(_keyBeep);
|
||||
|
||||
Future<String?> getErrorPath() =>
|
||||
_configService.getString(_keyError);
|
||||
|
||||
Future<String?> getAlertPath() =>
|
||||
_configService.getString(_keyAlert);
|
||||
|
||||
// --- Path setters ---
|
||||
|
||||
Future<void> setSuccessPath(String? path) =>
|
||||
_setConfigPath(_keySuccess, path);
|
||||
|
||||
Future<void> setFailurePath(String? path) =>
|
||||
_setConfigPath(_keyFailure, path);
|
||||
|
||||
Future<void> setBeepPath(String? path) =>
|
||||
_setConfigPath(_keyBeep, path);
|
||||
|
||||
Future<void> setErrorPath(String? path) =>
|
||||
_setConfigPath(_keyError, path);
|
||||
|
||||
Future<void> setAlertPath(String? path) =>
|
||||
_setConfigPath(_keyAlert, path);
|
||||
|
||||
Future<void> _setConfigPath(String key, String? path) async {
|
||||
if (path != null) {
|
||||
await _configService.setString(_keySuccess, path);
|
||||
await _configService.setString(key, path);
|
||||
} else {
|
||||
final config = await _configService.loadConfig();
|
||||
config.remove(_keySuccess);
|
||||
config.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setFailurePath(String? path) async {
|
||||
if (path != null) {
|
||||
await _configService.setString(_keyFailure, path);
|
||||
} else {
|
||||
final config = await _configService.loadConfig();
|
||||
config.remove(_keyFailure);
|
||||
}
|
||||
}
|
||||
// --- Playback methods ---
|
||||
|
||||
Future<void> playSuccess() async {
|
||||
final path = await getSuccessPath();
|
||||
@@ -48,11 +74,55 @@ class SoundService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> playBeep() async {
|
||||
final path = await getBeepPath();
|
||||
if (path != null) {
|
||||
await _player.play(DeviceFileSource(path));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> playError() async {
|
||||
final path = await getErrorPath();
|
||||
if (path != null) {
|
||||
await _player.play(DeviceFileSource(path));
|
||||
}
|
||||
}
|
||||
|
||||
/// Play alert sound in a loop until [stopAlert] is called.
|
||||
/// Loops up to 10 times as a safety guard (~15 seconds max).
|
||||
Future<void> playAlertLoop() async {
|
||||
final path = await getAlertPath();
|
||||
if (path == null) return;
|
||||
|
||||
_alertTimer?.cancel();
|
||||
var count = 0;
|
||||
_alertTimer = Timer.periodic(const Duration(milliseconds: 1500), (_) async {
|
||||
if (count >= 10) {
|
||||
_alertTimer?.cancel();
|
||||
return;
|
||||
}
|
||||
await _alertPlayer.play(DeviceFileSource(path));
|
||||
count++;
|
||||
});
|
||||
// Play immediately first time
|
||||
await _alertPlayer.play(DeviceFileSource(path));
|
||||
count++;
|
||||
}
|
||||
|
||||
/// Stop the alert loop.
|
||||
void stopAlert() {
|
||||
_alertTimer?.cancel();
|
||||
_alertTimer = null;
|
||||
_alertPlayer.stop();
|
||||
}
|
||||
|
||||
Future<void> play(String path) async {
|
||||
await _player.play(DeviceFileSource(path));
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_alertTimer?.cancel();
|
||||
await _player.dispose();
|
||||
await _alertPlayer.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
61
lib/widgets/status_bar.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Status bar dot colors matching PRD requirements.
|
||||
enum StatusDotColor {
|
||||
blue, // idle / waiting
|
||||
orange, // submitting
|
||||
green, // success
|
||||
red, // error (invalid code / submit failure / duplicate)
|
||||
yellow, // network error
|
||||
amber, // warning (duplicate box number)
|
||||
}
|
||||
|
||||
/// Reusable bottom status bar with a colored dot and text.
|
||||
class StatusBar extends StatelessWidget {
|
||||
final StatusDotColor dotColor;
|
||||
final String text;
|
||||
|
||||
const StatusBar({super.key, required this.dotColor, required this.text});
|
||||
|
||||
Color _resolveDotColor(BuildContext context) {
|
||||
return switch (dotColor) {
|
||||
StatusDotColor.blue => Colors.blue,
|
||||
StatusDotColor.orange => Colors.orange,
|
||||
StatusDotColor.green => Colors.green,
|
||||
StatusDotColor.red => Colors.red,
|
||||
StatusDotColor.yellow => Colors.yellow.shade700,
|
||||
StatusDotColor.amber => Colors.amber,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 28,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: _resolveDotColor(context),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
159
test/pages/boxing/boxing_api_actions_test.dart
Normal file
@@ -0,0 +1,159 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:pad_scanner/pages/boxing/boxing_api_actions.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
void main() {
|
||||
group('BoxingApiActions', () {
|
||||
test('returns missingApiUrl when API URL is empty', () async {
|
||||
final actions = BoxingApiActions(
|
||||
apiService: ApiService(
|
||||
client: _MockClient((_) async {
|
||||
fail('ApiService should not be called without a base URL');
|
||||
}),
|
||||
),
|
||||
loadApiUrl: () async => '',
|
||||
);
|
||||
|
||||
final result = await actions.fetchBoxInfo('26B1');
|
||||
|
||||
expect(result.success, isFalse);
|
||||
expect(result.errorKind, BoxingActionErrorKind.missingApiUrl);
|
||||
expect(result.errorMessage, BoxingApiActions.missingApiUrlMessage);
|
||||
});
|
||||
|
||||
test('fetchBoxInfo passes through successful data', () async {
|
||||
final actions = BoxingApiActions(
|
||||
apiService: ApiService(
|
||||
client: _MockClient((request) async {
|
||||
expect(request.url.path, '/CargoTrace/box/info');
|
||||
expect(request.url.queryParameters['zongpai_no'], '26B1');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'zongpai_no': '26B1',
|
||||
'paichan_no': 'W00009',
|
||||
'work_order_no': 'WO001',
|
||||
'quantity': 8,
|
||||
'current_zongpai_boxes': [
|
||||
{'box_item_id': 1, 'box_no': 2, 'quantity': 3},
|
||||
],
|
||||
'existing_boxes': [],
|
||||
'max_box_no': 2,
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
),
|
||||
loadApiUrl: () async => 'http://localhost',
|
||||
);
|
||||
|
||||
final result = await actions.fetchBoxInfo('26B1');
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(result.data?.paichanNo, 'W00009');
|
||||
expect(result.data?.currentZongpaiBoxes.single.boxNo, 2);
|
||||
});
|
||||
|
||||
test('saveBoxRecord maps duplicate and preserves box number', () async {
|
||||
final actions = BoxingApiActions(
|
||||
apiService: ApiService(
|
||||
client: _MockClient((_) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'error_code': 'DUPLICATE_BOX_ITEM',
|
||||
'message': '重复装箱',
|
||||
'box_no': 7,
|
||||
}),
|
||||
409,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
),
|
||||
loadApiUrl: () async => 'http://localhost',
|
||||
);
|
||||
|
||||
final result = await actions.saveBoxRecord(
|
||||
zongpaiNo: '26B1',
|
||||
boxNo: 7,
|
||||
quantity: 2,
|
||||
);
|
||||
|
||||
expect(result.success, isFalse);
|
||||
expect(result.errorKind, BoxingActionErrorKind.duplicate);
|
||||
expect(result.boxNo, 7);
|
||||
});
|
||||
|
||||
test('updateBoxRecord maps network errors', () async {
|
||||
final actions = BoxingApiActions(
|
||||
apiService: ApiService(
|
||||
client: _MockClient((_) async {
|
||||
throw Exception('Connection refused');
|
||||
}),
|
||||
),
|
||||
loadApiUrl: () async => 'http://localhost',
|
||||
);
|
||||
|
||||
final result = await actions.updateBoxRecord(
|
||||
boxItemId: 1,
|
||||
boxNo: 2,
|
||||
quantity: 3,
|
||||
);
|
||||
|
||||
expect(result.success, isFalse);
|
||||
expect(result.errorKind, BoxingActionErrorKind.network);
|
||||
expect(result.errorMessage, BoxingApiActions.networkErrorMessage);
|
||||
});
|
||||
|
||||
test('deleteBoxRecord passes through success and backend errors', () async {
|
||||
var calls = 0;
|
||||
final actions = BoxingApiActions(
|
||||
apiService: ApiService(
|
||||
client: _MockClient((_) async {
|
||||
calls += 1;
|
||||
if (calls == 1) {
|
||||
return http.Response(
|
||||
jsonEncode({'box_item_id': 1, 'deleted': true}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({'message': '指定装箱明细不存在'}),
|
||||
404,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
),
|
||||
loadApiUrl: () async => 'http://localhost',
|
||||
);
|
||||
|
||||
final ok = await actions.deleteBoxRecord(boxItemId: 1);
|
||||
final missing = await actions.deleteBoxRecord(boxItemId: 999);
|
||||
|
||||
expect(ok.success, isTrue);
|
||||
expect(missing.success, isFalse);
|
||||
expect(missing.errorKind, BoxingActionErrorKind.backend);
|
||||
expect(missing.errorMessage, '指定装箱明细不存在');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _MockClient extends http.BaseClient {
|
||||
final Future<http.Response> Function(http.BaseRequest) _handler;
|
||||
|
||||
_MockClient(this._handler);
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
final response = await _handler(request);
|
||||
return http.StreamedResponse(
|
||||
http.ByteStream.fromBytes(response.bodyBytes),
|
||||
response.statusCode,
|
||||
headers: response.headers,
|
||||
reasonPhrase: response.reasonPhrase,
|
||||
);
|
||||
}
|
||||
}
|
||||
88
test/pages/boxing/boxing_box_mutations_test.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pad_scanner/pages/boxing/boxing_box_mutations.dart';
|
||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
void main() {
|
||||
group('boxing box mutations', () {
|
||||
test('adds item to a new sorted box', () {
|
||||
final result = addExistingBoxItem(
|
||||
existingBoxes: [BoxDetailData(boxNo: 3, items: const [])],
|
||||
boxNo: 2,
|
||||
quantity: 5,
|
||||
boxItemId: 22,
|
||||
zongpaiNo: 'ZP022',
|
||||
workOrderNo: 'WO022',
|
||||
totalQuantity: 5,
|
||||
);
|
||||
|
||||
expect(result.boxes.map((box) => box.boxNo), [2, 3]);
|
||||
expect(result.maxBoxNo, 3);
|
||||
expect(result.boxes.first.items.single.zongpaiNo, 'ZP022');
|
||||
});
|
||||
|
||||
test('replaces single code item and removes empty source box', () {
|
||||
final result = replaceExistingBoxItem(
|
||||
existingBoxes: [
|
||||
BoxDetailData(
|
||||
boxNo: 1,
|
||||
items: [BoxItemData(boxItemId: 1, zongpaiNo: 'ZP001', quantity: 2)],
|
||||
),
|
||||
],
|
||||
editing: CurrentZongpaiBoxData(boxItemId: 1, boxNo: 1, quantity: 2),
|
||||
boxNo: 4,
|
||||
quantity: 3,
|
||||
zongpaiNo: 'ZP001',
|
||||
workOrderNo: 'WO001',
|
||||
totalQuantity: 5,
|
||||
);
|
||||
|
||||
expect(result.boxes.map((box) => box.boxNo), [4]);
|
||||
expect(result.boxes.single.items.single.quantity, 3);
|
||||
expect(result.maxBoxNo, 4);
|
||||
});
|
||||
|
||||
test('moves packed item to a different box', () {
|
||||
final result = replaceExistingPackedItem(
|
||||
existingBoxes: [
|
||||
BoxDetailData(
|
||||
boxNo: 2,
|
||||
items: [BoxItemData(boxItemId: 9, zongpaiNo: 'ZP009', quantity: 1)],
|
||||
),
|
||||
],
|
||||
item: const ManyToOnePackedItem(
|
||||
boxItemId: 9,
|
||||
zongpaiNo: 'ZP009',
|
||||
workOrderNo: 'WO009',
|
||||
boxNo: 2,
|
||||
quantity: 1,
|
||||
totalQuantity: 3,
|
||||
),
|
||||
boxNo: 5,
|
||||
quantity: 2,
|
||||
);
|
||||
|
||||
expect(result.boxes.map((box) => box.boxNo), [5]);
|
||||
expect(result.boxes.single.items.single.quantity, 2);
|
||||
});
|
||||
|
||||
test('removes item and drops empty box', () {
|
||||
final result = removeExistingBoxItem(
|
||||
existingBoxes: [
|
||||
BoxDetailData(
|
||||
boxNo: 1,
|
||||
items: [BoxItemData(boxItemId: 1, zongpaiNo: 'ZP001', quantity: 2)],
|
||||
),
|
||||
BoxDetailData(
|
||||
boxNo: 3,
|
||||
items: [BoxItemData(boxItemId: 2, zongpaiNo: 'ZP002', quantity: 1)],
|
||||
),
|
||||
],
|
||||
boxItemId: 1,
|
||||
);
|
||||
|
||||
expect(result.boxes.map((box) => box.boxNo), [3]);
|
||||
expect(result.maxBoxNo, 3);
|
||||
});
|
||||
});
|
||||
}
|
||||
123
test/pages/boxing/boxing_calculations_test.dart
Normal file
@@ -0,0 +1,123 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pad_scanner/pages/boxing/boxing_calculations.dart';
|
||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
void main() {
|
||||
group('boxing calculations', () {
|
||||
final currentBoxes = [
|
||||
CurrentZongpaiBoxData(boxItemId: 1, boxNo: 1, quantity: 4),
|
||||
CurrentZongpaiBoxData(boxItemId: 2, boxNo: 2, quantity: 3),
|
||||
];
|
||||
|
||||
test('calculates packed and remaining quantity', () {
|
||||
expect(packedQuantity(currentBoxes), 7);
|
||||
expect(
|
||||
remainingQuantity(totalQuantity: 10, currentBoxes: currentBoxes),
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
test('detects quantity too high only when remaining is positive', () {
|
||||
expect(quantityTooHigh(remainingQuantity: 3, quantityText: '4'), isTrue);
|
||||
expect(quantityTooHigh(remainingQuantity: 3, quantityText: '3'), isFalse);
|
||||
expect(quantityTooHigh(remainingQuantity: 0, quantityText: '1'), isFalse);
|
||||
});
|
||||
|
||||
test('validates submit conditions', () {
|
||||
expect(
|
||||
canSubmitBoxing(
|
||||
isSubmitting: false,
|
||||
phase: BoxingPhase.scanned,
|
||||
zongpaiNo: 'ZP001',
|
||||
boxNoText: '1',
|
||||
quantityText: '2',
|
||||
remainingQuantity: 3,
|
||||
quantityTooHigh: false,
|
||||
isDuplicateBoxNo: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
expect(
|
||||
canSubmitBoxing(
|
||||
isSubmitting: true,
|
||||
phase: BoxingPhase.scanned,
|
||||
zongpaiNo: 'ZP001',
|
||||
boxNoText: '1',
|
||||
quantityText: '2',
|
||||
remainingQuantity: 3,
|
||||
quantityTooHigh: false,
|
||||
isDuplicateBoxNo: false,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('detects duplicate box number in single code mode', () {
|
||||
expect(
|
||||
singleCodeBoxNoIsDuplicate(
|
||||
boxNoText: '1',
|
||||
currentBoxes: currentBoxes,
|
||||
editingBoxItemId: null,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
singleCodeBoxNoIsDuplicate(
|
||||
boxNoText: '1',
|
||||
currentBoxes: currentBoxes,
|
||||
editingBoxItemId: 1,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('merges visible many-to-one items by box item id', () {
|
||||
final existingBoxes = [
|
||||
BoxDetailData(
|
||||
boxNo: 8,
|
||||
items: [
|
||||
BoxItemData(
|
||||
boxItemId: 11,
|
||||
zongpaiNo: 'ZP011',
|
||||
workOrderNo: 'WO011',
|
||||
quantity: 2,
|
||||
totalQuantity: 6,
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
final packedItems = [
|
||||
const ManyToOnePackedItem(
|
||||
boxItemId: 11,
|
||||
zongpaiNo: 'ZP011',
|
||||
paichanNo: 'PC001',
|
||||
workOrderNo: 'WO011',
|
||||
boxNo: 8,
|
||||
quantity: 3,
|
||||
totalQuantity: 6,
|
||||
),
|
||||
const ManyToOnePackedItem(
|
||||
boxItemId: 12,
|
||||
zongpaiNo: 'ZP012',
|
||||
paichanNo: 'PC001',
|
||||
workOrderNo: 'WO012',
|
||||
boxNo: 8,
|
||||
quantity: 1,
|
||||
totalQuantity: 4,
|
||||
),
|
||||
];
|
||||
|
||||
final visible = visibleManyToOnePackedItems(
|
||||
boxNo: 8,
|
||||
existingBoxes: existingBoxes,
|
||||
packedItems: packedItems,
|
||||
paichanNo: 'PC001',
|
||||
);
|
||||
|
||||
expect(visible.map((item) => item.boxItemId), [11, 12]);
|
||||
expect(visible.first.quantity, 3);
|
||||
});
|
||||
});
|
||||
}
|
||||
81
test/pages/boxing/boxing_status_presenter_test.dart
Normal file
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
||||
import 'package:pad_scanner/pages/boxing/boxing_status_presenter.dart';
|
||||
import 'package:pad_scanner/widgets/status_bar.dart';
|
||||
|
||||
void main() {
|
||||
BoxingStatusPresentation status({
|
||||
bool isAutoProcessing = false,
|
||||
bool isSubmitting = false,
|
||||
bool isDuplicateBoxNo = false,
|
||||
bool quantityTooHigh = false,
|
||||
bool isEditingAssigned = false,
|
||||
bool isDeletingAssigned = false,
|
||||
bool hasPaichanSwitchNotice = false,
|
||||
BoxingPhase phase = BoxingPhase.waiting,
|
||||
bool isMultiCode = false,
|
||||
bool hasVisibleManyToOneItems = false,
|
||||
int remainingQuantity = 1,
|
||||
String boxNoText = '1',
|
||||
}) {
|
||||
return boxingStatusFor(
|
||||
isAutoProcessing: isAutoProcessing,
|
||||
isSubmitting: isSubmitting,
|
||||
isDuplicateBoxNo: isDuplicateBoxNo,
|
||||
quantityTooHigh: quantityTooHigh,
|
||||
isEditingAssigned: isEditingAssigned,
|
||||
isDeletingAssigned: isDeletingAssigned,
|
||||
hasPaichanSwitchNotice: hasPaichanSwitchNotice,
|
||||
phase: phase,
|
||||
isMultiCode: isMultiCode,
|
||||
hasVisibleManyToOneItems: hasVisibleManyToOneItems,
|
||||
remainingQuantity: remainingQuantity,
|
||||
boxNoText: boxNoText,
|
||||
);
|
||||
}
|
||||
|
||||
group('boxingStatusFor', () {
|
||||
test('reports waiting and multi-code continuation states', () {
|
||||
expect(status().text, '等待扫码');
|
||||
final multi = status(isMultiCode: true, hasVisibleManyToOneItems: true);
|
||||
expect(multi.text, '请继续扫码或完成本箱');
|
||||
});
|
||||
|
||||
test('prioritizes transient and validation states', () {
|
||||
expect(status(isSubmitting: true).text, '正在提交…');
|
||||
expect(
|
||||
status(isDuplicateBoxNo: true, phase: BoxingPhase.scanned).dot,
|
||||
StatusDotColor.amber,
|
||||
);
|
||||
expect(
|
||||
status(quantityTooHigh: true, phase: BoxingPhase.scanned).text,
|
||||
'超出可装数量上限',
|
||||
);
|
||||
});
|
||||
|
||||
test('reports edit delete and switched paichan states', () {
|
||||
expect(status(isEditingAssigned: true).text, '正在编辑已分配记录');
|
||||
expect(status(isDeletingAssigned: true).dot, StatusDotColor.red);
|
||||
expect(status(hasPaichanSwitchNotice: true).text, '排产号已切换,箱号已重置');
|
||||
});
|
||||
|
||||
test('reports scanned completed and submitted states', () {
|
||||
expect(
|
||||
status(phase: BoxingPhase.scanned, remainingQuantity: 0).text,
|
||||
'该总排号已全部装箱完毕',
|
||||
);
|
||||
expect(
|
||||
status(phase: BoxingPhase.scanned, remainingQuantity: 2).text,
|
||||
'数量已填入,请确认或修改',
|
||||
);
|
||||
expect(
|
||||
status(phase: BoxingPhase.submitted, isMultiCode: false).text,
|
||||
'装箱成功,可继续扫码',
|
||||
);
|
||||
expect(
|
||||
status(phase: BoxingPhase.submitted, isMultiCode: true).text,
|
||||
'请扫描下一个总排号',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
95
test/pages/registration/registration_calculations_test.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pad_scanner/pages/registration/registration_calculations.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
void main() {
|
||||
group('registration calculations', () {
|
||||
test('calculates overview stats by item status', () {
|
||||
final stats = overviewStats(
|
||||
_overview([
|
||||
_item('R1', status: 'not_shelved'),
|
||||
_item('R2', status: 'on_shelf'),
|
||||
_item('R3', status: 'transferred'),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(stats.totalCount, 3);
|
||||
expect(stats.shelvedCount, 1);
|
||||
expect(stats.transferredCount, 1);
|
||||
});
|
||||
|
||||
test('splits overview items into scanned and unscanned groups', () {
|
||||
final sections = splitOverviewItems(
|
||||
overview: _overview([_item('R1'), _item('R2'), _item('R3')]),
|
||||
zongpaiNos: ['R1', 'R3'],
|
||||
);
|
||||
|
||||
expect(sections.scanned.map((item) => item.zongpaiNo), ['R1', 'R3']);
|
||||
expect(sections.unscanned.map((item) => item.zongpaiNo), ['R2']);
|
||||
expect(sections.hasDivider, isTrue);
|
||||
});
|
||||
|
||||
test('finds located scanned items', () {
|
||||
final overview = _overview([
|
||||
_item('R1', status: 'on_shelf'),
|
||||
_item('R2', status: 'transferred'),
|
||||
_item('R3', status: 'on_shelf'),
|
||||
]);
|
||||
|
||||
expect(
|
||||
findOnShelfItemsInScanned(
|
||||
overview: overview,
|
||||
zongpaiNos: ['R1', 'R2'],
|
||||
).map((item) => item.zongpaiNo),
|
||||
['R1'],
|
||||
);
|
||||
expect(
|
||||
findTransferredItemsInScanned(
|
||||
overview: overview,
|
||||
zongpaiNos: ['R1', 'R2'],
|
||||
).map((item) => item.zongpaiNo),
|
||||
['R2'],
|
||||
);
|
||||
expect(
|
||||
hasAnyLocatedInScanned(overview: overview, zongpaiNos: ['R1']),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
hasAnyLocatedInScanned(overview: overview, zongpaiNos: ['R4']),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('maps row colors for scanned and unscanned states', () {
|
||||
expect(
|
||||
registrationBarColor(
|
||||
status: 'on_shelf',
|
||||
isScanned: true,
|
||||
isTransitTarget: false,
|
||||
),
|
||||
Colors.red,
|
||||
);
|
||||
expect(
|
||||
registrationRowBgColor(
|
||||
status: 'transferred',
|
||||
isScanned: false,
|
||||
isTransitTarget: false,
|
||||
),
|
||||
const Color(0xFFFFF3E0),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
PaichaOverviewResult _overview(List<PaichaOverviewItem> items) {
|
||||
return PaichaOverviewResult(
|
||||
success: true,
|
||||
totalCount: items.length,
|
||||
items: items,
|
||||
);
|
||||
}
|
||||
|
||||
PaichaOverviewItem _item(String zongpaiNo, {String status = 'not_shelved'}) {
|
||||
return PaichaOverviewItem(zongpaiNo: zongpaiNo, quantity: 1, status: status);
|
||||
}
|
||||
@@ -8,7 +8,11 @@ void main() {
|
||||
test('returns ok on 200', () async {
|
||||
final mockClient = _MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({'zongpai_no': '26B1', 'location_code': 'A01-02-03', 'created_at': '2026-05-11T10:00:00'}),
|
||||
jsonEncode({
|
||||
'zongpai_no': '26B1',
|
||||
'location_code': 'A01-02-03',
|
||||
'created_at': '2026-05-11T10:00:00',
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
@@ -21,16 +25,69 @@ void main() {
|
||||
);
|
||||
expect(result.success, isTrue);
|
||||
expect(result.isDuplicate, isFalse);
|
||||
expect(result.isOffShelfSuccess, isFalse);
|
||||
});
|
||||
|
||||
test('returns duplicate on 409 with location_code and registered_at', () async {
|
||||
test('returns off-shelf success on 200 with previous_location', () async {
|
||||
final mockClient = _MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'error_code': 'DUPLICATE_LOCATION',
|
||||
'message': '该总排号已存在货位记录',
|
||||
'location_code': 'A01-02-03',
|
||||
'registered_at': '2026-05-11T10:00:00',
|
||||
'zongpai_no': '26B1',
|
||||
'location_code': 'TRANS-01',
|
||||
'previous_location': 'A01-02-03',
|
||||
'created_at': '2026-05-13T10:00:00',
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
final svc = ApiService(client: mockClient);
|
||||
final result = await svc.registerLocation(
|
||||
baseUrl: 'http://localhost',
|
||||
zongpaiNo: '26B1',
|
||||
locationCode: 'TRANS-01',
|
||||
);
|
||||
expect(result.success, isTrue);
|
||||
expect(result.isOffShelfSuccess, isTrue);
|
||||
expect(result.isDuplicate, isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'returns duplicate on 409 with location_code and registered_at',
|
||||
() async {
|
||||
final mockClient = _MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'error_code': 'DUPLICATE_LOCATION',
|
||||
'message': '该总排号已存在货位记录',
|
||||
'location_code': 'A01-02-03',
|
||||
'registered_at': '2026-05-11T10:00:00',
|
||||
}),
|
||||
409,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
final svc = ApiService(client: mockClient);
|
||||
final result = await svc.registerLocation(
|
||||
baseUrl: 'http://localhost',
|
||||
zongpaiNo: '26B1',
|
||||
locationCode: 'A02-01-01',
|
||||
);
|
||||
expect(result.success, isFalse);
|
||||
expect(result.isDuplicate, isTrue);
|
||||
expect(result.duplicateInfo?['location_code'], 'A01-02-03');
|
||||
expect(result.duplicateInfo?['registered_at'], '2026-05-11T10:00:00');
|
||||
},
|
||||
);
|
||||
|
||||
test('returns already off shelf on 409 ALREADY_OFF_SHELF', () async {
|
||||
final mockClient = _MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'error_code': 'ALREADY_OFF_SHELF',
|
||||
'message': '该总排号已下架至转运区域,不可重新上架',
|
||||
'location_code': 'TRANS-01',
|
||||
'registered_at': '2026-05-13T10:00:00',
|
||||
}),
|
||||
409,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
@@ -43,15 +100,19 @@ void main() {
|
||||
locationCode: 'A02-01-01',
|
||||
);
|
||||
expect(result.success, isFalse);
|
||||
expect(result.isDuplicate, isTrue);
|
||||
expect(result.duplicateInfo?['location_code'], 'A01-02-03');
|
||||
expect(result.duplicateInfo?['registered_at'], '2026-05-11T10:00:00');
|
||||
expect(result.isDuplicate, isFalse);
|
||||
expect(result.isAlreadyOffShelf, isTrue);
|
||||
expect(result.errorMessage, '该总排号已下架至转运区域,不可重新上架');
|
||||
expect(result.conflictInfo?['location_code'], 'TRANS-01');
|
||||
});
|
||||
|
||||
test('returns error on 400 with message', () async {
|
||||
final mockClient = _MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({'error_code': 'INVALID_ZONGPAI', 'message': 'INVALID_ZONGPAI'}),
|
||||
jsonEncode({
|
||||
'error_code': 'INVALID_ZONGPAI',
|
||||
'message': 'INVALID_ZONGPAI',
|
||||
}),
|
||||
400,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
@@ -81,6 +142,236 @@ void main() {
|
||||
expect(result.errorMessage, '网络异常,请检查网络连接');
|
||||
});
|
||||
});
|
||||
|
||||
group('ApiService boxing APIs', () {
|
||||
test('fetchPaichaOverview parses overview items', () async {
|
||||
final mockClient = _MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'paicha_no': 'R00001',
|
||||
'total_count': 2,
|
||||
'items': [
|
||||
{
|
||||
'zongpai_no': '26B1',
|
||||
'work_order_no': '6-1(7)',
|
||||
'quantity': 80,
|
||||
'location_code': 'A01-02-03',
|
||||
'status': 'on_shelf',
|
||||
},
|
||||
{
|
||||
'zongpai_no': '26B2',
|
||||
'work_order_no': '6-2(3)',
|
||||
'quantity': 45,
|
||||
'location_code': null,
|
||||
'status': 'not_shelved',
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
|
||||
final svc = ApiService(client: mockClient);
|
||||
final result = await svc.fetchPaichaOverview(
|
||||
baseUrl: 'http://localhost',
|
||||
zongpaiNo: '26B1',
|
||||
);
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(result.paichaNo, 'R00001');
|
||||
expect(result.totalCount, 2);
|
||||
expect(result.items.first.status, 'on_shelf');
|
||||
expect(result.items.last.locationCode, isNull);
|
||||
});
|
||||
|
||||
test('fetchPaichaOverview maps 404 to not found result', () async {
|
||||
final mockClient = _MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({'error_code': 'PAICHA_NOT_FOUND', 'message': '暂无排产信息'}),
|
||||
404,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
|
||||
final svc = ApiService(client: mockClient);
|
||||
final result = await svc.fetchPaichaOverview(
|
||||
baseUrl: 'http://localhost',
|
||||
zongpaiNo: '26B404',
|
||||
);
|
||||
|
||||
expect(result.success, isFalse);
|
||||
expect(result.notFound, isTrue);
|
||||
expect(result.errorMessage, '暂无排产信息');
|
||||
});
|
||||
|
||||
test(
|
||||
'fetchPaichaOverview maps network failure to retryable error',
|
||||
() async {
|
||||
final mockClient = _MockClient((request) async {
|
||||
throw Exception('Connection refused');
|
||||
});
|
||||
|
||||
final svc = ApiService(client: mockClient);
|
||||
final result = await svc.fetchPaichaOverview(
|
||||
baseUrl: 'http://localhost',
|
||||
zongpaiNo: '26B1',
|
||||
);
|
||||
|
||||
expect(result.success, isFalse);
|
||||
expect(result.notFound, isFalse);
|
||||
expect(result.errorMessage, '加载失败,点击重试');
|
||||
},
|
||||
);
|
||||
|
||||
test('fetchBoxInfo parses current boxes and box item ids', () async {
|
||||
final mockClient = _MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'zongpai_no': '26BW0011',
|
||||
'paichan_no': 'W00009',
|
||||
'work_order_no': '6-1(7)',
|
||||
'quantity': 80,
|
||||
'current_zongpai_boxes': [
|
||||
{'box_item_id': 101, 'box_no': 3, 'quantity': 30},
|
||||
],
|
||||
'existing_boxes': [
|
||||
{
|
||||
'box_no': 3,
|
||||
'items': [
|
||||
{
|
||||
'box_item_id': 101,
|
||||
'zongpai_no': '26BW0011',
|
||||
'work_order_no': '6-1(7)',
|
||||
'quantity': 30,
|
||||
'total_quantity': 80,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
'max_box_no': 3,
|
||||
'suggested_box_no': 4,
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
|
||||
final svc = ApiService(client: mockClient);
|
||||
final result = await svc.fetchBoxInfo(
|
||||
baseUrl: 'http://localhost',
|
||||
zongpaiNo: '26BW0011',
|
||||
);
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(result.currentZongpaiBoxes.single.boxItemId, 101);
|
||||
expect(result.existingBoxes.single.items.single.boxItemId, 101);
|
||||
expect(result.existingBoxes.single.items.single.totalQuantity, 80);
|
||||
expect(result.suggestedBoxNo, 4);
|
||||
});
|
||||
|
||||
test('saveBoxRecord sends simplified request body', () async {
|
||||
late Map<String, dynamic> body;
|
||||
final mockClient = _MockClient((request) async {
|
||||
final req = request as http.Request;
|
||||
body = jsonDecode(req.body) as Map<String, dynamic>;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'box_item_id': 102,
|
||||
'paichan_no': 'W00009',
|
||||
'box_no': 4,
|
||||
'zongpai_no': '26BW0012',
|
||||
'quantity': 34,
|
||||
'created_at': '2026-05-13T10:00:00',
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
|
||||
final svc = ApiService(client: mockClient);
|
||||
final result = await svc.saveBoxRecord(
|
||||
baseUrl: 'http://localhost',
|
||||
zongpaiNo: '26BW0012',
|
||||
boxNo: 4,
|
||||
quantity: 34,
|
||||
);
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(result.boxItemId, 102);
|
||||
expect(result.zongpaiNo, '26BW0012');
|
||||
expect(body, {'zongpai_no': '26BW0012', 'box_no': 4, 'quantity': 34});
|
||||
expect(body.containsKey('box_mode'), isFalse);
|
||||
});
|
||||
|
||||
test('updateBoxRecord sends simplified request body', () async {
|
||||
late Map<String, dynamic> body;
|
||||
final mockClient = _MockClient((request) async {
|
||||
final req = request as http.Request;
|
||||
body = jsonDecode(req.body) as Map<String, dynamic>;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'box_item_id': 102,
|
||||
'paichan_no': 'W00009',
|
||||
'box_no': 4,
|
||||
'zongpai_no': '26BW0012',
|
||||
'quantity': 20,
|
||||
'updated_at': '2026-05-13T10:00:00',
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
|
||||
final svc = ApiService(client: mockClient);
|
||||
final result = await svc.updateBoxRecord(
|
||||
baseUrl: 'http://localhost',
|
||||
boxItemId: 102,
|
||||
boxNo: 4,
|
||||
quantity: 20,
|
||||
);
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(body, {'box_no': 4, 'quantity': 20});
|
||||
expect(body.containsKey('box_mode'), isFalse);
|
||||
});
|
||||
|
||||
test('deleteBoxRecord handles success and not found', () async {
|
||||
var calls = 0;
|
||||
final mockClient = _MockClient((request) async {
|
||||
calls += 1;
|
||||
if (calls == 1) {
|
||||
return http.Response(
|
||||
jsonEncode({'box_item_id': 102, 'deleted': true}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'error_code': 'BOX_ITEM_NOT_FOUND',
|
||||
'message': '指定装箱明细不存在',
|
||||
}),
|
||||
404,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
|
||||
final svc = ApiService(client: mockClient);
|
||||
final ok = await svc.deleteBoxRecord(
|
||||
baseUrl: 'http://localhost',
|
||||
boxItemId: 102,
|
||||
);
|
||||
final missing = await svc.deleteBoxRecord(
|
||||
baseUrl: 'http://localhost',
|
||||
boxItemId: 999,
|
||||
);
|
||||
|
||||
expect(ok.success, isTrue);
|
||||
expect(missing.success, isFalse);
|
||||
expect(missing.errorMessage, '指定装箱明细不存在');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _MockClient extends http.BaseClient {
|
||||
|
||||
60
test/services/boxing_context_test.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pad_scanner/services/boxing_context.dart';
|
||||
|
||||
void main() {
|
||||
group('decideMultiCodePaichanContext', () {
|
||||
test('first scan records context and fills recommended box if empty', () {
|
||||
final decision = decideMultiCodePaichanContext(
|
||||
lastPaichanNo: null,
|
||||
currentPaichanNo: 'W00009',
|
||||
currentBoxNoText: '',
|
||||
maxBoxNo: 3,
|
||||
);
|
||||
|
||||
expect(decision.change, PaichanContextChange.firstScan);
|
||||
expect(decision.boxNoToApply, 4);
|
||||
expect(decision.isSwitched, isFalse);
|
||||
});
|
||||
|
||||
test('same paichan keeps current box number', () {
|
||||
final decision = decideMultiCodePaichanContext(
|
||||
lastPaichanNo: 'W00009',
|
||||
currentPaichanNo: 'W00009',
|
||||
currentBoxNoText: '8',
|
||||
maxBoxNo: 3,
|
||||
);
|
||||
|
||||
expect(decision.change, PaichanContextChange.samePaichan);
|
||||
expect(decision.boxNoToApply, isNull);
|
||||
expect(decision.isSwitched, isFalse);
|
||||
});
|
||||
|
||||
test('switched paichan resets to new paichan max box plus one', () {
|
||||
final decision = decideMultiCodePaichanContext(
|
||||
lastPaichanNo: 'W00009',
|
||||
currentPaichanNo: 'W00010',
|
||||
currentBoxNoText: '8',
|
||||
maxBoxNo: 1,
|
||||
);
|
||||
|
||||
expect(decision.change, PaichanContextChange.switchedPaichan);
|
||||
expect(decision.boxNoToApply, 2);
|
||||
expect(decision.isSwitched, isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'same paichan defensively fills recommended box when box is empty',
|
||||
() {
|
||||
final decision = decideMultiCodePaichanContext(
|
||||
lastPaichanNo: 'W00009',
|
||||
currentPaichanNo: 'W00009',
|
||||
currentBoxNoText: '',
|
||||
maxBoxNo: 4,
|
||||
);
|
||||
|
||||
expect(decision.change, PaichanContextChange.samePaichan);
|
||||
expect(decision.boxNoToApply, 5);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
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,
|
||||
isMultiCode: false,
|
||||
zongpaiCount: 1,
|
||||
),
|
||||
'确 认 上 架',
|
||||
);
|
||||
expect(
|
||||
registrationSubmitLabel(
|
||||
isTransitTarget: false,
|
||||
isMultiCode: true,
|
||||
zongpaiCount: 3,
|
||||
),
|
||||
'批量上架(3 条)',
|
||||
);
|
||||
expect(
|
||||
registrationSubmitLabel(
|
||||
isTransitTarget: true,
|
||||
isMultiCode: false,
|
||||
zongpaiCount: 1,
|
||||
),
|
||||
'转运并装箱',
|
||||
);
|
||||
expect(
|
||||
registrationSubmitLabel(
|
||||
isTransitTarget: true,
|
||||
isMultiCode: 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);
|
||||
});
|
||||
});
|
||||
}
|
||||