Compare commits

..

50 Commits

Author SHA1 Message Date
Misaka
9dc714dbeb feat: extend boxing page for accessory boxing support
Add pending accessories section to both single-code and multi-code boxing
modes. Accessories display with orange styling and a "加入本箱" button that
calls saveBoxRecord with item_type=accessory. Extend API service with
PendingAccessory model, itemType fields on data classes, and optional
accessory_id parameter on saveBoxRecord. Show accessories with visual
indicator (wrench icon + orange background) in boxing detail page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 18:01:01 +08:00
Misaka
1416cff52c feat: add accessory module card to home page
Add 附件登记 (accessory registration) card to the home page module
registry with developing status, keyboard shortcut 3, and navigation
to AccessoryPage. Refactor _moduleFromShortcut to use dynamic index
lookup for better maintainability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 17:53:25 +08:00
Misaka
12cab16888 feat: add accessory registration page
Create accessory_page.dart with step-by-step form for registering
product accessories: paichan_no query, work order selection, zongpai_no
auto-fill, accessory type selector (preset + custom), quantity input,
location code (scanner + manual), and submit. Includes registered
accessories list with edit/delete actions and boxed-item indicators.
Integrates with ScannerService, FeedbackService, and StatusBar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 17:51:03 +08:00
Misaka
9aa5d86fde feat: add accessory API methods and data classes to ApiService
Add accessory-related data classes (WorkOrderGroup, AccessoryTypeInfo,
AccessoryRecord), result classes, and 8 API methods for the accessory
module. Also add pendingAccessories field to BoxInfoResult.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 17:46:14 +08:00
Misaka_Company
f6bfd8cb10 style: add curly braces to if/else statements in settings_page
Fix curly_braces_in_flow_control_structures lint warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-22 09:58:42 +08:00
Misaka_Company
9db50af1e6 chore: document Flutter pre-commit checks 2026-05-22 09:56:03 +08:00
Misaka
4e8be6a684 feat: allow on_shelf items in transit batch transfer
Transit shelf registration is effectively an off-shelf operation. Allow
not_shelved, on_shelf, and their mix when target is a transit shelf.
Only transferred items are blocked since they are already in transit.

Also fix conflict color logic: transferred items now correctly show as
red (conflict) for both transit and normal shelf targets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 21:39:44 +08:00
Misaka
5fc5c7de2b fix: skip shelf-switch warning when no previous shelf scanned in multi-code mode
In multi-code shelving mode, if the user has scanned zongpai numbers
but hasn't scanned any shelf yet, scanning the first shelf should not
trigger the "switch shelf" warning dialog since there is no existing
shelf to switch from.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 21:02:22 +08:00
Misaka
4891fe4dfd refactor: split registration page into focused parts 2026-05-21 20:30:01 +08:00
Misaka
305083bf3f test: update registration linking mode parameter 2026-05-21 19:50:40 +08:00
Misaka_Company
a601858225 refactor: unify registration mode concept to single-code/multi-code shelving
Replace the locked/unlocked terminology with RegistrationMode enum
(singleCode/multiCode) to align with boxing module's naming convention.
Multi-code mode now allows switching shelf locations with a confirmation
dialog when pending data exists. Use shared BoxingModePill widget for
consistent UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 17:30:23 +08:00
Misaka_Company
9ab7a51dfb feat: add pre-check to block batch operations when scanned items
already have location bindings (on_shelf or transferred)

- Replace single on_shelf pre-check with unified conflict detection
  for both normal shelf and transit targets
- Show conflict dialog listing duplicate and/or transferred items
  with details before any API calls are made
- Prevents partial batch failures where some items succeed before
  a conflicting item is encountered

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 16:57:22 +08:00
Misaka_Company
950effc164 refactor: redesign single-code boxing info bar layout
- Remove progress bar from paichanNo info section
- Increase paichanNo font size to 22, decrease zongpaiNo to 15
- Swap paichanNo and zongpaiNo row positions (paichanNo on top)
- Split boxing detail info into two lines (box count, max box number)
- Reduce zongpaiNo row height from 50 to 35

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 16:24:50 +08:00
Misaka_Company
e87d29e696 refactor: split boxing page into part files for scan, submit, ops and status
Extract large methods from _BoxingPageState into 6 part files:
- boxing_scan_part.dart: scan handling and auto-scan processing
- boxing_submit_part.dart: submit/update/delete API calls
- boxing_box_mutation_part.dart: box item mutations
- boxing_single_ops_part.dart: single-code boxing operations
- boxing_multi_ops_part.dart: multi-code boxing operations
- boxing_status_part.dart: status bar management

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 10:44:22 +08:00
Misaka_Company
b9f17d5d49 refactor: extract API actions into BoxingApiActions with error handling
Centralize all API call logic (fetchBoxInfo, saveBoxRecord,
updateBoxRecord, deleteBoxRecord) into BoxingApiActions class with
unified error categorization (network, duplicate, missingApiUrl).
Add helper methods to reduce repetitive error handling in boxing_page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 09:54:16 +08:00
Misaka_Company
9eb1bde645 docs: add Flutter test proxy workaround to CLAUDE.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 09:06:25 +08:00
Misaka_Company
c2d9f5fd56 refactor: extract boxing logic into dedicated modules with tests
- boxing_models.dart: add BoxingMode, BoxingPhase, BoxingPageArguments
- boxing_box_mutations.dart: box item add/replace/remove operations
- boxing_calculations.dart: pure calculation helpers (quantities, validation)
- boxing_status_presenter.dart: status bar text/dot determination
- dialogs/cross_paichan_dialog.dart: cross-paichan confirmation dialog
- widgets/boxing_notice_banners.dart: notice banner widgets
- Add unit tests for mutations, calculations, and status presenter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 09:02:56 +08:00
Misaka_Company
f2b2f41929 refactor: extract boxing page into modular components
Split the monolithic boxing_page.dart into separate files:
- boxing_models.dart: data models (_ManyToOnePackedItem, _Phase)
- widgets/boxing_shared_widgets.dart: shared UI components
- widgets/single_code_body.dart: single-code boxing UI
- widgets/multi_code_body.dart: multi-code boxing UI
- widgets/multi_packed_row.dart: multi-code packed row widget
- widgets/single_assigned_row.dart: single-code assigned row widget

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 08:35:45 +08:00
Misaka_Company
0ae4017eee fix: unify input field widths and fix button spacing in boxing page
- Unify all input fields (box number and quantity) to width 52 in both
  single-code and multi-code boxing modes
- Flatten nested Row layout in multi-code submit row to ensure equal
  8px spacing between quantity input, confirm button, and complete-box
  button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 18:58:50 +08:00
Misaka_Company
b17e0e7e62 feat: add cross-paichan warning dialog to multi-code boxing & fix box number calculation
Multi-code boxing mode now shows a confirmation dialog when scanning a
code from a different paichan, matching the registration page pattern.
Scans are blocked while the dialog is visible.

Also removes the redundant _lastBoxNo variable. Box numbers now always
use _maxBoxNo + 1 from the backend, fixing a bug where switching to a
new paichan incorrectly carried over the previous paichan's box number.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 18:15:42 +08:00
Misaka_Company
3fd47f4d7b style: improve list layout — larger fonts, adjusted column widths, center alignment
Boxing number module:
- Data row base font size 12→15, quantity column / edit mode unified to 15
- Work order column width flex 24→30, quantity column flex 14→23, action column 64px→54px
- Multi-code boxing total row column width flex 22→26
- Center-align all table headers and data rows

Shelf registration module:
- Data row base font size 12→15
- Center-align all table headers and data rows (location number changed from right-align to center)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 16:26:41 +08:00
Misaka_Company
eb833c830d fix: increment new box number from max box number after completing current box
Unify optimization for single-code and multi-code boxing modes: after
completing a box, set the next box number to max(existing boxes) + 1,
avoiding duplicate box numbers when browsing historical boxes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 14:46:41 +08:00
Misaka_Company
b1a76287d6 feat: support box-number navigation for completed multi-code boxing & unify key handling
- In multi-code boxing mode, scanning a completed total-row number shows a
  clickable warning banner; clicking it or pressing P4 navigates to that
  box number to view boxing details.
- Unify key handling across all pages to LogicalKeyboardKey named constants,
  removing the _androidKeyCodeFromLogicalKey fallback path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 14:21:38 +08:00
Misaka_Company
f587194267 refactor: replace boxing list with table layout matching registration design
Change single-code and multi-code boxing lists from card-style layout
(number + color bar + two-line content) to table-style layout
(column headers + flex columns + left border color bar),
matching the registration module design language.

Fields: Box No. / Total Row No. | Work Order | Quantity | Action Button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 12:40:51 +08:00
Misaka_Company
a6ee6a718b improve single-code boxing UX: preserve state, fix work order display, add double-vibrate alert
- Keep UI state after boxing completion for operator review instead of auto-reset
- Show real work order number instead of hardcoded "本次扫码" in assigned list
- Add double-vibration feedback when re-scanning already completed zongpai
- Change completed status banner and dot from green to red for clearer warning

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-19 17:05:26 +08:00
Misaka_Company
7a95f49acc add registration UI redesign preview and state documentation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-19 15:58:46 +08:00
Misaka_Company
8c967a0f99 redesign registration page: compact header layout, cross-paicha dialog, and locked-mode enhancements
- Restructure UI with two-column paicha header (left: paicha number, right: location + stats)
- Add delete button on scanned rows in locked mode (matching boxing module style)
- Add cross-paicha scan warning with double vibration and vertical button dialog
- Show red background for already-on-shelf items on normal shelf (both locked/unlocked)
- Pre-check batch submit: block entire batch if any item is already on shelf
- Support DPAD up/down navigation in cross-paicha dialog, default to "否"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-19 15:57:39 +08:00
Misaka_Company
d9f8e066b3 refine multi-code boxing: inline finish button with P3 shortcut
- Move 完成本箱 button to same row as quantity input, each taking half width
- Add P3 (BUTTON_L2, key code 104) hardware key support for finishing box
- Display P3 shortcut label on the finish button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-18 17:27:12 +08:00
Misaka_Company
f60745607f refine boxing UI: restructure info bar, scan bar, and mode switch
- Info bar: left-right layout with progress bar below, detail button on right
- Scan bar: remove '已识别' badge, show 工令号/总件数/已装X with larger font
- Mode switch: move to title right side, adopt rectangular style with 18px bold font

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-18 17:00:40 +08:00
Misaka_Company
9e4667d68f chore: ignore .sisyphus/ directory in gitignore
Add .sisyphus/ to .gitignore alongside .agents/ to exclude
Sisyphus plan files from version control.
2026-05-18 15:24:53 +08:00
Misaka_Company
9b9becb008 fix: restore hardware key focus after lock/unlock in boxing screen
Add WidgetsBindingObserver lifecycle hook to re-request focus
when app resumes, fixing P2 key not working after screen lock.
2026-05-18 15:23:56 +08:00
Misaka_Company
a7570891c5 refine multi-code detail entry 2026-05-18 15:06:56 +08:00
Misaka_Company
df3569ba22 docs update single-code boxing UI 2026-05-18 14:59:53 +08:00
Misaka_Company
1dc9e3d3a1 refactor single-code boxing UI 2026-05-18 14:54:07 +08:00
Misaka_Company
f964c76382 feat: refactor multi-code boxing UI with compact v2 layout
Merge box number and paichan info into a single header bar,
compress list rows to 40px with left color bars, fix bottom
operation area (scan/submit/finish) above status bar, and
shrink notification bars and status bar for more list space.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-18 13:58:50 +08:00
Misaka_Company
3710e5955d docs: add branch strategy to CLAUDE.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-18 12:35:48 +08:00
Misaka_Company
073162dc65 feat: add hardware key shortcuts and unify lock toggle design
- Home: add keyboard number shortcuts (1/2/3) for module navigation
- Boxing: add P2 key mode switching with pill-style AppBar header
- Registration: replace Switch with pill-style lock header matching boxing design
- Add Android keyCode fallback for scanner device compatibility
- Simplify home page cards to show only module name + shortcut hint
- Add lock redesign HTML preview document

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-18 11:26:02 +08:00
Misaka_Company
2b5fdf41a9 chore: update Android app branding 2026-05-15 15:46:06 +08:00
Misaka_Company
deb038404a feat: link transit registration to boxing 2026-05-15 12:40:10 +08:00
Misaka_Company
b2f2c536cb feat(registration): merge shelf overview into registration 2026-05-15 08:45:22 +08:00
Misaka_Company
ce80e1a1f6 feat(boxing): support editing assigned box records 2026-05-15 08:19:34 +08:00
Misaka_Company
a165bfeabe feat(boxing): handle multi-code paichan switches 2026-05-14 14:04:25 +08:00
Misaka_Company
28871eb21f feat(boxing): support v2.5 dual modes 2026-05-14 12:29:33 +08:00
Misaka_Company
077a1ab1b9 fix(settings): save all config atomically and strip trailing slash from API URL
- Save API URL and sound paths in a single load-modify-write cycle to
  prevent race conditions that wiped previously saved config
- Strip trailing slashes from API URL to avoid double-slash 404 errors
- Make AppConfigService.saveConfig public for batch config writes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-13 16:58:39 +08:00
Misaka_Company
42a9cc7204 feat(boxing): update detail page quantity display and paichan_no style
- Change quantity display from "数量:N" to "N/total" format (boxed/total)
- Enlarge paichan_no font to 18px bold and right-align

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-13 15:51:41 +08:00
Misaka_Company
396a19ff43 feat(boxing): update many-to-one packing flow 2026-05-13 15:14:14 +08:00
Misaka_Company
b32c9c5aad feat: handle off-shelf registration results 2026-05-13 12:43:22 +08:00
Misaka_Company
b975b15ba7 feat: improve one-to-many boxing workflow 2026-05-13 11:22:49 +08:00
Misaka_Company
e4a8816b98 chore: ignore agent workspace 2026-05-13 09:52:23 +08:00
Misaka_Company
c2fc884b66 feat(boxing): show work order numbers in boxing views 2026-05-13 09:48:08 +08:00
72 changed files with 17396 additions and 1528 deletions

4
.gitignore vendored
View File

@@ -47,4 +47,6 @@ app.*.map.json
/android/build/
# AI Agents
.claude/
.claude/
.agents/
.sisyphus/

View File

@@ -1,5 +1,33 @@
# pad_scanner CLAUDE.md
## Branch Strategy
- The main branch is `master`, always kept in a releasable state
- All new feature development must be done on a `dev` branch, created from `master`
- After verification, merge `dev` back into `master`
## Pre-Commit Checks
Before committing Flutter app changes, run these checks:
```bash
dart format --set-exit-if-changed .
flutter analyze
flutter test
```
If `flutter test` fails with a localhost WebSocket/proxy error, follow the proxy cleanup steps in the Flutter Test Rules section below and run it again.
### Handling Check Failures
- If a check fails because of the current change, fix the issue before committing.
- If `flutter analyze` reports any issue in files changed by the current task, fix it before committing.
- If `flutter analyze` reports only unrelated pre-existing issues, do not fix them in the current commit. Report the file, line, and lint/error name, then handle them in a separate cleanup commit or task.
- If a check fails because of unrelated pre-existing issues, do not include unrelated fixes in the same commit. Report the failing command and the existing issues, then handle them in a separate cleanup commit or task.
- If formatting fails, format only files changed by the current task. Do not run a broad formatting cleanup unless that is the explicit task.
- Treat `flutter test` failures as blocking unless the failure is clearly caused by the proxy issue described below and passes after rerunning with proxy variables cleared.
- When committing or reporting completion, mention which checks were run and whether any remaining failures are unrelated pre-existing issues.
## App Installation Rules
**Always use `adb install -r` to install the app. Never use `flutter install`.**
@@ -15,3 +43,16 @@ flutter build apk --release
# 2. Install (in-place upgrade, preserves config)
adb install -r build/app/outputs/flutter-apk/app-release.apk
```
## Flutter Test Rules
Before running `flutter test`, temporarily remove proxy environment variables for the current shell. The Flutter tester uses a localhost WebSocket, and proxy settings can break that connection with `Invalid WebSocket upgrade request`.
### PowerShell
```powershell
$env:HTTP_PROXY=''
$env:HTTPS_PROXY=''
$env:NO_PROXY='localhost,127.0.0.1,::1'
flutter test
```

View File

@@ -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"/>

View File

@@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#00286F</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">库房追踪</string>
</resources>

View File

@@ -0,0 +1,443 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>锁定货位 - 布局重设计预览</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, 'Segoe UI', 'Noto Sans SC', sans-serif; background: #f0f2f5; padding: 20px; }
h1 { text-align: center; margin-bottom: 24px; color: #333; font-size: 22px; }
h2 { color: #555; margin-bottom: 12px; font-size: 16px; }
.wrapper {
max-width: 1200px; margin: 0 auto;
display: grid; grid-template-columns: 1fr 1fr; gap: 32px;
}
.column { display: flex; flex-direction: column; gap: 24px; }
.phone {
width: 360px; margin: 0 auto;
border: 2px solid #333; border-radius: 24px; overflow: hidden;
background: #fff; box-shadow: 0 4px 20px rgba(0,0,0,.15);
}
/* ── AppBar ── */
.appbar {
height: 56px; background: #1565C0; color: #fff;
display: flex; align-items: center; padding: 0 12px;
font-size: 18px; font-weight: 600;
}
.appbar .back { font-size: 22px; margin-right: 12px; cursor: pointer; }
/* ── OLD: lock switch in actions ── */
.appbar-old .title { flex: 0 0 auto; }
.appbar-old .spacer { flex: 1; }
.appbar-old .lock-area {
display: flex; align-items: center; gap: 6px; font-size: 13px;
}
.p2-hint {
padding: 2px 6px; border-radius: 4px;
font-size: 11px; font-weight: 700;
}
.p2-hint.off { background: #e0e0e0; color: #757575; border: 1px solid #bdbdbd; }
.p2-hint.on { background: #fff3e0; color: #e65100; border: 1px solid #ffb74d; }
.switch {
width: 36px; height: 20px; border-radius: 10px;
position: relative; transition: .2s;
}
.switch.off { background: #bdbdbd; }
.switch.on { background: #ff9800; }
.switch::after {
content: ''; position: absolute; top: 2px;
width: 16px; height: 16px; border-radius: 50%;
background: #fff; transition: .2s;
}
.switch.off::after { left: 2px; }
.switch.on::after { left: 18px; }
/* ── NEW: mode header pill (same as boxing page) ── */
.appbar-new .title-text { flex: 0 0 auto; }
.appbar-new .gap { width: 12px; }
.mode-pill {
flex: 1; min-width: 0;
height: 36px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
gap: 6px; cursor: pointer; transition: background .2s;
user-select: none;
}
.mode-pill.blue { background: #1565C0; } /* same blue as boxing single-code */
.mode-pill.orange { background: #E65100; } /* same orange as boxing multi-code */
.mode-pill svg { width: 18px; height: 18px; fill: #fff; flex-shrink: 0; }
.mode-pill .label {
color: #fff; font-size: 18px; font-weight: 800;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.mode-pill .shortcut {
color: rgba(255,255,255,.85); font-size: 12px; font-weight: 700;
flex-shrink: 0;
}
/* ── Body ── */
.body { padding: 10px 12px 8px; }
.section-label { font-size: 14px; font-weight: 700; margin-bottom: 6px; color: #333; }
/* ── Input panels ── */
.panel {
border: 1px solid #bdbdbd; border-radius: 8px; padding: 10px;
min-height: 58px; transition: .2s;
}
.panel.active { border-color: #4caf50; border-width: 2px; }
.panel.locked {
border-color: #fb8c00; border-width: 2px;
background: linear-gradient(135deg, #fff8e1 0%, #fff 40%);
}
.panel-row { display: flex; align-items: center; }
.panel-row .loc { font-size: 18px; font-weight: 700; color: #424242; }
.panel-row .sep { color: #bdbdbd; font-size: 20px; padding: 0 8px; }
.panel-row .zp { flex: 3; text-align: right; font-size: 18px; font-weight: 700; color: #424242; }
.loc-chip {
display: inline-block; padding: 2px 6px; border-radius: 4px;
font-size: 11px; font-weight: 700; margin-left: 6px;
}
.loc-chip.blue { background: #e3f2fd; color: #1565C0; }
.lock-row { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
.lock-row .loc { flex: 1; font-size: 18px; font-weight: 700; color: #424242; }
.lock-icon { color: #fb8c00; font-size: 18px; }
.chip-wrap { display: flex; flex-wrap: wrap; gap: 6px; }
.zchip {
display: inline-flex; align-items: center; gap: 4px;
background: #f5f5f5; border: 1px solid #e0e0e0; border-radius: 16px;
padding: 2px 6px 2px 10px; font-size: 13px; font-weight: 600;
}
.zchip .x { color: #9e9e9e; cursor: pointer; font-size: 16px; line-height: 1; }
.submit-btn {
width: 100%; height: 46px; border: none; border-radius: 8px;
font-size: 17px; font-weight: 600; color: #fff; margin-top: 10px;
cursor: pointer;
}
.submit-btn.off { background: #e0e0e0; color: #757575; }
.submit-btn.on { background: #1565C0; }
.divider { height: 1px; background: #e0e0e0; margin: 0; }
.overview { padding: 8px 12px; }
.overview-label { font-size: 14px; font-weight: 700; margin-bottom: 6px; }
.overview-placeholder {
display: flex; align-items: center; justify-content: center;
color: #9e9e9e; font-size: 13px; min-height: 200px;
}
.status-bar {
padding: 10px 16px; font-size: 13px; background: #f5f5f5;
display: flex; align-items: center; gap: 8px;
}
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #2196F3; }
/* ── Labels & captions ── */
.col-label {
text-align: center; padding: 8px 16px; border-radius: 8px;
font-weight: 700; font-size: 15px; margin-bottom: 4px;
}
.col-label.old { background: #ffcdd2; color: #c62828; }
.col-label.new { background: #c8e6c9; color: #2e7d32; }
.caption {
text-align: center; color: #888; font-size: 12px;
margin-top: 8px; line-height: 1.6;
}
/* ── Reference boxing pill ── */
.ref-section {
max-width: 760px; margin: 40px auto 0;
padding: 20px; background: #fff; border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,.08);
}
.ref-section h2 { font-size: 18px; color: #333; margin-bottom: 16px; }
.ref-row {
display: flex; align-items: center; gap: 16px; margin-bottom: 12px;
}
.ref-label { font-size: 13px; color: #888; width: 80px; flex-shrink: 0; text-align: right; }
.ref-pill {
height: 36px; border-radius: 8px;
display: inline-flex; align-items: center; justify-content: center;
gap: 6px; padding: 0 12px;
}
.ref-pill svg { width: 18px; height: 18px; fill: #fff; }
.ref-pill .lbl { color: #fff; font-size: 18px; font-weight: 800; }
.ref-pill .p2 { color: rgba(255,255,255,.85); font-size: 12px; font-weight: 700; }
.compare-table {
width: 100%; border-collapse: collapse; font-size: 14px;
margin-top: 16px;
}
.compare-table th {
text-align: left; padding: 8px 12px; background: #f5f5f5;
border-bottom: 2px solid #ddd;
}
.compare-table td {
padding: 8px 12px; border-bottom: 1px solid #eee;
}
</style>
</head>
<body>
<h1>锁定货位 — 布局重设计预览</h1>
<div class="wrapper">
<!-- ═══════════ LEFT: OLD ═══════════ -->
<div class="column">
<div class="col-label old">现有设计</div>
<div>
<h2 style="text-align:center">未锁定</h2>
<div class="phone">
<div class="appbar appbar-old">
<span class="back">&larr;</span>
<span class="title">上架登记</span>
<span class="spacer"></span>
<div class="lock-area">
<span>锁定货位</span>
<span class="p2-hint off">P2</span>
<div class="switch off"></div>
</div>
</div>
<div class="body">
<div class="section-label">登记操作区</div>
<div class="panel active">
<div class="panel-row">
<span class="loc">A-01-02</span>
<span class="loc-chip blue">普通货架</span>
<span class="sep">|</span>
<span class="zp">ZP2024001</span>
</div>
</div>
<button class="submit-btn on">提 交</button>
</div>
<div class="divider"></div>
<div class="overview">
<div class="overview-label">排产号货架总览</div>
<div class="overview-placeholder">扫描总排号后自动显示排产号上架情况</div>
</div>
<div class="status-bar"><div class="status-dot"></div>请确认信息并提交</div>
</div>
<p class="caption">锁定开关挤在 AppBar 右侧,字小、触控区小</p>
</div>
<div>
<h2 style="text-align:center">已锁定</h2>
<div class="phone">
<div class="appbar appbar-old">
<span class="back">&larr;</span>
<span class="title">上架登记</span>
<span class="spacer"></span>
<div class="lock-area">
<span>锁定货位</span>
<span class="p2-hint on">P2</span>
<div class="switch on"></div>
</div>
</div>
<div class="body">
<div class="section-label">登记操作区</div>
<div class="panel locked">
<div class="lock-row">
<span class="loc">A-01-02</span>
<span class="lock-icon">&#128274;</span>
<span class="loc-chip blue">普通货架</span>
</div>
<div class="chip-wrap">
<span class="zchip">ZP2024001 <span class="x">&times;</span></span>
<span class="zchip">ZP2024002 <span class="x">&times;</span></span>
<span class="zchip">ZP2024003 <span class="x">&times;</span></span>
</div>
</div>
<button class="submit-btn on">批量提交3 条)</button>
</div>
<div class="divider"></div>
<div class="overview">
<div class="overview-label">排产号货架总览</div>
<div class="overview-placeholder">扫描总排号后自动显示排产号上架情况</div>
</div>
<div class="status-bar"><div class="status-dot"></div>货位已锁定,请扫描下一张执行卡</div>
</div>
<p class="caption">锁定状态仅靠边框颜色区分,不够醒目</p>
</div>
</div>
<!-- ═══════════ RIGHT: NEW ═══════════ -->
<div class="column">
<div class="col-label new">新设计(与装箱编号统一设计语言)</div>
<!-- NEW — unlocked -->
<div>
<h2 style="text-align:center">未锁定</h2>
<div class="phone">
<div class="appbar appbar-new">
<span class="back">&larr;</span>
<span class="title-text">上架登记</span>
<span class="gap"></span>
<!-- same pill as boxing page -->
<div class="mode-pill blue">
<svg viewBox="0 0 24 24"><path d="M6.99 11 3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></svg>
<span class="label">未锁定</span>
<span class="shortcut">P2</span>
</div>
</div>
<div class="body">
<div class="section-label">登记操作区</div>
<div class="panel active">
<div class="panel-row">
<span class="loc">A-01-02</span>
<span class="loc-chip blue">普通货架</span>
<span class="sep">|</span>
<span class="zp">ZP2024001</span>
</div>
</div>
<button class="submit-btn on">提 交</button>
</div>
<div class="divider"></div>
<div class="overview">
<div class="overview-label">排产号货架总览</div>
<div class="overview-placeholder">扫描总排号后自动显示排产号上架情况</div>
</div>
<div class="status-bar"><div class="status-dot"></div>请确认信息并提交</div>
</div>
<p class="caption">
蓝色药丸 = 未锁定(与装箱页"单码装箱"同色)<br>
点击或按 P2 切换为锁定
</p>
</div>
<!-- NEW — locked -->
<div>
<h2 style="text-align:center">已锁定</h2>
<div class="phone">
<div class="appbar appbar-new">
<span class="back">&larr;</span>
<span class="title-text">上架登记</span>
<span class="gap"></span>
<!-- same pill as boxing page — orange when locked -->
<div class="mode-pill orange">
<svg viewBox="0 0 24 24"><path d="M6.99 11 3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></svg>
<span class="label">货位锁定</span>
<span class="shortcut">P2</span>
</div>
</div>
<div class="body">
<div class="section-label">登记操作区</div>
<div class="panel locked">
<div class="lock-row">
<span class="loc">A-01-02</span>
<span class="lock-icon">&#128274;</span>
<span class="loc-chip blue">普通货架</span>
</div>
<div class="chip-wrap">
<span class="zchip">ZP2024001 <span class="x">&times;</span></span>
<span class="zchip">ZP2024002 <span class="x">&times;</span></span>
<span class="zchip">ZP2024003 <span class="x">&times;</span></span>
</div>
</div>
<button class="submit-btn on">批量提交3 条)</button>
</div>
<div class="divider"></div>
<div class="overview">
<div class="overview-label">排产号货架总览</div>
<div class="overview-placeholder">扫描总排号后自动显示排产号上架情况</div>
</div>
<div class="status-bar"><div class="status-dot"></div>货位已锁定,请扫描下一张执行卡</div>
</div>
<p class="caption">
橙色药丸 = 已锁定(与装箱页"多码凑箱"同色)<br>
输入面板下方内容不变,仅切换控件统一风格
</p>
</div>
</div>
</div>
<!-- ═══════════ Reference comparison ═══════════ -->
<div class="ref-section">
<h2>设计语言对照</h2>
<div class="ref-row">
<span class="ref-label">装箱编号</span>
<div class="ref-pill" style="background:#1565C0;">
<svg viewBox="0 0 24 24"><path d="M6.99 11 3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></svg>
<span class="lbl">单码装箱</span>
<span class="p2">P2</span>
</div>
<div class="ref-pill" style="background:#E65100;">
<svg viewBox="0 0 24 24"><path d="M6.99 11 3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></svg>
<span class="lbl">多码凑箱</span>
<span class="p2">P2</span>
</div>
</div>
<div class="ref-row">
<span class="ref-label">上架登记</span>
<div class="ref-pill" style="background:#1565C0;">
<svg viewBox="0 0 24 24"><path d="M6.99 11 3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></svg>
<span class="lbl">未锁定</span>
<span class="p2">P2</span>
</div>
<div class="ref-pill" style="background:#E65100;">
<svg viewBox="0 0 24 24"><path d="M6.99 11 3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></svg>
<span class="lbl">货位锁定</span>
<span class="p2">P2</span>
</div>
</div>
<table class="compare-table">
<thead>
<tr>
<th>属性</th>
<th>装箱编号</th>
<th>上架登记(新)</th>
</tr>
</thead>
<tbody>
<tr>
<td style="font-weight:600;">组件位置</td>
<td>AppBar 标题行内(标题 + 药丸)</td>
<td>AppBar 标题行内(标题 + 药丸)</td>
</tr>
<tr>
<td style="font-weight:600;">交互方式</td>
<td>点击药丸 / 按 P2 切换</td>
<td>点击药丸 / 按 P2 切换</td>
</tr>
<tr>
<td style="font-weight:600;">默认态(蓝色)</td>
<td>单码装箱</td>
<td>未锁定</td>
</tr>
<tr>
<td style="font-weight:600;">切换态(橙色)</td>
<td>多码凑箱</td>
<td>货位锁定</td>
</tr>
<tr>
<td style="font-weight:600;">药丸结构</td>
<td>swap 图标 + 模式名 + P2</td>
<td>swap 图标 + 状态名 + P2</td>
</tr>
<tr>
<td style="font-weight:600;">圆角 / 字重</td>
<td>8px / w800</td>
<td>8px / w800</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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">&#x21C4;</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">&#x26A0;</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>

View 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">&#x21C4;</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">&#x1F512;</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">&#x26A0;</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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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重构后布局
```
AppBar44px ← 固定顶部
[通知条 × 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不变

View File

@@ -0,0 +1,191 @@
# PRD · 多码凑箱页面布局优化
**模块**:装箱编号 > 多码凑箱模式BoxingMode.multiCode
**文件**`lib/pages/boxing_page.dart`(主要影响 `_buildManyToOneBody` 及相关子方法)
**版本**v2
**状态**:待评审
---
## 1. 背景与目标
多码凑箱页面在手持 PDA / 手机屏幕(宽度约 375390px上使用时原版布局存在以下问题
- **垂直空间浪费**AppBar、箱号区、排产信息区各自独占一行三块合计高度约 140px挤压了中间列表区的可视行数。
- **操作区随列表滚动**:扫码区和提交区位于列表下方,装入项目多时需要下拉才能看到操作控件,增加操作摩擦。
- **信息层次不清晰**:删除确认使用弹窗,打断了列表的整体感知;通知条与内容区边界不明确。
**优化目标**:在同一屏内展示更多已装入项目,同时保持操作控件始终可见,整体界面整洁、层次分明。
---
## 2. 改动总览
| 区域 | 原版高度(估算) | v2 高度(估算) | 节省 |
|------|--------------|--------------|------|
| AppBar | ~52px | 44px | 8px |
| 箱号区 + 排产信息区 | ~80px两行 | 48px合并一行 | 32px |
| 列表每行 | ~4852px | 40px | 812px/行 |
| 通知条(每条) | ~36px | 30px | 6px |
| 状态栏 | ~38px | 28px | 10px |
三项以上改动叠加后,典型场景下可多显示 **12 条**已装入记录,且操作底栏固定可见。
---
## 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` 高度约 4852px内含 `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`)的布局不做调整。
- 字体、主题色、品牌色系不做变更。
- 横屏/平板适配留作后续专项。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,154 @@
import 'package:pad_scanner/services/api_service.dart';
import 'package:pad_scanner/services/app_config_service.dart';
enum BoxingActionErrorKind { missingApiUrl, network, duplicate, backend }
class BoxingActionResult<T> {
final bool success;
final T? data;
final BoxingActionErrorKind? errorKind;
final String? errorMessage;
final int? boxNo;
const BoxingActionResult._({
required this.success,
this.data,
this.errorKind,
this.errorMessage,
this.boxNo,
});
const BoxingActionResult.ok(T data) : this._(success: true, data: data);
const BoxingActionResult.error({
required BoxingActionErrorKind kind,
required String message,
int? boxNo,
}) : this._(
success: false,
errorKind: kind,
errorMessage: message,
boxNo: boxNo,
);
}
typedef ApiUrlLoader = Future<String?> Function();
class BoxingApiActions {
static const networkErrorMessage = '网络异常,请检查网络连接';
static const missingApiUrlMessage = '未配置 API 地址,请前往设置';
final ApiService _apiService;
final ApiUrlLoader _loadApiUrl;
BoxingApiActions({required ApiService apiService, ApiUrlLoader? loadApiUrl})
: _apiService = apiService,
_loadApiUrl =
loadApiUrl ?? (() => AppConfigService().getString('api_url'));
Future<BoxingActionResult<BoxInfoResult>> fetchBoxInfo(String zongpai) async {
final baseUrl = await _requireBaseUrl();
if (baseUrl == null) return _missingApiUrl();
final result = await _apiService.fetchBoxInfo(
baseUrl: baseUrl,
zongpaiNo: zongpai,
);
if (result.success) return BoxingActionResult.ok(result);
return BoxingActionResult.error(
kind: _kindForMessage(result.errorMessage),
message: result.errorMessage ?? '查询失败',
);
}
Future<BoxingActionResult<BoxSaveResult>> saveBoxRecord({
required String zongpaiNo,
required int boxNo,
required int quantity,
String itemType = 'product',
int? accessoryId,
}) async {
final baseUrl = await _requireBaseUrl();
if (baseUrl == null) return _missingApiUrl();
final result = await _apiService.saveBoxRecord(
baseUrl: baseUrl,
zongpaiNo: zongpaiNo,
boxNo: boxNo,
quantity: quantity,
itemType: itemType,
accessoryId: accessoryId,
);
return _saveResult(result, fallbackMessage: '提交失败');
}
Future<BoxingActionResult<BoxSaveResult>> updateBoxRecord({
required int boxItemId,
required int boxNo,
required int quantity,
}) async {
final baseUrl = await _requireBaseUrl();
if (baseUrl == null) return _missingApiUrl();
final result = await _apiService.updateBoxRecord(
baseUrl: baseUrl,
boxItemId: boxItemId,
boxNo: boxNo,
quantity: quantity,
);
return _saveResult(result, fallbackMessage: '修改失败');
}
Future<BoxingActionResult<BoxDeleteResult>> deleteBoxRecord({
required int boxItemId,
}) async {
final baseUrl = await _requireBaseUrl();
if (baseUrl == null) return _missingApiUrl();
final result = await _apiService.deleteBoxRecord(
baseUrl: baseUrl,
boxItemId: boxItemId,
);
if (result.success) return BoxingActionResult.ok(result);
return BoxingActionResult.error(
kind: _kindForMessage(result.errorMessage),
message: result.errorMessage ?? '删除失败',
);
}
Future<String?> _requireBaseUrl() async {
final baseUrl = await _loadApiUrl() ?? '';
if (baseUrl.isEmpty) return null;
return baseUrl;
}
BoxingActionResult<T> _missingApiUrl<T>() {
return const BoxingActionResult.error(
kind: BoxingActionErrorKind.missingApiUrl,
message: missingApiUrlMessage,
);
}
BoxingActionResult<BoxSaveResult> _saveResult(
BoxSaveResult result, {
required String fallbackMessage,
}) {
if (result.success) return BoxingActionResult.ok(result);
if (result.isDuplicate) {
return BoxingActionResult.error(
kind: BoxingActionErrorKind.duplicate,
message: result.errorMessage ?? fallbackMessage,
boxNo: result.boxNo,
);
}
return BoxingActionResult.error(
kind: _kindForMessage(result.errorMessage),
message: result.errorMessage ?? fallbackMessage,
);
}
BoxingActionErrorKind _kindForMessage(String? message) {
if (message == networkErrorMessage) return BoxingActionErrorKind.network;
return BoxingActionErrorKind.backend;
}
}

View 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));
}

View 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);
}

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

View 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 ? '请扫描下一个总排号' : '装箱成功,可继续扫码',
);
}
}

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

View 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;
}
}

View 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: '删除失败');
}
}
}

View File

@@ -0,0 +1,272 @@
// ignore_for_file: invalid_use_of_protected_member, unnecessary_this
part of '../../boxing_page.dart';
extension _BoxingScanPart on _BoxingPageState {
void _onScan(ScanResult result) {
if (_isAutoProcessing) {
return;
}
if (_crossPaichaPending) {
_feedbackService.trigger(FeedbackEvent.alreadyCompleted);
return;
}
final parsed = CodeParser.parse(result.barcode);
if (parsed.type != CodeType.zongpaiNo) {
_feedbackService.trigger(FeedbackEvent.scanInvalid);
this._showStatusOverride(
'无效码,请重新扫描',
StatusDotColor.red,
const Duration(seconds: 2),
);
return;
}
final zongpai = parsed.value;
// 三种模式都允许后扫入的总排号覆盖当前扫码区;未确认数据不会入库。
this._queryBoxInfo(zongpai);
}
Future<bool> _queryBoxInfo(String zongpai) async {
final action = await _apiActions.fetchBoxInfo(zongpai);
if (!mounted) return false;
if (!action.success) {
_feedbackService.trigger(switch (action.errorKind) {
BoxingActionErrorKind.network => FeedbackEvent.networkError,
BoxingActionErrorKind.missingApiUrl => FeedbackEvent.submitFailure,
_ => FeedbackEvent.scanInvalid,
});
this._showStatusOverride(
action.errorMessage ?? '查询失败',
this._dotForActionError(action.errorKind),
const Duration(seconds: 2),
);
return false;
}
final result = action.data!;
// 判断该总牌号是否已全部装箱完毕
final packedQuantity = result.currentZongpaiBoxes.fold<int>(
0,
(sum, item) => sum + item.quantity,
);
final totalQuantity = result.quantity ?? 0;
final alreadyCompleted =
totalQuantity > 0 && packedQuantity >= totalQuantity;
_feedbackService.trigger(
alreadyCompleted
? FeedbackEvent.alreadyCompleted
: FeedbackEvent.scanValid,
);
setState(() {
_zongpaiNo = zongpai;
_paichanNo = result.paichanNo;
_workOrderNo = result.workOrderNo;
_erpQuantity = result.quantity;
_currentZongpaiBoxes = result.currentZongpaiBoxes;
_existingBoxes = result.existingBoxes;
_maxBoxNo = result.maxBoxNo;
_pendingAccessories = result.pendingAccessories;
_phase = BoxingPhase.scanned;
_isDuplicateBoxNo = false;
_editingBox = null;
_editingAssignedItemId = null;
_editingAssignedQuantity = '';
_deletingAssignedItemId = null;
_completedJumpBoxNo = null;
_statusOverrideText = null;
if (_mode == BoxingMode.singleCode) {
_paichanSwitchNotice = null;
}
// 根据模式自动填充
this._applyAutoFill();
});
return true;
}
Future<void> _processAutoScanCodes(List<String> codes) async {
if (!mounted) return;
setState(() {
_isAutoProcessing = true;
_autoWarnings.clear();
_statusOverrideText = '正在同步转运数据...';
_statusOverrideDot = StatusDotColor.orange;
});
if (_mode == BoxingMode.singleCode) {
final code = codes.first;
final ok = await this._queryBoxInfo(code);
if (!ok && mounted) {
setState(() => _autoWarnings.add('总排号 $code 未找到排产号信息,已忽略'));
}
} else {
for (final code in codes) {
if (!mounted) return;
final queried = await this._queryBoxInfo(code);
if (!queried) {
if (mounted) {
setState(() => _autoWarnings.add('总排号 $code 未找到排产号信息,已忽略'));
}
continue;
}
if (!_canSubmit) {
if (mounted) {
setState(() => _autoWarnings.add('总排号 $code 暂不能装箱,已忽略'));
}
continue;
}
final saved = await this._submit();
if (!saved && mounted) {
setState(() => _autoWarnings.add('总排号 $code 自动装箱失败,请手动处理'));
}
}
}
if (!mounted) return;
setState(() {
_isAutoProcessing = false;
_statusOverrideText = null;
_statusOverrideDot = null;
});
_focusQuantityInput();
_requestFocus();
}
void _applyAutoFill() {
switch (_mode) {
case BoxingMode.singleCode:
final remaining = _remainingQuantity;
if (remaining <= 0) {
_boxNoController.clear();
_quantityController.clear();
_statusOverrideText = '该总排号已全部装箱完毕';
_statusOverrideDot = StatusDotColor.red;
return;
}
_boxNoController.text = (_maxBoxNo + 1).toString();
_quantityController.text = remaining.toString();
_quantityController.selection = TextSelection(
baseOffset: 0,
extentOffset: _quantityController.text.length,
);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _mode == BoxingMode.singleCode) {
_quantityFocusNode.requestFocus();
}
});
case BoxingMode.multiCode:
final decision = decideMultiCodePaichanContext(
lastPaichanNo: _lastScannedPaichanNo,
currentPaichanNo: _paichanNo,
currentBoxNoText: _boxNoController.text,
maxBoxNo: _maxBoxNo,
);
if (decision.isSwitched) {
_crossPaichaPending = true;
_feedbackService.trigger(FeedbackEvent.alreadyCompleted);
this._showCrossPaichaDialog();
return;
} else {
_paichanSwitchNotice = null;
}
final boxNoToApply = decision.boxNoToApply;
if (boxNoToApply != null) {
_boxNoController.text = boxNoToApply.toString();
}
_lastScannedPaichanNo = _paichanNo;
final remaining = _remainingQuantity;
if (remaining <= 0) {
_quantityController.clear();
if (_manyToOnePackedItems.isEmpty &&
_currentZongpaiBoxes.isNotEmpty) {
_completedJumpBoxNo = _currentZongpaiBoxes.first.boxNo;
}
_statusOverrideText = '该总排号已全部装箱完毕';
_statusOverrideDot = StatusDotColor.red;
return;
}
_quantityController.text = remaining.toString();
_quantityController.selection = TextSelection(
baseOffset: 0,
extentOffset: _quantityController.text.length,
);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _mode == BoxingMode.multiCode) {
_quantityFocusNode.requestFocus();
}
});
}
_isDuplicateBoxNo = _boxNoIsDuplicate();
}
Future<void> _showCrossPaichaDialog() async {
final currentPaicha = _lastScannedPaichanNo ?? '--';
final newPaicha = _paichanNo ?? '--';
final confirmed = await showCrossPaichanDialog(
context: context,
currentPaicha: currentPaicha,
newPaicha: newPaicha,
);
if (!mounted) return;
confirmed ? this._confirmPaichanSwitch() : this._cancelPaichanSwitch();
}
void _confirmPaichanSwitch() {
setState(() {
_crossPaichaPending = false;
_manyToOnePackedItems.clear();
_editingPackedItemId = null;
_editingPackedQuantity = '';
_deletingPackedItemId = null;
_paichanSwitchNotice = '排产号已切换:${_paichanNo ?? "--"}';
_boxNoController.text = (_maxBoxNo + 1).toString();
_lastScannedPaichanNo = _paichanNo;
});
_feedbackService.trigger(FeedbackEvent.paichanSwitch);
// Continue with remaining quantity flow
final remaining = _remainingQuantity;
if (remaining <= 0) {
_quantityController.clear();
if (_manyToOnePackedItems.isEmpty && _currentZongpaiBoxes.isNotEmpty) {
_completedJumpBoxNo = _currentZongpaiBoxes.first.boxNo;
}
setState(() {
_statusOverrideText = '该总排号已全部装箱完毕';
_statusOverrideDot = StatusDotColor.red;
});
return;
}
setState(() {
_quantityController.text = remaining.toString();
_quantityController.selection = TextSelection(
baseOffset: 0,
extentOffset: _quantityController.text.length,
);
_isDuplicateBoxNo = _boxNoIsDuplicate();
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _mode == BoxingMode.multiCode) {
_quantityFocusNode.requestFocus();
}
});
}
void _cancelPaichanSwitch() {
setState(() {
_crossPaichaPending = false;
_zongpaiNo = null;
_paichanNo = _lastScannedPaichanNo;
_phase = BoxingPhase.waiting;
});
}
}

View 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: '删除失败');
}
}
}

View 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;
}
}

View File

@@ -0,0 +1,204 @@
// ignore_for_file: invalid_use_of_protected_member, unnecessary_this
part of '../../boxing_page.dart';
extension _BoxingSubmitPart on _BoxingPageState {
Future<bool> _submit() async {
if (!_canSubmit) return false;
final boxNo = int.parse(_boxNoController.text);
final quantity = int.parse(_quantityController.text);
setState(() => _isSubmitting = true);
final editing = _editingBox;
final action = editing == null
? await _apiActions.saveBoxRecord(
zongpaiNo: _zongpaiNo!,
boxNo: boxNo,
quantity: quantity,
)
: await _apiActions.updateBoxRecord(
boxItemId: editing.boxItemId,
boxNo: boxNo,
quantity: quantity,
);
if (!mounted) return false;
setState(() => _isSubmitting = false);
if (action.success) {
if (editing == null) {
this._onSubmitSuccess(boxNo, quantity, action.data!.boxItemId);
} else {
this._onUpdateSuccess(editing, boxNo, quantity);
}
return true;
} else if (action.errorKind == BoxingActionErrorKind.duplicate) {
_feedbackService.trigger(FeedbackEvent.duplicateBoxNo);
this._showStatusOverride(
'该总排号在箱号 ${action.boxNo ?? ""} 已存在,请重新输入',
StatusDotColor.amber,
const Duration(seconds: 2),
);
return false;
} else {
this._showActionError(action, fallback: '提交失败');
return false;
}
}
void _onSubmitSuccess(int boxNo, int quantity, int? boxItemId) {
_feedbackService.trigger(FeedbackEvent.submitSuccess);
setState(() {
if (_mode == BoxingMode.singleCode && boxItemId != null) {
_currentZongpaiBoxes = List.from(_currentZongpaiBoxes)
..add(
CurrentZongpaiBoxData(
boxItemId: boxItemId,
boxNo: boxNo,
quantity: quantity,
),
);
}
this._addExistingBoxItem(boxNo, quantity, boxItemId);
_maxBoxNo = _maxBoxNo > boxNo ? _maxBoxNo : boxNo;
});
switch (_mode) {
case BoxingMode.singleCode:
final remaining = _remainingQuantity;
if (remaining <= 0) {
this._showStatusOverride(
'装箱成功',
StatusDotColor.green,
const Duration(milliseconds: 1500),
);
} else {
setState(() {
_phase = BoxingPhase.scanned;
_boxNoController.text = (_maxBoxNo + 1).toString();
_quantityController.text = remaining.toString();
_quantityController.selection = TextSelection(
baseOffset: 0,
extentOffset: _quantityController.text.length,
);
_isDuplicateBoxNo = false;
});
_focusQuantityInput();
this._showStatusOverride(
'请继续完成剩余数量装箱',
StatusDotColor.green,
const Duration(milliseconds: 1500),
);
}
case BoxingMode.multiCode:
_lastScannedPaichanNo = _paichanNo;
setState(() {
if (boxItemId != null) {
_manyToOnePackedItems.add(
ManyToOnePackedItem(
boxItemId: boxItemId,
zongpaiNo: _zongpaiNo!,
paichanNo: _paichanNo,
workOrderNo: _workOrderNo,
boxNo: boxNo,
quantity: quantity,
totalQuantity: _erpQuantity,
),
);
}
_phase = BoxingPhase.waiting;
_zongpaiNo = null;
_editingPackedItemId = null;
_editingPackedQuantity = '';
_deletingPackedItemId = null;
});
this._showStatusOverride(
'装箱成功,请继续扫码',
StatusDotColor.green,
const Duration(milliseconds: 1500),
);
}
}
void _onUpdateSuccess(
CurrentZongpaiBoxData editing,
int boxNo,
int quantity,
) {
_feedbackService.trigger(FeedbackEvent.submitSuccess);
setState(() {
_currentZongpaiBoxes = _currentZongpaiBoxes.map((item) {
if (item.boxItemId != editing.boxItemId) return item;
return CurrentZongpaiBoxData(
boxItemId: item.boxItemId,
boxNo: boxNo,
quantity: quantity,
);
}).toList();
this._replaceExistingBoxItem(editing, boxNo, quantity);
_editingBox = null;
_boxNoController.text = (_maxBoxNo + 1).toString();
final remaining = _remainingQuantity;
if (remaining > 0) {
_quantityController.text = remaining.toString();
} else {
_quantityController.clear();
}
_isDuplicateBoxNo = false;
});
this._showStatusOverride(
'修改成功',
StatusDotColor.green,
const Duration(milliseconds: 1500),
);
}
Future<void> _submitAccessory(PendingAccessory accessory) async {
if (_zongpaiNo == null || _isSubmitting) return;
final boxNo = int.tryParse(_boxNoController.text);
if (boxNo == null || boxNo <= 0) {
this._showStatusOverride(
'请先输入有效箱号',
StatusDotColor.red,
const Duration(seconds: 2),
);
return;
}
setState(() => _isSubmitting = true);
final action = await _apiActions.saveBoxRecord(
zongpaiNo: _zongpaiNo!,
boxNo: boxNo,
quantity: accessory.quantity,
itemType: 'accessory',
accessoryId: accessory.accessoryId,
);
if (!mounted) return;
setState(() => _isSubmitting = false);
if (action.success) {
_feedbackService.trigger(FeedbackEvent.submitSuccess);
// Refresh box info to update pending accessories
await this._queryBoxInfo(_zongpaiNo!);
if (mounted) {
this._showStatusOverride(
'${accessory.accessoryType} 已加入箱号 $boxNo',
StatusDotColor.green,
const Duration(milliseconds: 1500),
);
}
} else {
this._showActionError(action, fallback: '附件装箱失败');
}
}
}

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

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

View File

@@ -0,0 +1,689 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
import 'package:pad_scanner/pages/boxing/widgets/multi_packed_row.dart';
import 'package:pad_scanner/services/api_service.dart';
class MultiCodeBody extends StatelessWidget {
final bool hasScan;
final String? zongpaiNo;
final String? paichanNo;
final String? workOrderNo;
final int? erpQuantity;
final int maxBoxNo;
final List<BoxDetailData> existingBoxes;
final List<ManyToOnePackedItem> visibleItems;
final TextEditingController boxNoController;
final TextEditingController quantityController;
final FocusNode boxNoFocusNode;
final FocusNode quantityFocusNode;
final bool isSubmitting;
final bool quantityTooHigh;
final bool canSubmit;
final int? editingPackedItemId;
final String editingPackedQuantity;
final int? deletingPackedItemId;
final VoidCallback onOpenDetail;
final VoidCallback onSubmitFromKeyboard;
final VoidCallback onSubmit;
final VoidCallback onQuantityChanged;
final VoidCallback onFinishBox;
final ValueChanged<String> onEditingPackedQuantityChanged;
final ValueChanged<ManyToOnePackedItem> onSavePackedItem;
final VoidCallback onCancelEditPackedItem;
final ValueChanged<ManyToOnePackedItem> onStartEditPackedItem;
final ValueChanged<ManyToOnePackedItem> onStartDeletePackedItem;
final ValueChanged<ManyToOnePackedItem> onDeletePackedItem;
final VoidCallback onCancelDeletePackedItem;
final List<PendingAccessory> pendingAccessories;
final ValueChanged<PendingAccessory> onSubmitAccessory;
const MultiCodeBody({
super.key,
required this.hasScan,
required this.zongpaiNo,
required this.paichanNo,
required this.workOrderNo,
required this.erpQuantity,
required this.maxBoxNo,
required this.existingBoxes,
required this.visibleItems,
required this.boxNoController,
required this.quantityController,
required this.boxNoFocusNode,
required this.quantityFocusNode,
required this.isSubmitting,
required this.quantityTooHigh,
required this.canSubmit,
required this.editingPackedItemId,
required this.editingPackedQuantity,
required this.deletingPackedItemId,
required this.onOpenDetail,
required this.onSubmitFromKeyboard,
required this.onSubmit,
required this.onQuantityChanged,
required this.onFinishBox,
required this.onEditingPackedQuantityChanged,
required this.onSavePackedItem,
required this.onCancelEditPackedItem,
required this.onStartEditPackedItem,
required this.onStartDeletePackedItem,
required this.onDeletePackedItem,
required this.onCancelDeletePackedItem,
required this.pendingAccessories,
required this.onSubmitAccessory,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
_MultiHeaderBar(
paichanNo: paichanNo,
maxBoxNo: maxBoxNo,
existingBoxes: existingBoxes,
boxNoController: boxNoController,
boxNoFocusNode: boxNoFocusNode,
isSubmitting: isSubmitting,
onSubmitFromKeyboard: onSubmitFromKeyboard,
onOpenDetail: onOpenDetail,
),
const _MultiListHeader(),
Expanded(
child: visibleItems.isEmpty && pendingAccessories.isEmpty
? Center(
child: Text(
'尚未装入任何物料',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade500,
fontStyle: FontStyle.italic,
),
),
)
: ListView.builder(
itemCount: visibleItems.length + pendingAccessories.length,
itemBuilder: (context, index) {
if (index < visibleItems.length) {
final item = visibleItems[index];
return MultiPackedRow(
item: item,
editing: editingPackedItemId == item.boxItemId,
deleting: deletingPackedItemId == item.boxItemId,
isSubmitting: isSubmitting,
editingQuantity: editingPackedQuantity,
onEditingQuantityChanged:
onEditingPackedQuantityChanged,
onSave: () => onSavePackedItem(item),
onCancelEdit: onCancelEditPackedItem,
onStartEdit: () => onStartEditPackedItem(item),
onStartDelete: () => onStartDeletePackedItem(item),
onDelete: () => onDeletePackedItem(item),
onCancelDelete: onCancelDeletePackedItem,
);
}
final accIndex = index - visibleItems.length;
final acc = pendingAccessories[accIndex];
return _MultiAccessoryRow(
accessory: acc,
isSubmitting: isSubmitting,
onSubmit: () => onSubmitAccessory(acc),
);
},
),
),
_MultiBottomArea(
hasScan: hasScan,
hasItems: visibleItems.isNotEmpty,
zongpaiNo: zongpaiNo,
workOrderNo: workOrderNo,
erpQuantity: erpQuantity,
quantityController: quantityController,
quantityFocusNode: quantityFocusNode,
isSubmitting: isSubmitting,
quantityTooHigh: quantityTooHigh,
canSubmit: canSubmit,
onSubmitFromKeyboard: onSubmitFromKeyboard,
onSubmit: onSubmit,
onQuantityChanged: onQuantityChanged,
onFinishBox: onFinishBox,
),
],
);
}
}
class _MultiHeaderBar extends StatelessWidget {
final String? paichanNo;
final int maxBoxNo;
final List<BoxDetailData> existingBoxes;
final TextEditingController boxNoController;
final FocusNode boxNoFocusNode;
final bool isSubmitting;
final VoidCallback onSubmitFromKeyboard;
final VoidCallback onOpenDetail;
const _MultiHeaderBar({
required this.paichanNo,
required this.maxBoxNo,
required this.existingBoxes,
required this.boxNoController,
required this.boxNoFocusNode,
required this.isSubmitting,
required this.onSubmitFromKeyboard,
required this.onOpenDetail,
});
@override
Widget build(BuildContext context) {
final hasDetail = existingBoxes.isNotEmpty;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
),
child: Row(
children: [
const Text(
'箱号',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.black54,
),
),
const SizedBox(width: 6),
SizedBox(
width: 52,
height: 32,
child: TextField(
controller: boxNoController,
focusNode: boxNoFocusNode,
enabled: !isSubmitting,
keyboardType: TextInputType.none,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
textAlign: TextAlign.center,
onEditingComplete: () {},
onSubmitted: (_) => onSubmitFromKeyboard(),
decoration: InputDecoration(
contentPadding: EdgeInsets.zero,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: Colors.grey.shade400),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: Colors.blue, width: 1.5),
),
),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
),
),
Container(
width: 1,
height: 28,
margin: const EdgeInsets.symmetric(horizontal: 10),
color: Colors.grey.shade300,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
paichanNo ?? '--',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
),
overflow: TextOverflow.ellipsis,
),
Text(
'已有 ${existingBoxes.length} 箱 · 最大箱号 $maxBoxNo',
style: TextStyle(fontSize: 11, color: Colors.grey.shade500),
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 10),
BoxingDetailButton(
enabled: hasDetail,
colorScheme: Theme.of(context).colorScheme,
onPressed: onOpenDetail,
),
],
),
);
}
}
class _MultiListHeader extends StatelessWidget {
const _MultiListHeader();
@override
Widget build(BuildContext context) {
return Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.shade200,
border: Border(bottom: BorderSide(color: Colors.grey.shade400)),
),
child: const Row(
children: [
Expanded(
flex: 26,
child: Text(
'总排号',
textAlign: TextAlign.center,
style: boxingHeaderStyle,
),
),
Expanded(
flex: 30,
child: Text(
'工令号',
textAlign: TextAlign.center,
style: boxingHeaderStyle,
),
),
Expanded(
flex: 23,
child: Text(
'数量',
textAlign: TextAlign.center,
style: boxingHeaderStyle,
),
),
SizedBox(
width: 54,
child: Text(
'操作',
textAlign: TextAlign.center,
style: boxingHeaderStyle,
),
),
],
),
);
}
}
class _MultiBottomArea extends StatelessWidget {
final bool hasScan;
final bool hasItems;
final String? zongpaiNo;
final String? workOrderNo;
final int? erpQuantity;
final TextEditingController quantityController;
final FocusNode quantityFocusNode;
final bool isSubmitting;
final bool quantityTooHigh;
final bool canSubmit;
final VoidCallback onSubmitFromKeyboard;
final VoidCallback onSubmit;
final VoidCallback onQuantityChanged;
final VoidCallback onFinishBox;
const _MultiBottomArea({
required this.hasScan,
required this.hasItems,
required this.zongpaiNo,
required this.workOrderNo,
required this.erpQuantity,
required this.quantityController,
required this.quantityFocusNode,
required this.isSubmitting,
required this.quantityTooHigh,
required this.canSubmit,
required this.onSubmitFromKeyboard,
required this.onSubmit,
required this.onQuantityChanged,
required this.onFinishBox,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey.shade300)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_MultiScanBar(
hasScan: hasScan,
zongpaiNo: zongpaiNo,
workOrderNo: workOrderNo,
erpQuantity: erpQuantity,
),
_MultiSubmitRow(
hasScan: hasScan,
hasItems: hasItems,
quantityController: quantityController,
quantityFocusNode: quantityFocusNode,
isSubmitting: isSubmitting,
quantityTooHigh: quantityTooHigh,
canSubmit: canSubmit,
onSubmitFromKeyboard: onSubmitFromKeyboard,
onSubmit: onSubmit,
onQuantityChanged: onQuantityChanged,
onFinishBox: onFinishBox,
),
],
),
);
}
}
class _MultiScanBar extends StatelessWidget {
final bool hasScan;
final String? zongpaiNo;
final String? workOrderNo;
final int? erpQuantity;
const _MultiScanBar({
required this.hasScan,
required this.zongpaiNo,
required this.workOrderNo,
required this.erpQuantity,
});
@override
Widget build(BuildContext context) {
if (!hasScan) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
color: Colors.grey.shade50,
child: const Row(
children: [
Icon(Icons.qr_code_scanner, color: Colors.grey, size: 16),
SizedBox(width: 8),
Text(
'请扫描执行卡二维码',
style: TextStyle(
color: Colors.grey,
fontSize: 13,
fontWeight: FontWeight.w400,
),
),
],
),
);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
decoration: BoxDecoration(
color: Colors.green.shade50,
border: Border(bottom: BorderSide(color: Colors.green.shade200)),
),
child: Row(
children: [
const Icon(Icons.check_circle, color: Colors.green, size: 15),
const SizedBox(width: 6),
Expanded(
child: Text(
'${zongpaiNo ?? ""} · ${workOrderNo ?? "--"} · ${erpQuantity ?? "--"}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.green.shade700,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
class _MultiSubmitRow extends StatelessWidget {
final bool hasScan;
final bool hasItems;
final TextEditingController quantityController;
final FocusNode quantityFocusNode;
final bool isSubmitting;
final bool quantityTooHigh;
final bool canSubmit;
final VoidCallback onSubmitFromKeyboard;
final VoidCallback onSubmit;
final VoidCallback onQuantityChanged;
final VoidCallback onFinishBox;
const _MultiSubmitRow({
required this.hasScan,
required this.hasItems,
required this.quantityController,
required this.quantityFocusNode,
required this.isSubmitting,
required this.quantityTooHigh,
required this.canSubmit,
required this.onSubmitFromKeyboard,
required this.onSubmit,
required this.onQuantityChanged,
required this.onFinishBox,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 7, 12, 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'数量',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: hasScan ? Colors.black54 : Colors.grey.shade400,
),
),
const SizedBox(width: 6),
SizedBox(
width: 52,
height: 34,
child: TextField(
controller: quantityController,
focusNode: quantityFocusNode,
enabled: hasScan && !isSubmitting,
keyboardType: TextInputType.none,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
textAlign: TextAlign.center,
onChanged: (_) => onQuantityChanged(),
onEditingComplete: () {},
onSubmitted: (_) => onSubmitFromKeyboard(),
decoration: InputDecoration(
contentPadding: EdgeInsets.zero,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(
color: quantityTooHigh ? Colors.red : Colors.grey.shade300,
),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: Colors.grey.shade200),
),
filled: !hasScan,
fillColor: Colors.grey.shade100,
),
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
),
const SizedBox(width: 8),
SizedBox(
height: 34,
child: ElevatedButton(
onPressed: canSubmit ? onSubmit : null,
style: ElevatedButton.styleFrom(
backgroundColor: canSubmit
? Theme.of(context).colorScheme.primary
: Colors.grey.shade200,
foregroundColor: canSubmit
? Colors.white
: Colors.grey.shade400,
padding: const EdgeInsets.symmetric(horizontal: 18),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
child: isSubmitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'确认',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: SizedBox(
height: 34,
child: OutlinedButton(
onPressed: hasItems ? onFinishBox : null,
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
side: BorderSide(
color: hasItems
? Colors.grey.shade600
: Colors.grey.shade300,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'完成本箱',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: hasItems ? Colors.black87 : Colors.grey.shade400,
),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration(
color: hasItems
? Colors.grey.shade200
: Colors.grey.shade100,
borderRadius: BorderRadius.circular(3),
),
child: Text(
'P3',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: hasItems
? Colors.black54
: Colors.grey.shade400,
),
),
),
],
),
),
),
),
],
),
);
}
}
class _MultiAccessoryRow extends StatelessWidget {
final PendingAccessory accessory;
final bool isSubmitting;
final VoidCallback onSubmit;
const _MultiAccessoryRow({
required this.accessory,
required this.isSubmitting,
required this.onSubmit,
});
@override
Widget build(BuildContext context) {
return Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.orange.shade50,
border: Border(bottom: BorderSide(color: Colors.grey.shade200)),
),
child: Row(
children: [
Icon(Icons.build, size: 14, color: Colors.orange.shade600),
const SizedBox(width: 6),
Expanded(
flex: 40,
child: Text(
accessory.accessoryType,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
Expanded(
flex: 20,
child: Text(
'\u00d7${accessory.quantity}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.grey.shade700,
),
),
),
SizedBox(
height: 28,
child: ElevatedButton(
onPressed: isSubmitting ? null : onSubmit,
style: ElevatedButton.styleFrom(
backgroundColor: isSubmitting
? Colors.grey.shade300
: Colors.orange.shade600,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
child: const Text(
'加入本箱',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700),
),
),
),
],
),
);
}
}

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

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

View File

@@ -0,0 +1,699 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
import 'package:pad_scanner/pages/boxing/widgets/single_assigned_row.dart';
import 'package:pad_scanner/services/api_service.dart';
class SingleCodeBody extends StatelessWidget {
final bool isWaiting;
final bool isFinished;
final String? zongpaiNo;
final String? paichanNo;
final String? workOrderNo;
final int? erpQuantity;
final int packedQuantity;
final int maxBoxNo;
final List<CurrentZongpaiBoxData> assignedItems;
final List<BoxDetailData> existingBoxes;
final TextEditingController boxNoController;
final TextEditingController quantityController;
final FocusNode boxNoFocusNode;
final FocusNode quantityFocusNode;
final bool isSubmitting;
final bool isDuplicateBoxNo;
final bool quantityTooHigh;
final bool canSubmit;
final int? editingAssignedItemId;
final String editingAssignedQuantity;
final int? deletingAssignedItemId;
final VoidCallback onOpenDetail;
final VoidCallback onCheckDuplicateBoxNo;
final VoidCallback onSubmitFromKeyboard;
final VoidCallback onSubmit;
final VoidCallback onQuantityChanged;
final ValueChanged<String> onEditingAssignedQuantityChanged;
final ValueChanged<CurrentZongpaiBoxData> onSaveAssignedItem;
final VoidCallback onCancelEditAssigned;
final ValueChanged<CurrentZongpaiBoxData> onStartEditAssigned;
final ValueChanged<CurrentZongpaiBoxData> onStartDeleteAssigned;
final ValueChanged<CurrentZongpaiBoxData> onDeleteAssignedItem;
final VoidCallback onCancelDeleteAssigned;
final List<PendingAccessory> pendingAccessories;
final ValueChanged<PendingAccessory> onSubmitAccessory;
const SingleCodeBody({
super.key,
required this.isWaiting,
required this.isFinished,
required this.zongpaiNo,
required this.paichanNo,
required this.workOrderNo,
required this.erpQuantity,
required this.packedQuantity,
required this.maxBoxNo,
required this.assignedItems,
required this.existingBoxes,
required this.boxNoController,
required this.quantityController,
required this.boxNoFocusNode,
required this.quantityFocusNode,
required this.isSubmitting,
required this.isDuplicateBoxNo,
required this.quantityTooHigh,
required this.canSubmit,
required this.editingAssignedItemId,
required this.editingAssignedQuantity,
required this.deletingAssignedItemId,
required this.onOpenDetail,
required this.onCheckDuplicateBoxNo,
required this.onSubmitFromKeyboard,
required this.onSubmit,
required this.onQuantityChanged,
required this.onEditingAssignedQuantityChanged,
required this.onSaveAssignedItem,
required this.onCancelEditAssigned,
required this.onStartEditAssigned,
required this.onStartDeleteAssigned,
required this.onDeleteAssignedItem,
required this.onCancelDeleteAssigned,
required this.pendingAccessories,
required this.onSubmitAccessory,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
children: [
_SingleInfoBar(
isWaiting: isWaiting,
paichanNo: paichanNo,
existingBoxes: existingBoxes,
maxBoxNo: maxBoxNo,
colorScheme: colorScheme,
onOpenDetail: onOpenDetail,
),
_SingleScanBar(
isWaiting: isWaiting,
zongpaiNo: zongpaiNo,
workOrderNo: workOrderNo,
erpQuantity: erpQuantity,
packedQuantity: packedQuantity,
),
_SingleAssignedList(
isWaiting: isWaiting,
isFinished: isFinished,
items: assignedItems,
workOrderNo: workOrderNo,
isSubmitting: isSubmitting,
editingAssignedItemId: editingAssignedItemId,
editingAssignedQuantity: editingAssignedQuantity,
deletingAssignedItemId: deletingAssignedItemId,
onEditingAssignedQuantityChanged: onEditingAssignedQuantityChanged,
onSaveAssignedItem: onSaveAssignedItem,
onCancelEditAssigned: onCancelEditAssigned,
onStartEditAssigned: onStartEditAssigned,
onStartDeleteAssigned: onStartDeleteAssigned,
onDeleteAssignedItem: onDeleteAssignedItem,
onCancelDeleteAssigned: onCancelDeleteAssigned,
),
if (pendingAccessories.isNotEmpty && !isWaiting)
_PendingAccessoriesSection(
pendingAccessories: pendingAccessories,
isSubmitting: isSubmitting,
onSubmitAccessory: onSubmitAccessory,
),
_SingleBottomArea(
disabled: isWaiting || isFinished,
boxNoController: boxNoController,
quantityController: quantityController,
boxNoFocusNode: boxNoFocusNode,
quantityFocusNode: quantityFocusNode,
isSubmitting: isSubmitting,
isDuplicateBoxNo: isDuplicateBoxNo,
quantityTooHigh: quantityTooHigh,
canSubmit: canSubmit,
onCheckDuplicateBoxNo: onCheckDuplicateBoxNo,
onSubmitFromKeyboard: onSubmitFromKeyboard,
onSubmit: onSubmit,
onQuantityChanged: onQuantityChanged,
),
],
);
}
}
class _SingleScanBar extends StatelessWidget {
final bool isWaiting;
final String? zongpaiNo;
final String? workOrderNo;
final int? erpQuantity;
final int packedQuantity;
const _SingleScanBar({
required this.isWaiting,
required this.zongpaiNo,
required this.workOrderNo,
required this.erpQuantity,
required this.packedQuantity,
});
@override
Widget build(BuildContext context) {
if (isWaiting) {
return Container(
height: 35,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
),
child: const Row(
children: [
Icon(Icons.qr_code_scanner, color: Colors.grey, size: 16),
SizedBox(width: 8),
Text(
'请扫描执行卡二维码',
style: TextStyle(color: Colors.grey, fontSize: 13),
),
],
),
);
}
return Container(
height: 35,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.green.shade50,
border: Border(bottom: BorderSide(color: Colors.green.shade200)),
),
child: Row(
children: [
const Icon(Icons.check_circle, color: Colors.green, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
zongpaiNo ?? '',
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Text(
'${workOrderNo ?? "--"} / ${erpQuantity ?? 0}件 / 已装$packedQuantity',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Colors.black54,
),
),
],
),
);
}
}
class _SingleInfoBar extends StatelessWidget {
final bool isWaiting;
final String? paichanNo;
final List<BoxDetailData> existingBoxes;
final int maxBoxNo;
final ColorScheme colorScheme;
final VoidCallback onOpenDetail;
const _SingleInfoBar({
required this.isWaiting,
required this.paichanNo,
required this.existingBoxes,
required this.maxBoxNo,
required this.colorScheme,
required this.onOpenDetail,
});
@override
Widget build(BuildContext context) {
final detailEnabled = !isWaiting && existingBoxes.isNotEmpty;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
),
child: Row(
children: [
Text(
isWaiting ? '排产号 --' : (paichanNo ?? '--'),
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: isWaiting ? Colors.grey.shade400 : Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Text(
isWaiting ? '已有 -- 箱' : '已有 ${existingBoxes.length}',
style: TextStyle(
fontSize: 12,
color: isWaiting
? Colors.grey.shade400
: Colors.grey.shade600,
),
),
Text(
isWaiting ? '最大箱号 --' : '最大箱号 $maxBoxNo',
style: TextStyle(
fontSize: 12,
color: isWaiting
? Colors.grey.shade400
: Colors.grey.shade600,
),
),
],
),
),
const SizedBox(width: 10),
BoxingDetailButton(
enabled: detailEnabled,
colorScheme: colorScheme,
onPressed: onOpenDetail,
),
],
),
);
}
}
class _SingleAssignedList extends StatelessWidget {
final bool isWaiting;
final bool isFinished;
final List<CurrentZongpaiBoxData> items;
final String? workOrderNo;
final bool isSubmitting;
final int? editingAssignedItemId;
final String editingAssignedQuantity;
final int? deletingAssignedItemId;
final ValueChanged<String> onEditingAssignedQuantityChanged;
final ValueChanged<CurrentZongpaiBoxData> onSaveAssignedItem;
final VoidCallback onCancelEditAssigned;
final ValueChanged<CurrentZongpaiBoxData> onStartEditAssigned;
final ValueChanged<CurrentZongpaiBoxData> onStartDeleteAssigned;
final ValueChanged<CurrentZongpaiBoxData> onDeleteAssignedItem;
final VoidCallback onCancelDeleteAssigned;
const _SingleAssignedList({
required this.isWaiting,
required this.isFinished,
required this.items,
required this.workOrderNo,
required this.isSubmitting,
required this.editingAssignedItemId,
required this.editingAssignedQuantity,
required this.deletingAssignedItemId,
required this.onEditingAssignedQuantityChanged,
required this.onSaveAssignedItem,
required this.onCancelEditAssigned,
required this.onStartEditAssigned,
required this.onStartDeleteAssigned,
required this.onDeleteAssignedItem,
required this.onCancelDeleteAssigned,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: Column(
children: [
const _SingleAssignedHeader(),
Expanded(
child: items.isEmpty
? Center(
child: Text(
isFinished
? '该总排号已全部装箱完毕'
: (isWaiting ? '扫码后显示已分配箱号记录' : '本次扫码尚未分配箱号'),
style: TextStyle(
fontSize: 13,
color: isFinished
? Colors.green.shade700
: Colors.grey.shade500,
fontStyle: FontStyle.italic,
),
),
)
: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return SingleAssignedRow(
item: item,
workOrderNo: workOrderNo,
editing: editingAssignedItemId == item.boxItemId,
deleting: deletingAssignedItemId == item.boxItemId,
isSubmitting: isSubmitting,
editingQuantity: editingAssignedQuantity,
onEditingQuantityChanged:
onEditingAssignedQuantityChanged,
onSave: () => onSaveAssignedItem(item),
onCancelEdit: onCancelEditAssigned,
onStartEdit: () => onStartEditAssigned(item),
onStartDelete: () => onStartDeleteAssigned(item),
onDelete: () => onDeleteAssignedItem(item),
onCancelDelete: onCancelDeleteAssigned,
);
},
),
),
],
),
);
}
}
class _SingleAssignedHeader extends StatelessWidget {
const _SingleAssignedHeader();
@override
Widget build(BuildContext context) {
return Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.shade200,
border: Border(bottom: BorderSide(color: Colors.grey.shade400)),
),
child: const Row(
children: [
Expanded(
flex: 22,
child: Text(
'箱号',
textAlign: TextAlign.center,
style: boxingHeaderStyle,
),
),
Expanded(
flex: 30,
child: Text(
'工令号',
textAlign: TextAlign.center,
style: boxingHeaderStyle,
),
),
Expanded(
flex: 23,
child: Text(
'数量',
textAlign: TextAlign.center,
style: boxingHeaderStyle,
),
),
SizedBox(
width: 54,
child: Text(
'操作',
textAlign: TextAlign.center,
style: boxingHeaderStyle,
),
),
],
),
);
}
}
class _SingleBottomArea extends StatelessWidget {
final bool disabled;
final TextEditingController boxNoController;
final TextEditingController quantityController;
final FocusNode boxNoFocusNode;
final FocusNode quantityFocusNode;
final bool isSubmitting;
final bool isDuplicateBoxNo;
final bool quantityTooHigh;
final bool canSubmit;
final VoidCallback onCheckDuplicateBoxNo;
final VoidCallback onSubmitFromKeyboard;
final VoidCallback onSubmit;
final VoidCallback onQuantityChanged;
const _SingleBottomArea({
required this.disabled,
required this.boxNoController,
required this.quantityController,
required this.boxNoFocusNode,
required this.quantityFocusNode,
required this.isSubmitting,
required this.isDuplicateBoxNo,
required this.quantityTooHigh,
required this.canSubmit,
required this.onCheckDuplicateBoxNo,
required this.onSubmitFromKeyboard,
required this.onSubmit,
required this.onQuantityChanged,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey.shade300)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Text(
'箱号',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: disabled ? Colors.grey.shade400 : Colors.black54,
),
),
const SizedBox(width: 4),
SizedBox(
width: 52,
height: 34,
child: TextField(
controller: boxNoController,
focusNode: boxNoFocusNode,
enabled: !disabled && !isSubmitting,
keyboardType: TextInputType.none,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
textAlign: TextAlign.center,
onChanged: (_) => onCheckDuplicateBoxNo(),
onSubmitted: (_) => onSubmitFromKeyboard(),
decoration: compactInputDecoration(
disabled: disabled || isSubmitting,
borderColor: isDuplicateBoxNo
? Colors.amber
: Colors.blue.shade700,
),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(width: 8),
Text(
'数量',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: disabled ? Colors.grey.shade400 : Colors.black54,
),
),
const SizedBox(width: 4),
SizedBox(
width: 52,
height: 34,
child: TextField(
controller: quantityController,
focusNode: quantityFocusNode,
enabled: !disabled && !isSubmitting,
keyboardType: TextInputType.none,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
textAlign: TextAlign.center,
onChanged: (_) => onQuantityChanged(),
onSubmitted: (_) => onSubmitFromKeyboard(),
decoration: compactInputDecoration(
disabled: disabled || isSubmitting,
borderColor: quantityTooHigh
? Colors.red
: Colors.blue.shade700,
),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
),
const Spacer(),
SizedBox(
height: 34,
child: ElevatedButton(
onPressed: canSubmit ? onSubmit : null,
style: ElevatedButton.styleFrom(
backgroundColor: canSubmit
? Theme.of(context).colorScheme.primary
: Colors.grey.shade300,
foregroundColor: canSubmit
? Colors.white
: Colors.grey.shade600,
padding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
child: isSubmitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'确认',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
),
);
}
}
class _PendingAccessoriesSection extends StatelessWidget {
final List<PendingAccessory> pendingAccessories;
final bool isSubmitting;
final ValueChanged<PendingAccessory> onSubmitAccessory;
const _PendingAccessoriesSection({
required this.pendingAccessories,
required this.isSubmitting,
required this.onSubmitAccessory,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.orange.shade50,
border: Border(bottom: BorderSide(color: Colors.orange.shade200)),
),
child: Row(
children: [
Icon(Icons.build, size: 14, color: Colors.orange.shade700),
const SizedBox(width: 6),
Text(
'待装箱附件(${pendingAccessories.length} 项)',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Colors.orange.shade800,
),
),
],
),
),
...pendingAccessories.map(
(acc) => _AccessoryRow(
accessory: acc,
isSubmitting: isSubmitting,
onSubmit: () => onSubmitAccessory(acc),
),
),
],
);
}
}
class _AccessoryRow extends StatelessWidget {
final PendingAccessory accessory;
final bool isSubmitting;
final VoidCallback onSubmit;
const _AccessoryRow({
required this.accessory,
required this.isSubmitting,
required this.onSubmit,
});
@override
Widget build(BuildContext context) {
return Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.orange.shade50,
border: Border(bottom: BorderSide(color: Colors.grey.shade200)),
),
child: Row(
children: [
Expanded(
flex: 40,
child: Text(
accessory.accessoryType,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
Expanded(
flex: 20,
child: Text(
'\u00d7${accessory.quantity}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.grey.shade700,
),
),
),
SizedBox(
height: 28,
child: ElevatedButton(
onPressed: isSubmitting ? null : onSubmit,
style: ElevatedButton.styleFrom(
backgroundColor: isSubmitting
? Colors.grey.shade300
: Colors.orange.shade600,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
child: const Text(
'加入本箱',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700),
),
),
),
],
),
);
}
}

View File

@@ -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(
@@ -74,14 +103,57 @@ class BoxingDetailPage extends StatelessWidget {
),
child: Column(
children: box.items.map((item) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
final isAccessory = item.itemType == 'accessory';
return Container(
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 4,
),
margin: const EdgeInsets.symmetric(vertical: 1),
decoration: isAccessory
? BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(4),
)
: null,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(item.zongpaiNo, style: const TextStyle(fontSize: 14)),
Text('数量:${item.quantity}',
style: const TextStyle(fontSize: 14)),
if (isAccessory)
Padding(
padding: const EdgeInsets.only(right: 6),
child: Icon(
Icons.build,
size: 14,
color: Colors.orange.shade700,
),
),
Expanded(
flex: 3,
child: Text(
item.zongpaiNo,
style: TextStyle(
fontSize: 14,
fontWeight: isAccessory
? FontWeight.w600
: FontWeight.normal,
),
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),
),
],
),
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,14 @@
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';
import 'package:pad_scanner/pages/boxing_page.dart';
import 'package:pad_scanner/pages/accessory_page.dart';
import 'package:pad_scanner/services/api_service.dart';
/// Module type enum for each available feature card
enum ModuleType { registration, boxing, shelfQuery }
enum ModuleType { registration, boxing, accessory, shelfQuery }
/// Module status for PRD state tracking
enum ModuleStatus { online, developing, planning }
@@ -14,20 +16,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 +41,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 +50,34 @@ 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.accessory,
shortcut: '3',
icon: Icons.build,
title: '附件登记',
status: ModuleStatus.developing,
route: '/accessory',
),
FeatureCard(
type: ModuleType.shelfQuery,
shortcut: '4',
icon: Icons.search_outlined,
title: '货架查询',
description: '按合同查询货架库存',
status: ModuleStatus.planning,
version: null,
route: '',
),
];
@@ -80,6 +86,59 @@ 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 == null || shortcut < 1 || shortcut > _modules.length) {
return null;
}
return _modules[shortcut - 1];
}
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;
}
if (key == LogicalKeyboardKey.digit4 ||
key == LogicalKeyboardKey.numpad4 ||
event.character == '4') {
return 4;
}
return null;
}
Future<void> _checkConnection() async {
@@ -130,20 +189,28 @@ class _HomePageState extends State<HomePage> {
case ModuleType.boxing:
targetPage = const BoxingPage();
break;
case ModuleType.accessory:
targetPage = const AccessoryPage();
break;
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 +228,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 +300,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 +312,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,
),
),
);
}
}

View File

@@ -0,0 +1,333 @@
// ignore_for_file: invalid_use_of_protected_member
part of '../../registration_page.dart';
extension _RegistrationDialogsPart on _RegistrationPageState {
void _showLocationSwitchDialog(
String newLocationCode,
CodeType newLocationType,
) {
final count = _zongpaiNos.length;
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: Colors.orange.shade50,
title: Row(
children: [
Icon(Icons.warning_amber, color: Colors.orange.shade800),
const SizedBox(width: 8),
Text('切换货架号', style: TextStyle(color: Colors.orange.shade900)),
],
),
content: Text(
'当前已有 $count 条待上架数据,切换货架号不会清除已扫描的总排号。',
style: const TextStyle(fontSize: 14),
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(ctx);
},
child: const Text('取消'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
_feedbackService.trigger(FeedbackEvent.scanValid);
setState(() {
_locationCode = newLocationCode;
_locationType = newLocationType;
_clearStatusOverride();
});
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange.shade700,
foregroundColor: Colors.white,
),
child: const Text('确认切换'),
),
],
),
);
}
void _showDuplicateDialog(String zongpaiNo, Map<String, dynamic>? info) {
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: Colors.red.shade50,
title: const Row(
children: [
Icon(Icons.warning, color: Colors.red),
SizedBox(width: 8),
Text('重复上架', style: TextStyle(color: Colors.red)),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('总排号:$zongpaiNo'),
const SizedBox(height: 4),
Text('已登记货位:${info?["location_code"] ?? "未知"}'),
const SizedBox(height: 4),
Text('登记时间:${info?["registered_at"] ?? "未知"}'),
const SizedBox(height: 12),
const Text(
'请核查实物,确认是否操作错误。',
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
actions: [
TextButton(
onPressed: () {
_feedbackService.stopAlert();
Navigator.pop(ctx);
},
child: const Text('关闭'),
),
],
),
);
}
void _showLocationConflictDialog({
required List<PaichaOverviewItem> onShelfItems,
required List<PaichaOverviewItem> transferredItems,
}) {
final contentParts = <Widget>[];
if (onShelfItems.isNotEmpty) {
contentParts.add(
Text(
'重复上架',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.red.shade700,
),
),
);
for (final item in onShelfItems) {
contentParts.addAll([
Text('总排号:${item.zongpaiNo}'),
Text('已登记货位:${item.locationCode ?? "未知"}'),
const SizedBox(height: 6),
]);
}
}
if (transferredItems.isNotEmpty) {
if (onShelfItems.isNotEmpty) {
contentParts.add(const Divider());
contentParts.add(const SizedBox(height: 4));
}
contentParts.add(
Text(
'货物已转运',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.orange.shade700,
),
),
);
for (final item in transferredItems) {
contentParts.addAll([
Text('总排号:${item.zongpaiNo}'),
Text('转运货位:${item.locationCode ?? "未知"}'),
const SizedBox(height: 6),
]);
}
}
contentParts.add(const SizedBox(height: 4));
contentParts.add(
const Text(
'请核查实物,确认是否操作错误。',
style: TextStyle(fontWeight: FontWeight.bold),
),
);
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: Colors.red.shade50,
title: const Row(
children: [
Icon(Icons.warning, color: Colors.red),
SizedBox(width: 8),
Text('操作冲突', style: TextStyle(color: Colors.red)),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: contentParts,
),
actions: [
TextButton(
onPressed: () {
_feedbackService.stopAlert();
Navigator.pop(ctx);
},
child: const Text('关闭'),
),
],
),
);
}
Future<void> _showCrossPaichaDialog(String newZongpaiNo) async {
final currentPaicha = _overview?.paichaNo ?? '--';
final shouldSwitch = await _confirmCrossPaichaSwitch(
newZongpaiNo: newZongpaiNo,
currentPaicha: currentPaicha,
);
if (!mounted) return;
if (shouldSwitch) {
_switchToNewPaicha(newZongpaiNo);
return;
}
setState(() {
_crossPaichaPending = false;
});
}
Future<bool> _confirmCrossPaichaSwitch({
required String newZongpaiNo,
required String currentPaicha,
}) async {
final dialogFocus = FocusNode();
var dismissed = false;
var selectedIndex = 0; // 0 = "否" (default), 1 = "是"
void dismissAsNo() {
if (dismissed) return;
dismissed = true;
Navigator.of(context).pop(false);
}
void confirmSwitch() {
if (dismissed) return;
dismissed = true;
Navigator.of(context).pop(true);
}
final dialogFuture = showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) {
return KeyboardListener(
focusNode: dialogFocus,
onKeyEvent: (event) {
if (event is! KeyDownEvent) return;
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
setDialogState(() => selectedIndex = 0);
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
setDialogState(() => selectedIndex = 1);
} else if (event.logicalKey == LogicalKeyboardKey.enter) {
selectedIndex == 0 ? dismissAsNo() : confirmSwitch();
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
dismissAsNo();
}
},
child: PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) dismissAsNo();
},
child: AlertDialog(
backgroundColor: Colors.orange.shade50,
title: Row(
children: [
Icon(Icons.warning_amber, color: Colors.orange.shade800),
const SizedBox(width: 8),
Text(
'跨排产号扫描',
style: TextStyle(color: Colors.orange.shade900),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'总排号 $newZongpaiNo 不属于当前排产号($currentPaicha'
'是否切换到新的排产号?\n\n'
'选择「是」将放弃当前已扫描的所有数据。',
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 16),
_dialogOptionBtn(
label: '',
selected: selectedIndex == 0,
onTap: dismissAsNo,
),
const SizedBox(height: 8),
_dialogOptionBtn(
label: '是,切换排产号',
selected: selectedIndex == 1,
onTap: confirmSwitch,
),
],
),
),
),
);
},
),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
dialogFocus.requestFocus();
});
final shouldSwitch = await dialogFuture;
return shouldSwitch ?? false;
}
Widget _dialogOptionBtn({
required String label,
required bool selected,
required VoidCallback onTap,
}) {
return SizedBox(
width: double.infinity,
height: 40,
child: ElevatedButton(
onPressed: onTap,
style: ElevatedButton.styleFrom(
backgroundColor: selected
? Colors.blue.shade700
: Colors.grey.shade200,
foregroundColor: selected ? Colors.white : Colors.black87,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: Text(
label,
style: TextStyle(
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
),
),
),
);
}
void _switchToNewPaicha(String newZongpaiNo) {
_feedbackService.trigger(FeedbackEvent.paichanSwitch);
setState(() {
_crossPaichaPending = false;
_zongpaiNos.clear();
_zongpaiNos.add(newZongpaiNo);
_overview = null;
_overviewNotFound = false;
_overviewError = null;
_clearStatusOverride();
});
_loadOverview(newZongpaiNo);
}
}

View 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);
}
}
});
}
}

View File

@@ -0,0 +1,80 @@
// ignore_for_file: invalid_use_of_protected_member
part of '../../registration_page.dart';
extension _RegistrationOverviewPart on _RegistrationPageState {
/// Find all scanned items that are already on shelf.
List<PaichaOverviewItem> _findOnShelfItemsInScanned() {
return registration_calculations.findOnShelfItemsInScanned(
overview: _overview,
zongpaiNos: _zongpaiNos,
);
}
/// Find all scanned items that are already transferred.
List<PaichaOverviewItem> _findTransferredItemsInScanned() {
return registration_calculations.findTransferredItemsInScanned(
overview: _overview,
zongpaiNos: _zongpaiNos,
);
}
Future<String?> _baseUrl() async {
final configService = AppConfigService();
final baseUrl = await configService.getString('api_url') ?? '';
if (baseUrl.isEmpty) {
return null;
}
return baseUrl;
}
Future<void> _loadOverview(String zongpaiNo, {bool force = false}) async {
if (!force && _overviewZongpaiNo == zongpaiNo && _overview != null) {
return;
}
final requestId = ++_overviewRequestId;
setState(() {
_overviewZongpaiNo = zongpaiNo;
_overviewLoading = true;
_overviewNotFound = false;
_overviewError = null;
});
final baseUrl = await _baseUrl();
if (!mounted || requestId != _overviewRequestId) return;
if (baseUrl == null) {
setState(() {
_overviewLoading = false;
_overviewError = '未配置 API 地址,请前往设置';
});
return;
}
final result = await _apiService.fetchPaichaOverview(
baseUrl: baseUrl,
zongpaiNo: zongpaiNo,
);
if (!mounted || requestId != _overviewRequestId) return;
setState(() {
_overviewLoading = false;
if (result.success) {
_overview = result;
_overviewNotFound = false;
_overviewError = null;
} else if (result.notFound) {
_overview = null;
_overviewNotFound = true;
_overviewError = null;
} else {
_overviewError = result.errorMessage ?? '加载失败,点击重试';
}
});
}
Future<void> _refreshCurrentOverview() async {
final zongpaiNo = _overviewZongpaiNo;
if (zongpaiNo == null) return;
await _loadOverview(zongpaiNo, force: true);
}
}

View File

@@ -0,0 +1,128 @@
// ignore_for_file: invalid_use_of_protected_member
part of '../../registration_page.dart';
extension _RegistrationScanPart on _RegistrationPageState {
void _startScanListening() {
_scanSubscription ??= _scannerService.scanResults.listen(_onScan);
}
Future<void> _stopScanListening() async {
final subscription = _scanSubscription;
_scanSubscription = null;
await subscription?.cancel();
}
void _restartScanListening() {
_scanSubscription?.cancel();
_scanSubscription = null;
_startScanListening();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_focusNode.requestFocus();
}
});
}
void _onKeyEvent(KeyEvent event) {
if (event is! KeyDownEvent) return;
if (_isLockToggleKey(event)) {
_cycleMode();
return;
}
if (event.logicalKey == LogicalKeyboardKey.enter) {
_submit();
}
}
bool _isLockToggleKey(KeyEvent event) {
final key = event.logicalKey;
return key == LogicalKeyboardKey.select ||
key == LogicalKeyboardKey.gameButtonRight1;
}
void _onScan(ScanResult result) {
final parsed = CodeParser.parse(result.barcode);
switch (parsed.type) {
case CodeType.zongpaiNo:
_handleZongpaiScan(parsed.value);
case CodeType.locationNormal:
case CodeType.locationTransit:
_handleLocationScan(parsed.value, parsed.type);
case CodeType.invalid:
_feedbackService.trigger(FeedbackEvent.scanInvalid);
_showStatusOverride(
'无效码:${result.barcode}',
StatusDotColor.red,
const Duration(seconds: 2),
);
}
}
void _handleLocationScan(String locationCode, CodeType locationType) {
// In singleCode mode or no existing data or no previous shelf, switch directly
if (_mode == RegistrationMode.singleCode ||
_zongpaiNos.isEmpty ||
_locationCode == null) {
_feedbackService.trigger(FeedbackEvent.scanValid);
setState(() {
_locationCode = locationCode;
_locationType = locationType;
_clearStatusOverride();
});
return;
}
// In multiCode mode with existing data, confirm before switching
_showLocationSwitchDialog(locationCode, locationType);
}
void _handleZongpaiScan(String zongpaiNo) {
// If cross-paicha dialog is showing, just vibrate and discard
if (_crossPaichaPending) {
_triggerDoubleVibration();
return;
}
// In multiCode mode with existing data, check for cross-paicha scan
if (_mode == RegistrationMode.multiCode &&
_zongpaiNos.isNotEmpty &&
_overview?.success == true &&
!_overview!.items.any((item) => item.zongpaiNo == zongpaiNo)) {
_crossPaichaPending = true;
_triggerDoubleVibration();
_showCrossPaichaDialog(zongpaiNo);
return;
}
final shouldRefresh = _overviewZongpaiNo != zongpaiNo;
_feedbackService.trigger(FeedbackEvent.scanValid);
setState(() {
if (_mode == RegistrationMode.multiCode) {
if (!_zongpaiNos.contains(zongpaiNo)) {
_zongpaiNos.add(zongpaiNo);
}
} else {
if (_zongpaiNos.isEmpty) {
_zongpaiNos.add(zongpaiNo);
} else {
_zongpaiNos[0] = zongpaiNo;
}
}
_clearStatusOverride();
});
if (shouldRefresh) {
_loadOverview(zongpaiNo);
}
}
void _triggerDoubleVibration() {
Vibration.vibrate(pattern: [0, 200, 100, 200]);
}
void _removeZongpai(String zongpaiNo) {
setState(() {
_zongpaiNos.remove(zongpaiNo);
});
}
}

View File

@@ -0,0 +1,55 @@
// ignore_for_file: invalid_use_of_protected_member
part of '../../registration_page.dart';
extension _RegistrationStatusPart on _RegistrationPageState {
void _clearStatusOverride() {
_statusOverrideText = null;
_statusOverrideDot = null;
}
void _showStatusOverride(String text, StatusDotColor dot, Duration duration) {
setState(() {
_statusOverrideText = text;
_statusOverrideDot = dot;
});
Future.delayed(duration, () {
if (mounted) setState(() => _clearStatusOverride());
});
}
void _updateBaseStatus() {
if (_isSubmitting) {
_statusDot = StatusDotColor.orange;
_statusText = '正在提交…';
return;
}
if (_mode == RegistrationMode.multiCode &&
_locationCode != null &&
_zongpaiNos.isEmpty) {
_statusDot = StatusDotColor.blue;
_statusText = '多码上架模式,请扫描下一张执行卡';
return;
}
final hasZongpai = _zongpaiNos.isNotEmpty;
final hasLocation = _locationCode != null;
if (hasZongpai && hasLocation) {
_statusDot = StatusDotColor.blue;
_statusText = '请确认信息并提交';
} else if (hasZongpai) {
_statusDot = StatusDotColor.blue;
_statusText = '请扫描目标货位号';
} else if (hasLocation) {
_statusDot = StatusDotColor.blue;
_statusText = '请扫描执行卡';
} else {
_statusDot = StatusDotColor.blue;
_statusText = '等待扫描总排号或货位号…';
}
}
bool get _canSubmit =>
_zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting;
bool get _isTransitTarget => isTransitTarget(_locationType, _locationCode);
}

View File

@@ -0,0 +1,317 @@
// ignore_for_file: invalid_use_of_protected_member
part of '../../registration_page.dart';
extension _RegistrationSubmitPart on _RegistrationPageState {
Future<void> _submit() async {
if (!_canSubmit) return;
final baseUrl = await _baseUrl();
if (baseUrl == null) {
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride(
'未配置 API 地址,请前往设置',
StatusDotColor.red,
const Duration(seconds: 2),
);
return;
}
setState(() => _isSubmitting = true);
// For transit target: block if any scanned item is already transferred
// (on_shelf items are allowed — transit registration acts as off-shelf)
if (_isTransitTarget) {
final transferredItems = _findTransferredItemsInScanned();
if (transferredItems.isNotEmpty) {
setState(() => _isSubmitting = false);
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showLocationConflictDialog(
onShelfItems: [],
transferredItems: transferredItems,
);
return;
}
}
if (_mode == RegistrationMode.multiCode && _zongpaiNos.length > 1) {
if (_isTransitTarget && !await _ensureBatchSamePaicha(baseUrl)) {
if (!mounted) return;
setState(() => _isSubmitting = false);
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride(
batchPaichaMismatchMessage,
StatusDotColor.red,
const Duration(seconds: 2),
);
return;
}
// Pre-check: block entire batch if any item already has a location binding
if (!_isTransitTarget) {
final onShelfItems = _findOnShelfItemsInScanned();
final transferredItems = _findTransferredItemsInScanned();
if (onShelfItems.isNotEmpty || transferredItems.isNotEmpty) {
setState(() => _isSubmitting = false);
_feedbackService.trigger(FeedbackEvent.duplicateError);
_showLocationConflictDialog(
onShelfItems: onShelfItems,
transferredItems: transferredItems,
);
return;
}
}
await _submitBatch(baseUrl);
} else {
await _submitOne(baseUrl, _zongpaiNos.first);
}
}
Future<bool> _ensureBatchSamePaicha(String baseUrl) async {
final paichaByCode = <String, String>{};
final cachedOverview = _overview;
if (cachedOverview?.success == true && cachedOverview!.paichaNo != null) {
for (final item in cachedOverview.items) {
paichaByCode[item.zongpaiNo] = cachedOverview.paichaNo!;
}
}
for (final zongpaiNo in _zongpaiNos) {
if (paichaByCode.containsKey(zongpaiNo)) continue;
final result = await _apiService.fetchPaichaOverview(
baseUrl: baseUrl,
zongpaiNo: zongpaiNo,
);
if (!result.success || result.paichaNo == null) {
return false;
}
for (final item in result.items) {
paichaByCode[item.zongpaiNo] = result.paichaNo!;
}
}
return allSamePaicha(_zongpaiNos.map((code) => paichaByCode[code]));
}
Future<void> _submitOne(String baseUrl, String zongpaiNo) async {
final result = await _apiService.registerLocation(
baseUrl: baseUrl,
zongpaiNo: zongpaiNo,
locationCode: _locationCode!,
);
if (!mounted) return;
setState(() => _isSubmitting = false);
if (result.success) {
_feedbackService.trigger(FeedbackEvent.submitSuccess);
if (_isTransitTarget) {
setState(() => _isSubmitting = false);
await _navigateToBoxing(
mode: BoxingMode.singleCode,
codes: [zongpaiNo],
codesToClear: [zongpaiNo],
clearLocation: _mode == RegistrationMode.singleCode,
);
return;
}
setState(() {
_zongpaiNos.remove(zongpaiNo);
if (_mode == RegistrationMode.singleCode) {
_locationCode = null;
_locationType = null;
}
});
final msg = result.isOffShelfSuccess
? '下架成功'
: (_mode == RegistrationMode.multiCode ? '多码上架模式,请扫描下一张执行卡' : '上架成功');
_showStatusOverride(
msg,
StatusDotColor.green,
const Duration(milliseconds: 1500),
);
await _refreshCurrentOverview();
} else if (result.isDuplicate) {
_feedbackService.trigger(FeedbackEvent.duplicateError);
_showDuplicateDialog(zongpaiNo, result.duplicateInfo);
} else if (result.isAlreadyOffShelf) {
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride(
result.errorMessage ?? '该总排号已下架至转运区域,不可重新上架',
StatusDotColor.red,
const Duration(seconds: 2),
);
} else {
final isNetwork = result.errorMessage == '网络异常,请检查网络连接';
_feedbackService.trigger(
isNetwork ? FeedbackEvent.networkError : FeedbackEvent.submitFailure,
);
_showStatusOverride(
result.errorMessage ?? '提交失败',
isNetwork ? StatusDotColor.yellow : StatusDotColor.red,
const Duration(seconds: 2),
);
}
}
Future<void> _submitBatch(String baseUrl) async {
int successCount = 0;
int offShelfCount = 0;
final failed = <String>[];
final toRemove = <String>[];
final transitCodes = <String>[];
for (final zongpaiNo in List.of(_zongpaiNos)) {
final result = await _apiService.registerLocation(
baseUrl: baseUrl,
zongpaiNo: zongpaiNo,
locationCode: _locationCode!,
);
if (result.success) {
successCount++;
if (result.isOffShelfSuccess) {
offShelfCount++;
}
toRemove.add(zongpaiNo);
if (_isTransitTarget) {
transitCodes.add(zongpaiNo);
}
} else {
failed.add(zongpaiNo);
if (result.isDuplicate) {
if (_isTransitTarget && transitCodes.isNotEmpty) {
setState(() {
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
_isSubmitting = false;
});
await _navigateToBoxing(
mode: BoxingMode.multiCode,
codes: transitCodes,
codesToClear: toRemove,
clearLocation: false,
returnMessage: '成功 $successCount 条,失败 ${failed.length}',
);
return;
}
setState(() {
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
_isSubmitting = false;
});
_feedbackService.trigger(FeedbackEvent.duplicateError);
_showDuplicateDialog(zongpaiNo, result.duplicateInfo);
return;
}
if (result.isAlreadyOffShelf) {
if (_isTransitTarget && transitCodes.isNotEmpty) {
setState(() {
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
_isSubmitting = false;
});
await _navigateToBoxing(
mode: BoxingMode.multiCode,
codes: transitCodes,
codesToClear: toRemove,
clearLocation: false,
returnMessage: '成功 $successCount 条,失败 ${failed.length}',
);
return;
}
setState(() {
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
_isSubmitting = false;
});
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride(
result.errorMessage ?? '该总排号已下架至转运区域,不可重新上架',
StatusDotColor.red,
const Duration(seconds: 2),
);
return;
}
}
}
if (!mounted) return;
setState(() {
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
_isSubmitting = false;
});
if (_isTransitTarget && transitCodes.isNotEmpty) {
await _navigateToBoxing(
mode: BoxingMode.multiCode,
codes: transitCodes,
codesToClear: toRemove,
clearLocation: failed.isEmpty,
returnMessage: failed.isEmpty
? null
: '成功 $successCount 条,失败 ${failed.length}',
);
return;
}
if (failed.isEmpty) {
_feedbackService.trigger(FeedbackEvent.submitSuccess);
final msg = offShelfCount == successCount
? '批量下架成功($successCount 条)'
: '批量上架成功($successCount 条)';
_showStatusOverride(
msg,
StatusDotColor.green,
const Duration(milliseconds: 1500),
);
await _refreshCurrentOverview();
} else {
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride(
'成功 $successCount 条,失败 ${failed.length}',
StatusDotColor.red,
const Duration(seconds: 2),
);
}
}
Future<void> _navigateToBoxing({
required BoxingMode mode,
required List<String> codes,
required List<String> codesToClear,
required bool clearLocation,
String? returnMessage,
}) async {
await _refreshCurrentOverview();
if (!mounted) return;
final navigator = Navigator.of(context);
await _stopScanListening();
await navigator.push(
MaterialPageRoute(
builder: (_) => BoxingPage(
arguments: BoxingPageArguments(
initialMode: mode,
autoScanCodes: codes,
),
),
),
);
if (!mounted) return;
await Future<void>.delayed(const Duration(milliseconds: 120));
if (!mounted) return;
_restartScanListening();
setState(() {
_zongpaiNos.removeWhere((item) => codesToClear.contains(item));
if (clearLocation) {
_locationCode = null;
_locationType = null;
}
});
await _refreshCurrentOverview();
if (!mounted) return;
if (returnMessage != null) {
_showStatusOverride(
returnMessage,
StatusDotColor.red,
const Duration(seconds: 2),
);
}
}
}

View File

@@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import 'package:pad_scanner/services/api_service.dart';
class RegistrationOverviewStats {
final int totalCount;
final int shelvedCount;
final int transferredCount;
const RegistrationOverviewStats({
required this.totalCount,
required this.shelvedCount,
required this.transferredCount,
});
}
class RegistrationOverviewSections {
final List<PaichaOverviewItem> scanned;
final List<PaichaOverviewItem> unscanned;
const RegistrationOverviewSections({
required this.scanned,
required this.unscanned,
});
bool get hasDivider => scanned.isNotEmpty && unscanned.isNotEmpty;
}
RegistrationOverviewStats overviewStats(PaichaOverviewResult? overview) {
if (overview?.success != true) {
return const RegistrationOverviewStats(
totalCount: 0,
shelvedCount: 0,
transferredCount: 0,
);
}
var shelvedCount = 0;
var transferredCount = 0;
for (final item in overview!.items) {
switch (item.status) {
case 'on_shelf':
shelvedCount++;
case 'transferred':
transferredCount++;
}
}
return RegistrationOverviewStats(
totalCount: overview.totalCount,
shelvedCount: shelvedCount,
transferredCount: transferredCount,
);
}
RegistrationOverviewSections splitOverviewItems({
required PaichaOverviewResult overview,
required Iterable<String> zongpaiNos,
}) {
final scannedSet = zongpaiNos.toSet();
final scanned = <PaichaOverviewItem>[];
final unscanned = <PaichaOverviewItem>[];
for (final item in overview.items) {
if (scannedSet.contains(item.zongpaiNo)) {
scanned.add(item);
} else {
unscanned.add(item);
}
}
return RegistrationOverviewSections(scanned: scanned, unscanned: unscanned);
}
List<PaichaOverviewItem> findOnShelfItemsInScanned({
required PaichaOverviewResult? overview,
required Iterable<String> zongpaiNos,
}) {
return _findScannedItemsByStatus(
overview: overview,
zongpaiNos: zongpaiNos,
status: 'on_shelf',
);
}
List<PaichaOverviewItem> findTransferredItemsInScanned({
required PaichaOverviewResult? overview,
required Iterable<String> zongpaiNos,
}) {
return _findScannedItemsByStatus(
overview: overview,
zongpaiNos: zongpaiNos,
status: 'transferred',
);
}
bool hasAnyLocatedInScanned({
required PaichaOverviewResult? overview,
required Iterable<String> zongpaiNos,
}) {
if (overview?.success != true) return false;
final scannedSet = zongpaiNos.toSet();
return overview!.items.any(
(item) =>
scannedSet.contains(item.zongpaiNo) &&
(item.status == 'on_shelf' || item.status == 'transferred'),
);
}
Color registrationBarColor({
required String status,
required bool isScanned,
required bool isTransitTarget,
}) {
if (isScanned) {
final isConflict = isTransitTarget
? status == 'transferred'
: (status == 'on_shelf' || status == 'transferred');
return isConflict ? Colors.red : const Color(0xFF43A047);
}
switch (status) {
case 'on_shelf':
return const Color(0xFF2196F3);
case 'transferred':
return const Color(0xFFFF9800);
default:
return Colors.grey.shade400;
}
}
Color registrationRowBgColor({
required String status,
required bool isScanned,
required bool isTransitTarget,
}) {
if (isScanned) {
final isConflict = isTransitTarget
? status == 'transferred'
: (status == 'on_shelf' || status == 'transferred');
return isConflict ? Colors.red.shade50 : const Color(0xFFF1F8E9);
}
switch (status) {
case 'on_shelf':
return const Color(0xFFE3F2FD);
case 'transferred':
return const Color(0xFFFFF3E0);
default:
return const Color(0xFFF5F5F5);
}
}
List<PaichaOverviewItem> _findScannedItemsByStatus({
required PaichaOverviewResult? overview,
required Iterable<String> zongpaiNos,
required String status,
}) {
if (overview?.success != true) return [];
final scannedSet = zongpaiNos.toSet();
return overview!.items
.where(
(item) => scannedSet.contains(item.zongpaiNo) && item.status == status,
)
.toList();
}

View File

@@ -0,0 +1,453 @@
// ignore_for_file: invalid_use_of_protected_member
part of '../../registration_page.dart';
extension _RegistrationWidgetsPart on _RegistrationPageState {
String _locationLabel(CodeType? type) {
if (type == CodeType.locationTransit) return '转运区域';
return '普通货架';
}
Color _locationLabelColor(CodeType? type) {
if (type == CodeType.locationTransit) return Colors.orange;
return Colors.blue;
}
Widget _buildModePill() {
return BoxingModePill(
isSingle: _mode == RegistrationMode.singleCode,
label: _mode == RegistrationMode.singleCode ? '单码上架' : '多码上架',
enabled: !_isSubmitting,
onTap: _cycleMode,
);
}
Widget _buildLocationChip() {
final color = _locationLabelColor(_locationType);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
_locationLabel(_locationType),
style: TextStyle(
fontSize: 11,
color: color,
fontWeight: FontWeight.bold,
),
),
);
}
// === Paicha Header ===
Widget _buildPaichaHeader() {
final overview = _overview;
final hasData = overview?.success == true;
final hasLoc = _locationCode != null;
final stats = registration_calculations.overviewStats(overview);
return Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 6),
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
hasData ? (overview!.paichaNo ?? '--') : '--',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: hasData ? Colors.black87 : Colors.grey.shade400,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (hasLoc)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_locationCode!,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.black54,
),
),
const SizedBox(width: 5),
_buildLocationChip(),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildStatTag(
'${stats.totalCount}',
const Color(0xFFE3F2FD),
const Color(0xFF1565C0),
hasData,
),
_buildStatTag(
'上架 ${stats.shelvedCount}',
const Color(0xFFE8F5E9),
const Color(0xFF2E7D32),
hasData,
),
_buildStatTag(
'转运 ${stats.transferredCount}',
const Color(0xFFFFF3E0),
const Color(0xFFE65100),
hasData,
),
],
),
],
),
),
],
),
);
}
Widget _buildStatTag(String text, Color bg, Color fg, bool hasData) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
margin: const EdgeInsets.only(left: 5),
decoration: BoxDecoration(
color: hasData ? bg : const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(10),
),
child: Text(
text,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: hasData ? fg : Colors.grey.shade400,
),
),
);
}
// === Table Head ===
Widget _buildTableHead() {
return Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.shade200,
border: Border(bottom: BorderSide(color: Colors.grey.shade400)),
),
child: const Row(
children: [
Expanded(
flex: 22,
child: Text(
'总排号',
textAlign: TextAlign.center,
style: _headerStyle,
),
),
Expanded(
flex: 18,
child: Text(
'工令号',
textAlign: TextAlign.center,
style: _headerStyle,
),
),
Expanded(
flex: 12,
child: Text('数量', textAlign: TextAlign.center, style: _headerStyle),
),
Expanded(
flex: 28,
child: Text(
'货位号',
textAlign: TextAlign.center,
style: _headerStyle,
),
),
],
),
);
}
// === List Body ===
Widget _buildListBody() {
if (_overviewZongpaiNo == null) {
return _buildListMessage('扫描总排号后自动显示排产号上架情况');
}
if (_overviewLoading && _overview == null) {
return _buildListMessage('正在加载排产号上架情况…');
}
if (_overviewNotFound) {
return _buildListMessage('暂无排产信息');
}
if (_overviewError != null && _overview == null) {
return _buildListError(_overviewError!);
}
final overview = _overview;
if (overview == null || !overview.success) {
return _buildListMessage('扫描总排号后自动显示排产号上架情况');
}
final sections = registration_calculations.splitOverviewItems(
overview: overview,
zongpaiNos: _zongpaiNos,
);
final scanned = sections.scanned;
final unscanned = sections.unscanned;
return Column(
children: [
if (_overviewError != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
color: Colors.red.shade50,
child: Row(
children: [
Expanded(
child: Text(
_overviewError!,
style: const TextStyle(fontSize: 12, color: Colors.red),
),
),
TextButton(
onPressed: _overviewZongpaiNo == null
? null
: () => _loadOverview(_overviewZongpaiNo!, force: true),
child: const Text('重试'),
),
],
),
),
Expanded(
child: ListView.builder(
itemCount:
scanned.length +
(sections.hasDivider ? 1 : 0) +
unscanned.length,
itemBuilder: (context, index) {
if (index < scanned.length) {
return _buildListRow(scanned[index], isScanned: true);
}
if (index == scanned.length && sections.hasDivider) {
return _buildSectionDivider(unscanned.length);
}
final unscannedIdx =
index - scanned.length - (sections.hasDivider ? 1 : 0);
return _buildListRow(unscanned[unscannedIdx], isScanned: false);
},
),
),
],
);
}
Widget _buildSectionDivider(int count) {
return Container(
height: 24,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: Colors.grey.shade200)),
),
child: Row(
children: [
const Expanded(child: Divider(height: 0)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'以下 $count 项未操作',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: Colors.grey.shade500,
),
),
),
const Expanded(child: Divider(height: 0)),
],
),
);
}
Widget _buildListRow(PaichaOverviewItem item, {required bool isScanned}) {
final rowStyle = TextStyle(
fontSize: 15,
fontWeight: isScanned ? FontWeight.w700 : FontWeight.w500,
color: Colors.black87,
);
final barColor = registration_calculations.registrationBarColor(
status: item.status,
isScanned: isScanned,
isTransitTarget: _isTransitTarget,
);
final bgColor = registration_calculations.registrationRowBgColor(
status: item.status,
isScanned: isScanned,
isTransitTarget: _isTransitTarget,
);
return Container(
constraints: const BoxConstraints(minHeight: 36),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: bgColor,
border: Border(
left: BorderSide(color: barColor, width: 3),
bottom: BorderSide(color: Colors.grey.shade200),
),
),
child: Row(
children: [
Expanded(
flex: 22,
child: Text(
item.zongpaiNo,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: rowStyle,
),
),
Expanded(
flex: 18,
child: Text(
item.workOrderNo ?? '--',
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: rowStyle,
),
),
Expanded(
flex: 12,
child: Text(
item.quantity.toString(),
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: rowStyle,
),
),
Expanded(
flex: 28,
child: Text(
item.locationCode ?? '\u2014',
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: rowStyle,
),
),
if (isScanned && _mode == RegistrationMode.multiCode) ...[
const SizedBox(width: 6),
SizedBox(
width: 28,
height: 28,
child: IconButton(
icon: Icon(Icons.delete_outline, size: 16),
color: Colors.red,
onPressed: () => _removeZongpai(item.zongpaiNo),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
),
),
],
],
),
);
}
Widget _buildListMessage(String text) {
return Center(
child: Text(
text,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
);
}
Widget _buildListError(String text) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
text,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13, color: Colors.red),
),
const SizedBox(height: 6),
TextButton(
onPressed: _overviewZongpaiNo == null
? null
: () => _loadOverview(_overviewZongpaiNo!, force: true),
child: const Text('重试'),
),
],
),
);
}
// === Bottom Bar ===
Widget _buildBottomBar(ColorScheme colorScheme) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey.shade300)),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: SizedBox(
width: double.infinity,
height: 34,
child: ElevatedButton(
onPressed: _canSubmit ? _submit : null,
style: ElevatedButton.styleFrom(
backgroundColor: registrationSubmitColor(
canSubmit: _canSubmit,
isTransitTarget: _isTransitTarget,
primaryColor: colorScheme.primary,
),
foregroundColor: _canSubmit ? Colors.white : Colors.grey.shade600,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
child: _isSubmitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
registrationSubmitLabel(
isTransitTarget: _isTransitTarget,
isMultiCode: _mode == RegistrationMode.multiCode,
zongpaiCount: _zongpaiNos.length,
),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
),
);
}
}
const _headerStyle = TextStyle(fontSize: 10, fontWeight: FontWeight.w700);

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pad_scanner/services/app_config_service.dart';
@@ -5,7 +7,26 @@ 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/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});
@@ -20,22 +41,32 @@ class _RegistrationPageState extends State<RegistrationPage> {
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;
// Status bar state
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();
});
@@ -43,305 +74,15 @@ class _RegistrationPageState extends State<RegistrationPage> {
@override
void dispose() {
_scanSubscription?.cancel();
_focusNode.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:
_feedbackService.trigger(FeedbackEvent.scanValid);
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);
}
}
_clearStatusOverride();
});
case CodeType.locationNormal:
case CodeType.locationTransit:
if (!_isLocked) {
_feedbackService.trigger(FeedbackEvent.scanValid);
setState(() {
_locationCode = parsed.value;
_locationType = parsed.type;
_clearStatusOverride();
});
}
case CodeType.invalid:
_feedbackService.trigger(FeedbackEvent.scanInvalid);
_showStatusOverride(
'无效码:${result.barcode}',
StatusDotColor.red,
const Duration(seconds: 2),
);
}
}
void _removeZongpai(int index) {
setState(() {
_zongpaiNos.removeAt(index);
});
}
// --- Status bar management ---
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 (_isLocked && _locationCode != null && _zongpaiNos.isEmpty) {
_statusDot = StatusDotColor.blue;
_statusText = '货位已锁定,请扫描下一张执行卡';
return;
}
final hasZ = _zongpaiNos.isNotEmpty;
final hasL = _locationCode != null;
if (hasZ && hasL) {
_statusDot = StatusDotColor.blue;
_statusText = '请确认信息并提交';
} else if (hasZ && !hasL) {
_statusDot = StatusDotColor.blue;
_statusText = '请扫描目标货位号';
} else if (!hasZ && hasL) {
_statusDot = StatusDotColor.blue;
_statusText = '请扫描执行卡';
} else {
_statusDot = StatusDotColor.blue;
_statusText = '等待扫描总排号或货位号…';
}
}
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) {
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride(
'未配置 API 地址,请前往设置',
StatusDotColor.red,
const Duration(seconds: 2),
);
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) {
_feedbackService.trigger(FeedbackEvent.submitSuccess);
setState(() {
_zongpaiNos.remove(zongpaiNo);
if (!_isLocked) {
_locationCode = null;
_locationType = null;
}
});
final msg = _isLocked ? '货位已锁定,请扫描下一张执行卡' : '上架成功';
_showStatusOverride(msg, StatusDotColor.green, const Duration(milliseconds: 1500));
} else if (result.isDuplicate) {
_feedbackService.trigger(FeedbackEvent.duplicateError);
_showDuplicateDialog(zongpaiNo, result.duplicateInfo);
} 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;
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;
});
_feedbackService.trigger(FeedbackEvent.duplicateError);
_showDuplicateDialog(zp, result.duplicateInfo);
return;
}
}
}
if (!mounted) return;
setState(() {
_zongpaiNos.removeWhere((e) => toRemove.contains(e));
_isSubmitting = false;
});
if (failed.isEmpty) {
_feedbackService.trigger(FeedbackEvent.submitSuccess);
_showStatusOverride(
'批量上架成功($successCount 条)',
StatusDotColor.green,
const Duration(milliseconds: 1500),
);
} else {
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride(
'成功 $successCount 条,失败 ${failed.length}',
StatusDotColor.red,
const Duration(seconds: 2),
);
}
}
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 _toggleLock(bool value) {
if (value && _locationCode == null) {
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride(
'请先扫描货位号',
StatusDotColor.red,
const Duration(seconds: 2),
);
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;
// Compute effective status
_updateBaseStatus();
final effectiveDot = _statusOverrideDot ?? _statusDot;
final effectiveText = _statusOverrideText ?? _statusText;
@@ -351,292 +92,21 @@ class _RegistrationPageState extends State<RegistrationPage> {
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: [
// Main form area (no top banner)
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),
),
),
),
],
),
),
),
// Bottom status bar
_buildPaichaHeader(),
if (_overview?.success == true) _buildTableHead(),
Expanded(child: _buildListBody()),
_buildBottomBar(colorScheme),
StatusBar(dotColor: effectiveDot, text: effectiveText),
],
),

View File

@@ -57,14 +57,42 @@ class _SettingsPageState extends State<SettingsPage> {
}
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('设置已保存');
@@ -91,18 +119,6 @@ class _SettingsPageState extends State<SettingsPage> {
);
if (result != null && result.files.single.path != null) {
final path = result.files.single.path!;
switch (key) {
case 'success':
await _soundService.setSuccessPath(path);
case 'failure':
await _soundService.setFailurePath(path);
case 'beep':
await _soundService.setBeepPath(path);
case 'error':
await _soundService.setErrorPath(path);
case 'alert':
await _soundService.setAlertPath(path);
}
setState(() {
switch (key) {
case 'success':
@@ -121,18 +137,6 @@ class _SettingsPageState extends State<SettingsPage> {
}
Future<void> _clearSound(String key) async {
switch (key) {
case 'success':
await _soundService.setSuccessPath(null);
case 'failure':
await _soundService.setFailurePath(null);
case 'beep':
await _soundService.setBeepPath(null);
case 'error':
await _soundService.setErrorPath(null);
case 'alert':
await _soundService.setAlertPath(null);
}
setState(() {
switch (key) {
case 'success':

File diff suppressed because it is too large Load Diff

View File

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

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

View File

@@ -10,7 +10,9 @@ enum FeedbackEvent {
duplicateError, // Duplicate (shelf) → alert loop + continuous vibrate
networkError, // Network error → failure sound + short vibrate
duplicateBoxNo, // Duplicate box no → failure sound + short vibrate
alreadyCompleted, // Zongpai already boxed → beep + double vibrate
modeSwitch, // Mode toggle → beep only
paichanSwitch, // Paichan changed → beep + short vibrate
}
/// Centralized feedback dispatcher.
@@ -54,8 +56,16 @@ class FeedbackService {
_soundService.playFailure();
_vibrateShort();
case FeedbackEvent.alreadyCompleted:
_soundService.playBeep();
_vibrateDouble();
case FeedbackEvent.modeSwitch:
_soundService.playBeep();
case FeedbackEvent.paichanSwitch:
_soundService.playBeep();
_vibrateShort();
}
}
@@ -77,6 +87,11 @@ class FeedbackService {
Vibration.vibrate(pattern: [0, 300, 100, 300, 100, 300]);
}
/// Two short bursts to signal an already-completed item.
void _vibrateDouble() {
Vibration.vibrate(pattern: [0, 200, 100, 200]);
}
Future<void> dispose() async {
await _soundService.dispose();
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:pad_scanner/services/code_parser.dart';
const batchPaichaMismatchMessage = '批量凑箱仅支持同排产号产品,请移除不属于同一批次的总排号';
bool isTransitTarget(CodeType? locationType, String? locationCode) {
return locationType == CodeType.locationTransit ||
(locationCode?.startsWith('TRANS-') ?? false);
}
String registrationSubmitLabel({
required bool isTransitTarget,
required bool 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;
}

View File

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

View File

@@ -33,23 +33,24 @@ class StatusBar extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 12),
color: colorScheme.surfaceContainerHighest,
child: Row(
children: [
Container(
width: 8,
height: 8,
width: 6,
height: 6,
decoration: BoxDecoration(
color: _resolveDotColor(context),
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
const SizedBox(width: 7),
Expanded(
child: Text(
text,
style: const TextStyle(fontSize: 14),
style: const TextStyle(fontSize: 11),
overflow: TextOverflow.ellipsis,
),
),

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

View 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);
});
});
}

View 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);
});
});
}

View 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,
'请扫描下一个总排号',
);
});
});
}

View 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);
}

View File

@@ -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 {

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

View 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);
});
});
}