Compare commits

...

65 Commits

Author SHA1 Message Date
Misaka
99ed3bb9ec feat: add temp storage shelf (B prefix) UI support
Recognize B-prefix location codes as temp storage shelves. Add purple
color scheme, conflict detection for temp_stored status, and updated
submit/dialog logic for temp storage target handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-25 22:48:29 +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
Misaka_Company
62583510b2 docs: add CLAUDE.md with app installation rules
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-12 17:33:41 +08:00
Misaka_Company
1417c8baf9 refactor: unify error feedback system with FeedbackService and StatusBar widget
- Create FeedbackService as centralized sound/vibration dispatcher
- Create reusable StatusBar widget with StatusDotColor enum (blue/orange/green/red/yellow/amber)
- Extend SoundService with beep/error/alert sound types and loop playback
- Remove top feedback banners from both registration and boxing pages
- Route all feedback through bottom status bar per PRD requirements
- Add sound and vibration feedback to boxing module (was completely missing)
- Handle network errors with yellow status, general errors with red
- Enhance duplicate dialog with alert loop sound that stops on close
- Add beep/error/alert sound file selectors in settings page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-12 17:15:28 +08:00
Misaka_Company
ac77294ecb feat: enhance boxing module with box mode support and fix Android build
- Add box_mode parameter to box submission API for one-to-one/one-to-many/many-to-one modes
- Allow re-scan override in one2one mode without clearing state
- Improve duplicate detection messages per boxing mode
- Disable Kotlin incremental compilation to fix cross-drive build failure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-12 15:42:43 +08:00
Misaka
2937efdefd chore: ignore android/build directory
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-11 20:43:13 +08:00
Misaka
732befb2a0 merge: integrate boxing module from remote with sound/vibration feedback
Merge remote boxing module (one2one, one2many, many2one modes) with
local sound and vibration feedback feature. Migrate SharedPreferences
to AppConfigService across all services including SoundService.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 20:18:47 +08:00
Misaka
230e6ee07a feat: add sound and vibration feedback for registration actions
Integrate audioplayers, vibration, and file_picker packages to provide
audio/haptic feedback on registration success/failure. Add sound
settings UI in SettingsPage for customizing notification sounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 20:13:03 +08:00
Misaka_Company
5bae60cc82 feat: implement boxing module with three modes (one2one, one2many, many2one)
- Add BoxInfoResult/BoxSaveResult data classes and API methods to ApiService
- Create BoxingPage with mode switching, auto-fill rules, duplicate detection
- Create BoxingDetailPage for read-only box detail view
- Activate boxing module in HomePage navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 17:38:16 +08:00
Misaka_Company
cdc64bf82f feat: replace SharedPreferences with file-based AppConfigService and add HomePage navigation
- Implement AppConfigService using path_provider to store settings in a local JSON file,\n   ensuring configuration survives app updates/reinstalls.\n - Replace shared_preferences with AppConfigService in HomePage, SettingsPage,\n   and RegistrationPage.\n - Replace shared_preferences with AppConfigService in HomePage, SettingsPage,\n   and RegistrationPage.\n - Add HomePage.dart as the main navigation hub with feature cards (Registration,\n   Boxing, Shelf Query) and dynamic connection status bar.\n - Remove Settings entry from RegistrationPage; Settings are only accessible from\n   the Home page. Use 'flutter analyze' to verify the code.
2026-05-11 16:24:16 +08:00
Misaka_Company
3f70049292 test: add unit tests for ApiService.registerLocation
Cover success (200), duplicate (409), validation error (400), and
network failure paths using a MockClient.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 12:58:33 +08:00
Misaka_Company
9e18ed4b8c feat: handle 400 status code with unified error format
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 12:47:21 +08:00
Misaka
28006f19a5 docs: add sound settings design document
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-09 20:50:35 +08:00
Misaka_Company
b129ed9714 feat: improve scan UX — case normalization, lock-mode list, layout swap
- CodeParser: normalize scanned barcode to uppercase and trim whitespace
  to handle scanner hardware outputting lowercase or trailing characters
- RegistrationPage: swap layout so location field is above zongpai field
- Lock mode: zongpai display changes to scrollable list supporting
  multiple entries with swipe-to-delete and per-item delete buttons
- Lock mode: batch submit when multiple zongpai numbers are queued
- Show scanned barcode content in invalid code error messages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-09 17:02:21 +08:00
Misaka_Company
d136dbf947 chore: remove obsolete tests, add design docs and PRD
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-09 14:32:24 +08:00
Misaka_Company
2f45816cfc feat: wire RegistrationPage as app home, remove ScanPage
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-09 14:30:10 +08:00
Misaka_Company
00dc838c43 feat: add RegistrationPage with single and lock-mode shelf registration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-09 14:28:39 +08:00
Misaka_Company
a79b18d7e8 feat: rewrite SettingsPage with save and test-connection
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-09 14:25:46 +08:00
Misaka_Company
b859a9cea2 feat: rewrite ApiService for registration API, remove ScanRecord model
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-09 14:23:53 +08:00
Misaka_Company
f030fb2476 feat: add CodeParser for barcode classification per PRD §4
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-09 14:20:35 +08:00
92 changed files with 19456 additions and 317 deletions

6
.gitignore vendored
View File

@@ -32,6 +32,7 @@ migrate_working_dir/
.pub-cache/
.pub/
/build/
android/build/
# Symbolication related
app.*.symbols
@@ -43,6 +44,9 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
/android/build/
# AI Agents
.claude/
.claude/
.agents/
.sisyphus/

58
CLAUDE.md Normal file
View File

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

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

@@ -1,4 +1,5 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
kotlin.incremental=false
android.useAndroidX=true
android.enableJetifier=true
systemProp.http.proxyHost=127.0.0.1

View File

@@ -0,0 +1,261 @@
# 错误反馈机制重构计划
> 版本v1.0
> 日期2026-05-12
> 状态:已完成
---
## 1. 背景与目标
### 1.1 当前问题
对照 PRD上架登记模块 v1.0 + 装箱编号模块 v1.3)的要求,当前 Flutter 应用在错误反馈方面存在以下差距:
| # | 问题 | 影响范围 |
|---|------|---------|
| 1 | **装箱模块完全没有声音和振动反馈**`boxing_page.dart` 未引入 `SoundService``Vibration` | 装箱编号 |
| 2 | **反馈横幅位置错误** — 当前在页面顶部显示反馈横幅PRD 要求统一由底部状态栏承载所有反馈文案 | 上架登记、装箱编号 |
| 3 | **底部状态栏缺少黄色(网络异常)和红色(错误)状态** — 注册页状态栏圆点仅使用蓝/橙/绿三色,错误信息只出现在顶部横幅 | 上架登记 |
| 4 | **反馈信息重复显示** — 顶部横幅和底部状态栏同时显示类似内容,造成冗余 | 上架登记、装箱编号 |
| 5 | **声音类型不足**`SoundService` 只有 success/failure 两种PRD 要求区分"扫描提示音"、"错误音"、"成功音"、"连续错误音" | 全局 |
| 6 | **重复上架弹窗缺少声音差异化** — PRD 要求"连续错误音"+"连续振动",当前只是普通失败音 | 上架登记 |
> LED 反馈暂时不实现,后续单独处理。
### 1.2 重构目标
- **统一反馈入口**:创建集中的 `FeedbackService`,管理声音/振动三通道反馈
- **消除重复**:移除顶部反馈横幅,所有反馈文案统一由底部状态栏显示
- **补全缺失**:为装箱模块补齐声音/振动反馈
- **符合 PRD**:状态栏圆点颜色、文案、持续时间与 PRD 表格一一对应
---
## 2. PRD 反馈要求汇总
### 2.1 上架登记模块PRD 第 7 节)
| 事件 | 屏幕(底部状态栏) | 声音 | 振动 |
|------|-------------------|------|------|
| 扫入有效总排号 | 总排号字段填入,绿色高亮 | 短促提示音 | 短震 |
| 扫入有效货位号 | 货位号字段填入,绿色高亮 | 短促提示音 | 短震 |
| 扫入无效码 | 红色提示 2 秒:"无效码:{值}" | 错误音 | 短震 |
| 提交成功 | 绿色提示 1.5 秒 | 成功音 | 长震 |
| 提交失败 | 红色提示 2 秒 | 失败音 | 短震 |
| 重复上架错误 | 红色错误弹窗(需手动关闭) | 连续错误音 | 连续震 |
| 网络异常 | 黄色提示:"网络异常,请检查网络连接" | 失败音 | 短震 |
### 2.2 装箱编号模块PRD 第 9 节)
| 事件 | 屏幕(底部状态栏) | 声音 | 振动 |
|------|-------------------|------|------|
| 扫入有效总排号 | 显示排产号信息,绿色对勾 | 短促提示音 | 短震 |
| 扫入无效码 | 红色提示:"无效码,请重新扫描" | 错误音 | 短震 |
| 提交成功 | 绿色提示 1.5 秒 | 成功音 | 长震 |
| 重复箱号 | amber 提示 + 输入框 amber 边框 | 失败音 | 短震 |
| 网络异常 | 黄色提示:"网络异常,请检查网络连接" | 失败音 | 短震 |
| 切换装箱模式 | 模式控件高亮切换 | 短促提示音 | — |
### 2.3 底部状态栏圆点颜色规范
| 状态 | 圆点颜色 |
|------|---------|
| 空闲等待 | 蓝色 |
| 提交中 | 橙色 |
| 成功 | 绿色 |
| 错误(无效码/提交失败/重复) | 红色 |
| 网络异常 | 黄色 |
| 警告(重复箱号) | amber |
---
## 3. 架构设计
### 3.1 新增 `FeedbackService`
创建 `lib/services/feedback_service.dart`,作为**唯一的反馈调度中心**。
```
FeedbackService
├── trigger(FeedbackEvent event) ← 统一入口
├── _playSound(SoundType type) ← 声音通道
├── _vibrate(VibrationPattern p) ← 振动通道
└── 底部状态栏状态由各 Page State 管理FeedbackService 不直接操控 Widget
```
**FeedbackEvent 枚举:**
```dart
enum FeedbackEvent {
scanValid, // 扫入有效码 → 短促提示音 + 短震
scanInvalid, // 扫入无效码 → 错误音 + 短震
submitSuccess, // 提交成功 → 成功音 + 长震
submitFailure, // 提交失败 → 失败音 + 短震
duplicateError, // 重复上架 → 连续错误音 + 连续震
networkError, // 网络异常 → 失败音 + 短震
duplicateBoxNo, // 重复箱号 → 失败音 + 短震
modeSwitch, // 模式切换 → 短促提示音
}
```
### 3.2 声音扩展
扩展 `SoundService`,增加声音类型:
| 声音类型 | 方法名 | 用途 |
|---------|--------|------|
| 短促提示音 | `playBeep()` | 有效扫码、模式切换 |
| 错误音 | `playError()` | 无效码 |
| 成功音 | `playSuccess()` | 提交成功(保留现有) |
| 失败音 | `playFailure()` | 提交失败、网络异常(保留现有) |
| 连续错误音 | `playAlertLoop()` | 重复上架弹窗(循环播放直到弹窗关闭) |
**实现策略**
-`SoundService` 中增加 `beep``error``alert` 三个可配置路径
- 设置页面增加对应的文件选择器
- 若路径未配置则静默跳过(不阻塞业务)
### 3.3 底部状态栏组件化
抽取 `lib/widgets/status_bar.dart` 为独立 Widget
```dart
enum StatusDotColor { blue, orange, green, red, yellow, amber }
class StatusBar extends StatelessWidget {
final StatusDotColor dotColor;
final String text;
// ...
}
```
所有页面统一使用此组件,消除重复代码。
---
## 4. 文件变更清单
### 4.1 新增文件
| 文件路径 | 说明 |
|---------|------|
| `lib/services/feedback_service.dart` | 反馈调度中心,管理声音/振动 |
| `lib/widgets/status_bar.dart` | 底部状态栏可复用组件 |
### 4.2 修改文件
| 文件路径 | 变更内容 |
|---------|---------|
| `lib/services/sound_service.dart` | 增加 `beep`/`error`/`alert` 声音类型和配置 |
| `lib/pages/registration_page.dart` | ① 移除顶部反馈横幅 ② 状态栏增加红/黄颜色 ③ 引入 FeedbackService ④ 重复上架弹窗调用连续错误音 |
| `lib/pages/boxing_page.dart` | ① 移除顶部反馈横幅 ② 引入 FeedbackService ③ 增加声音/振动反馈 ④ 状态栏统一使用 StatusBar 组件 |
| `lib/pages/settings_page.dart` | 增加声音文件配置项beep/error/alert |
### 4.3 不变文件
| 文件路径 | 原因 |
|---------|------|
| `lib/services/api_service.dart` | API 层只负责数据传输,不涉及反馈 |
| `lib/services/code_parser.dart` | 解析逻辑不变 |
| `lib/services/scanner_service.dart` | 扫码硬件层不变 |
| `lib/pages/home_page.dart` | 不涉及作业反馈 |
| `lib/pages/boxing_detail_page.dart` | 只读展示页,无反馈交互 |
---
## 5. 实施步骤
### 步骤 1创建 `FeedbackService`(核心)
**文件**`lib/services/feedback_service.dart`
- 定义 `FeedbackEvent` 枚举
- 根据 event 映射声音类型、振动模式
- 调用 `SoundService``Vibration`
**预估改动**~70 行新代码
### 步骤 2扩展 `SoundService`
**文件**`lib/services/sound_service.dart`
- 新增 `playBeep()``playError()``playAlertLoop()``stopAlert()` 方法
- 新增 `beep_path``error_path``alert_path` 配置存取
- 所有播放方法在路径未配置时静默返回
**预估改动**~40 行新增
### 步骤 3创建 `StatusBar` 组件
**文件**`lib/widgets/status_bar.dart`
- 抽取底部状态栏为独立 Widget
- 接收 `dotColor`(枚举)和 `text` 参数
- 统一样式:圆点 + 文字,背景色使用 `surfaceContainerHighest`
**预估改动**~40 行新代码
### 步骤 4重构上架登记页
**文件**`lib/pages/registration_page.dart`
变更清单:
1. **删除**顶部反馈横幅相关代码(`_snackbarMessage``_snackbarColor``_showFeedback()` 方法中的横幅渲染)
2. **引入** `FeedbackService`,在以下时机调用 `trigger()`
- `_onScan` 无效码 → `scanInvalid`
- `_onScan` 有效码 → `scanValid`
- `_submitOne` 成功 → `submitSuccess`
- `_submitOne` 失败 → `submitFailure`
- `_submitOne` 重复 → `duplicateError`
- catch 网络异常 → `networkError`
3. **修改**底部状态栏:
- 状态栏圆点颜色增加红色和黄色
- 错误文案在状态栏中显示(不再在横幅中)
- 移除 `_successMessage` 单独字段,统一由状态栏管理
4. **替换** 内联状态栏 Widget 为 `StatusBar` 组件
5. **修改** `_showDuplicateDialog`:打开时触发 `duplicateError`(连续音/震),关闭时停止
**预估改动**:净减 ~30 行(移除横幅 > 新增 trigger 调用)
### 步骤 5重构装箱编号页
**文件**`lib/pages/boxing_page.dart`
变更清单:
1. **删除**顶部反馈横幅相关代码
2. **删除** 内联底部状态栏代码,替换为 `StatusBar` 组件
3. **引入** `FeedbackService`,在以下时机调用 `trigger()`
- `_onScan` 无效码 → `scanInvalid`
- `_queryBoxInfo` 成功 → `scanValid`
- `_submit` 成功 → `submitSuccess`
- `_submit` 失败 → `submitFailure`
- `_submit` 重复箱号 → `duplicateBoxNo`
- catch 网络异常 → `networkError`
- `_cycleMode``modeSwitch`
4. **修改**状态栏颜色逻辑,增加红色/黄色/amber
**预估改动**:净减 ~20 行
### 步骤 6设置页扩展**文件**`lib/pages/settings_page.dart`- 新增三个声音文件选择器提示音beep、错误音error、警报音alert- 保持与现有 success/failure 选择器一致的 UI 风格**预估改动**~60 行新增
---
## 6. 风险与约束
| 风险 | 缓解措施 |
|------|---------|
| LED 功能依赖设备硬件 | `LedService` 全面包裹 try-catch不支持时静默降级 |
| 声音文件路径未配置 | 所有 `play*()` 方法在路径为空时静默返回 |
| 连续错误音循环播放 | `playAlertLoop()` 使用循环计数或时长上限,`stopAlert()` 确保可停止 |
| 振动权限 | Android 需要 `VIBRATE` 权限,检查 `AndroidManifest.xml` 是否已声明 |
---
## 7. 不在本次范围内
- 后端 API 错误格式调整PRD 已定义,后端自行对齐)
- 操作日志与操作人员记录
- 离线缓存与联网同步
- 新模块开发
- LED 反馈(后续单独处理)

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,93 @@
# 上架登记模块设计文档
日期2026-05-09
状态:已批准
---
## 1. 概述
基于 PRD v1.0 实现上架登记模块,完全替换现有 ScanPage。支持单次上架和连续上架锁定货位两种模式。新增配置页面管理 API 地址。
## 2. 架构
延续现有轻量架构,不引入额外状态管理框架。使用 setState + 简单状态类。
### 文件结构
```
lib/
├── main.dart # MaterialApp + 路由
├── models/
│ ├── registration_state.dart # 上架登记页面状态
│ └── api_config.dart # API 配置模型
├── pages/
│ ├── registration_page.dart # 上架登记主页(替换 ScanPage
│ └── settings_page.dart # 重构API地址 + 保存 + 测试连接
├── services/
│ ├── api_service.dart # 重写:适配 /CargoTrace/location
│ ├── code_parser.dart # 新增:码值识别
│ └── scanner_service.dart # 保持不变
```
## 3. 核心组件
### 3.1 CodeParser码值识别
静态工具类,实现 PRD §4 规则:
- 总排号:`^\d{2}(B|C|T)\d+$``^\d{2}(BW|CW)\d{4}$`
- 普通货位:`[区域货架]-[层]-[格]` 全大写横杠分隔
- 转运货位:`TRANS-` 开头
- 无效码:不匹配以上规则
返回枚举类型:`zongpaiNo` / `locationNormal` / `locationTransit` / `invalid`
### 3.2 RegistrationPage上架登记主页
状态字段:
- zongpaiNo / locationCode / isLocked / isSubmitting / statusText / errorType
扫码处理流程:
1. ScannerService 收到扫码事件
2. CodeParser 识别码值类型
3. 根据类型填入对应字段(重复同类型覆盖)
4. 锁定模式下货位号不接受新扫码值
5. 两字段均非空时确认按钮高亮
6. 提交 → 调用 ApiService → 处理结果
### 3.3 SettingsPage配置页
- API 地址输入框
- 保存设置按钮 → SharedPreferences 持久化
- 测试连接按钮 → HEAD/GET 请求验证可达性
- 返回按钮
### 3.4 ApiServiceAPI 服务)
- POST `{baseUrl}/CargoTrace/location`
- 请求体:`{ "zongpai_no": "...", "location_code": "..." }`
- 处理 200/409/400/500 响应
- 超时 5 秒
## 4. 错误处理
| 错误类型 | UI 表现 |
|---------|---------|
| 无效码 | 红色 SnackBar已有字段不受影响 |
| 重复上架 (409) | Dialog 弹窗,显示已有货位和登记时间,需手动关闭 |
| 网络异常 | 黄色提示条,数据保留,可重试 |
| 其他业务错误 | 显示后端错误描述,数据保留 |
## 5. 反馈机制(当前版本)
仅实现屏幕反馈:颜色变化、状态提示文案、弹窗。声音/振动/LED 留后续版本。
## 6. 页面布局
参照 PRD §8.1 的 ASCII 布局:
- 顶部标题栏 + 锁定货位 Toggle
- 总排号输入显示区(扫入时绿色边框)
- 货位号显示区 + 货位类型标签(蓝/橙)
- 确认上架按钮(两字段均填入时高亮)
- 底部状态提示栏

View File

@@ -0,0 +1,941 @@
# 上架登记模块 Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace the generic ScanPage with a shelf-registration (上架登记) module that validates scanned barcodes, binds execution card numbers to warehouse locations, and provides API configuration.
**Architecture:** Lightweight Flutter app using setState for state management, EventChannel for native barcode scanner, SharedPreferences for config persistence, and http package for API calls. CodeParser provides pure regex-based barcode classification.
**Tech Stack:** Flutter 3.x, Dart, http, shared_preferences, Android native EventChannel (SEUIC scanner)
---
### Task 1: CodeParser — barcode classification service
**Files:**
- Create: `lib/services/code_parser.dart`
- Test: `test/services/code_parser_test.dart`
**Step 1: Write the tests**
```dart
// test/services/code_parser_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:pad_scanner/services/code_parser.dart';
void main() {
group('CodeParser.parse', () {
test('identifies zongpaiNo type B', () {
final result = CodeParser.parse('26B1');
expect(result.type, CodeType.zongpaiNo);
expect(result.value, '26B1');
});
test('identifies zongpaiNo type C', () {
final result = CodeParser.parse('26C12');
expect(result.type, CodeType.zongpaiNo);
expect(result.value, '26C12');
});
test('identifies zongpaiNo type T', () {
final result = CodeParser.parse('26T3');
expect(result.type, CodeType.zongpaiNo);
expect(result.value, '26T3');
});
test('identifies zongpaiNo type BW (thermometer, 4 digits)', () {
final result = CodeParser.parse('26BW0001');
expect(result.type, CodeType.zongpaiNo);
expect(result.value, '26BW0001');
});
test('identifies zongpaiNo type CW (thermometer, 4 digits)', () {
final result = CodeParser.parse('26CW0015');
expect(result.type, CodeType.zongpaiNo);
expect(result.value, '26CW0015');
});
test('rejects BW with non-4-digit serial', () {
final result = CodeParser.parse('26BW01');
expect(result.type, CodeType.invalid);
});
test('rejects CW with non-4-digit serial', () {
final result = CodeParser.parse('26CW15');
expect(result.type, CodeType.invalid);
});
test('identifies normal location code', () {
final result = CodeParser.parse('A01-02-03');
expect(result.type, CodeType.locationNormal);
expect(result.value, 'A01-02-03');
});
test('identifies transit location code', () {
final result = CodeParser.parse('TRANS-01');
expect(result.type, CodeType.locationTransit);
expect(result.value, 'TRANS-01');
});
test('rejects lowercase location code', () {
final result = CodeParser.parse('a01-02-03');
expect(result.type, CodeType.invalid);
});
test('rejects completely invalid code', () {
final result = CodeParser.parse('HELLO123');
expect(result.type, CodeType.invalid);
});
test('rejects empty string', () {
final result = CodeParser.parse('');
expect(result.type, CodeType.invalid);
});
test('isLocation helper returns true for normal location', () {
expect(CodeParser.isLocation(CodeType.locationNormal), true);
expect(CodeParser.isLocation(CodeType.locationTransit), true);
expect(CodeParser.isLocation(CodeType.zongpaiNo), false);
expect(CodeParser.isLocation(CodeType.invalid), false);
});
test('identifies production number format (reserved, still invalid)', () {
final result = CodeParser.parse('R00001');
expect(result.type, CodeType.invalid);
});
});
}
```
**Step 2: Run tests to verify they fail**
Run: `flutter test test/services/code_parser_test.dart`
Expected: FAIL — `code_parser.dart` does not exist
**Step 3: Write the implementation**
```dart
// lib/services/code_parser.dart
/// Regex-based barcode classifier per PRD §4.
class CodeParser {
// 总排号: YY + B/C/T + digits, or YY + BW/CW + exactly 4 digits
static final _zongpaiRegex = RegExp(
r'^\d{2}(B|C|T)\d+$|^\d{2}(BW|CW)\d{4}$',
);
// 普通货位: AREA-LEVEL-SLOT, all uppercase letters/digits separated by dashes
static final _locationRegex = RegExp(
r'^[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$',
);
// 转运货位: TRANS-xxx
static final _transitRegex = RegExp(
r'^TRANS-',
);
/// Parse a scanned code string and return its classification.
static ParseResult parse(String code) {
if (code.isEmpty) {
return ParseResult(type: CodeType.invalid, value: code);
}
// Check transit first (TRANS- prefix)
if (_transitRegex.hasMatch(code)) {
return ParseResult(type: CodeType.locationTransit, value: code);
}
// Check zongpai number
if (_zongpaiRegex.hasMatch(code)) {
return ParseResult(type: CodeType.zongpaiNo, value: code);
}
// Check normal location
if (_locationRegex.hasMatch(code)) {
return ParseResult(type: CodeType.locationNormal, value: code);
}
return ParseResult(type: CodeType.invalid, value: code);
}
/// Returns true if the code type represents any location.
static bool isLocation(CodeType type) =>
type == CodeType.locationNormal || type == CodeType.locationTransit;
}
enum CodeType { zongpaiNo, locationNormal, locationTransit, invalid }
class ParseResult {
final CodeType type;
final String value;
ParseResult({required this.type, required this.value});
}
```
**Step 4: Run tests to verify they pass**
Run: `flutter test test/services/code_parser_test.dart`
Expected: All PASS
**Step 5: Commit**
```bash
git add lib/services/code_parser.dart test/services/code_parser_test.dart
git commit -m "feat: add CodeParser for barcode classification per PRD §4"
```
---
### Task 2: ApiService — rewrite for registration API
**Files:**
- Modify: `lib/services/api_service.dart` (full rewrite)
- Delete: `lib/models/scan_record.dart` (no longer needed)
**Step 1: Write the new ApiService**
```dart
// lib/services/api_service.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
/// Result of a registration API call.
class RegistrationResult {
final bool success;
final bool isDuplicate;
final String? errorMessage;
final Map<String, dynamic>? duplicateInfo;
RegistrationResult({
required this.success,
this.isDuplicate = false,
this.errorMessage,
this.duplicateInfo,
});
factory RegistrationResult.ok() =>
RegistrationResult(success: true);
factory RegistrationResult.duplicate(Map<String, dynamic> info) =>
RegistrationResult(success: false, isDuplicate: true, duplicateInfo: info);
factory RegistrationResult.error(String message) =>
RegistrationResult(success: false, errorMessage: message);
}
class ApiService {
final http.Client _client;
final Duration timeout;
ApiService({http.Client? client, this.timeout = const Duration(seconds: 5)})
: _client = client ?? http.Client();
/// Submit a shelf registration (上架登记).
Future<RegistrationResult> registerLocation({
required String baseUrl,
required String zongpaiNo,
required String locationCode,
}) async {
final uri = Uri.parse('$baseUrl/CargoTrace/location');
try {
final response = await _client
.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'zongpai_no': zongpaiNo,
'location_code': locationCode,
}),
)
.timeout(timeout);
switch (response.statusCode) {
case 200:
return RegistrationResult.ok();
case 409:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return RegistrationResult.duplicate(body);
default:
final body = jsonDecode(response.body) as Map<String, dynamic>;
final msg = body['error'] ?? body['message'] ?? 'Unknown error (${response.statusCode})';
return RegistrationResult.error(msg.toString());
}
} catch (e) {
return RegistrationResult.error('网络异常,请检查网络连接');
}
}
/// Test connectivity by making a HEAD request to the base URL.
Future<bool> testConnection(String baseUrl) async {
try {
final uri = Uri.parse(baseUrl);
final response = await _client.head(uri).timeout(timeout);
return response.statusCode < 500;
} catch (_) {
return false;
}
}
}
```
**Step 2: Verify compilation**
Run: `flutter analyze lib/services/api_service.dart`
Expected: No issues
**Step 3: Commit**
```bash
git add lib/services/api_service.dart
git rm lib/models/scan_record.dart
git commit -m "feat: rewrite ApiService for registration API, remove ScanRecord model"
```
---
### Task 3: SettingsPage — rewrite with save + test connection
**Files:**
- Modify: `lib/pages/settings_page.dart` (full rewrite)
**Step 1: Write the new SettingsPage**
```dart
// lib/pages/settings_page.dart
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:pad_scanner/services/api_service.dart';
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
final _controller = TextEditingController();
final _apiService = ApiService();
bool _saving = false;
bool _testing = false;
@override
void initState() {
super.initState();
_loadUrl();
}
Future<void> _loadUrl() async {
final prefs = await SharedPreferences.getInstance();
final url = prefs.getString('api_url') ?? '';
_controller.text = url;
}
Future<void> _saveUrl() async {
final url = _controller.text.trim();
if (url.isEmpty) {
_showSnackBar('请输入 API 地址', isError: true);
return;
}
setState(() => _saving = true);
final prefs = await SharedPreferences.getInstance();
await prefs.setString('api_url', url);
setState(() => _saving = false);
if (mounted) {
_showSnackBar('设置已保存');
}
}
Future<void> _testConnection() async {
final url = _controller.text.trim();
if (url.isEmpty) {
_showSnackBar('请先输入 API 地址', isError: true);
return;
}
setState(() => _testing = true);
final ok = await _apiService.testConnection(url);
setState(() => _testing = false);
if (mounted) {
_showSnackBar(
ok ? '连接成功' : '连接失败,请检查地址和网络',
isError: !ok,
);
}
}
void _showSnackBar(String message, {bool isError = false}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: isError ? Colors.red.shade700 : Colors.green.shade700,
duration: const Duration(seconds: 2),
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('设置')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('API 服务器地址', style: TextStyle(fontSize: 14)),
const SizedBox(height: 8),
TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'http://192.168.1.100:8000',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.url,
),
const SizedBox(height: 20),
ElevatedButton.icon(
onPressed: _saving ? null : _saveUrl,
icon: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: const Text('保存设置'),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _testing ? null : _testConnection,
icon: _testing
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.wifi),
label: const Text('测试连接'),
),
],
),
),
);
}
}
```
**Step 2: Verify compilation**
Run: `flutter analyze lib/pages/settings_page.dart`
Expected: No issues
**Step 3: Commit**
```bash
git add lib/pages/settings_page.dart
git commit -m "feat: rewrite SettingsPage with save and test-connection"
```
---
### Task 4: RegistrationPage — main shelf registration UI
**Files:**
- Create: `lib/pages/registration_page.dart`
**Step 1: Write the RegistrationPage**
This is the main page. It handles:
- Scanner event stream subscription
- Barcode classification via CodeParser
- State management for zongpaiNo, locationCode, lock mode
- Submit flow with error handling
- PRD §8 layout
```dart
// lib/pages/registration_page.dart
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:pad_scanner/services/scanner_service.dart';
import 'package:pad_scanner/services/code_parser.dart';
import 'package:pad_scanner/services/api_service.dart';
import 'package:pad_scanner/pages/settings_page.dart';
class RegistrationPage extends StatefulWidget {
const RegistrationPage({super.key});
@override
State<RegistrationPage> createState() => _RegistrationPageState();
}
class _RegistrationPageState extends State<RegistrationPage> {
final _scannerService = ScannerService();
final _apiService = ApiService();
String? _zongpaiNo;
String? _locationCode;
CodeType? _locationType;
bool _isLocked = false;
bool _isSubmitting = false;
// Feedback state
String? _successMessage;
String? _snackbarMessage;
Color? _snackbarColor;
@override
void initState() {
super.initState();
_scannerService.scanResults.listen(_onScan);
}
void _onScan(ScanResult result) {
final parsed = CodeParser.parse(result.barcode);
switch (parsed.type) {
case CodeType.zongpaiNo:
setState(() {
_zongpaiNo = parsed.value;
_snackbarMessage = null;
_successMessage = null;
});
case CodeType.locationNormal:
case CodeType.locationTransit:
if (!_isLocked) {
setState(() {
_locationCode = parsed.value;
_locationType = parsed.type;
_snackbarMessage = null;
_successMessage = null;
});
}
case CodeType.invalid:
_showFeedback('无效码,请重新扫描', isError: true);
}
}
void _showFeedback(String message, {bool isError = false}) {
setState(() {
_snackbarMessage = message;
_snackbarColor = isError ? Colors.red.shade700 : Colors.green.shade700;
});
Future.delayed(const Duration(seconds: 2), () {
if (mounted) setState(() => _snackbarMessage = null);
});
}
String get _statusText {
if (_isSubmitting) return '正在提交…';
if (_successMessage != null) return _successMessage!;
if (_isLocked && _locationCode != null && _zongpaiNo == null) {
return '货位已锁定,请扫描下一张执行卡';
}
final hasZ = _zongpaiNo != null;
final hasL = _locationCode != null;
if (hasZ && hasL) return '请确认信息并提交';
if (hasZ && !hasL) return '请扫描目标货位号';
if (!hasZ && hasL) return '请扫描执行卡';
return '等待扫描总排号或货位号…';
}
bool get _canSubmit =>
_zongpaiNo != null &&
_locationCode != null &&
!_isSubmitting;
Future<void> _submit() async {
if (!_canSubmit) return;
final prefs = await SharedPreferences.getInstance();
final baseUrl = prefs.getString('api_url') ?? '';
if (baseUrl.isEmpty) {
_showFeedback('未配置 API 地址,请前往设置', isError: true);
return;
}
setState(() => _isSubmitting = true);
final result = await _apiService.registerLocation(
baseUrl: baseUrl,
zongpaiNo: _zongpaiNo!,
locationCode: _locationCode!,
);
if (!mounted) return;
setState(() => _isSubmitting = false);
if (result.success) {
setState(() {
_zongpaiNo = null;
if (!_isLocked) {
_locationCode = null;
_locationType = null;
}
_successMessage = _isLocked
? '货位已锁定,请扫描下一张执行卡'
: '上架成功';
});
_showFeedback(
_isLocked ? '上架成功' : '上架成功',
isError: false,
);
// Auto-clear success message
Future.delayed(const Duration(milliseconds: 1500), () {
if (mounted) setState(() => _successMessage = null);
});
} else if (result.isDuplicate) {
_showDuplicateDialog(result.duplicateInfo);
} else {
_showFeedback(result.errorMessage ?? '提交失败', isError: true);
}
}
void _showDuplicateDialog(Map<String, dynamic>? info) {
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: Colors.red.shade50,
title: const Row(
children: [
Icon(Icons.warning, color: Colors.red),
SizedBox(width: 8),
Text('重复上架', style: TextStyle(color: Colors.red)),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('总排号:${_zongpaiNo ?? ""}'),
const SizedBox(height: 4),
Text('已登记货位:${info?["location_code"] ?? "未知"}'),
const SizedBox(height: 4),
Text('登记时间:${info?["registered_at"] ?? "未知"}'),
const SizedBox(height: 12),
const Text('请核查实物,确认是否操作错误。', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('关闭'),
),
],
),
);
}
void _toggleLock(bool value) {
if (value && _locationCode == null) {
_showFeedback('请先扫描货位号', isError: true);
return;
}
setState(() => _isLocked = value);
}
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;
return Scaffold(
appBar: AppBar(
title: const Text('上架登记'),
actions: [
// Lock toggle in app bar
Padding(
padding: const EdgeInsets.only(right: 4),
child: Row(
children: [
const Text('锁定货位', style: TextStyle(fontSize: 13)),
Switch(
value: _isLocked,
onChanged: _toggleLock,
),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsPage()),
),
),
],
),
),
],
),
body: Column(
children: [
// Feedback snackbar area
if (_snackbarMessage != null)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
color: _snackbarColor,
child: Text(
_snackbarMessage!,
style: const TextStyle(color: Colors.white, fontSize: 14),
textAlign: TextAlign.center,
),
),
// Main form area
Expanded(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Zongpai number field
const Align(
alignment: Alignment.centerLeft,
child: Text('总排号', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
),
const SizedBox(height: 6),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: BoxDecoration(
border: Border.all(
color: _zongpaiNo != null ? Colors.green : Colors.grey.shade400,
width: _zongpaiNo != null ? 2 : 1,
),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_zongpaiNo ?? '',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: _zongpaiNo != null ? Colors.black87 : Colors.grey,
),
),
),
const SizedBox(height: 20),
// Location field
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: 28),
// 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,
),
)
: const Text('确 认 上 架', style: TextStyle(fontSize: 18)),
),
),
],
),
),
),
// Status bar at bottom
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
color: colorScheme.surfaceContainerHighest,
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: _isSubmitting
? Colors.orange
: _successMessage != null
? Colors.green
: Colors.blue,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
_statusText,
style: const TextStyle(fontSize: 14),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
);
}
}
```
**Step 2: Verify compilation**
Run: `flutter analyze lib/pages/registration_page.dart`
Expected: No issues
**Step 3: Commit**
```bash
git add lib/pages/registration_page.dart
git commit -m "feat: add RegistrationPage with single and lock-mode shelf registration"
```
---
### Task 5: main.dart — wire up RegistrationPage as home
**Files:**
- Modify: `lib/main.dart`
- Delete: `lib/pages/scan_page.dart`
**Step 1: Update main.dart**
```dart
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:pad_scanner/pages/registration_page.dart';
void main() {
runApp(const PadScannerApp());
}
class PadScannerApp extends StatelessWidget {
const PadScannerApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '上架登记',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const RegistrationPage(),
);
}
}
```
**Step 2: Delete old ScanPage**
```bash
git rm lib/pages/scan_page.dart
```
**Step 3: Verify full project compiles**
Run: `flutter analyze`
Expected: No issues
**Step 4: Commit**
```bash
git add lib/main.dart
git commit -m "feat: wire RegistrationPage as app home, remove ScanPage"
```
---
### Task 6: Final verification
**Step 1: Run all tests**
Run: `flutter test`
Expected: All tests pass
**Step 2: Run full analysis**
Run: `flutter analyze`
Expected: No issues found
**Step 3: Verify no dangling imports**
Search for any remaining references to `scan_page.dart` or `scan_record.dart`:
Run: `grep -r "scan_page\|scan_record" lib/`
Expected: No matches

View File

@@ -0,0 +1,38 @@
# 铃声配置功能设计
## 概述
在设置页新增铃声配置入口,用户可以为"上架成功"和"上架失败"分别指定本地音频文件作为提示铃声。
## 依赖
- `file_picker` — 从设备选择音频文件(支持 mp3/wav/ogg/aac/m4a/flac
- `audioplayers` — 播放本地音频文件
## 数据存储
使用 `SharedPreferences`
- `sound_success` — 上架成功铃声文件路径
- `sound_failure` — 上架失败铃声文件路径
## UI 设计
设置页新增"铃声设置"分区API 地址下方:
```
铃声设置
─────────────────────────────
上架成功铃声
[当前文件名或"未设置"] [选择] [试听] [清除]
上架失败铃声
[当前文件名或"未设置"] [选择] [试听] [清除]
```
## 音频播放逻辑
- 封装为 `SoundService`,统一管理播放
- 通过 `DeviceFileSource` 播放本地文件
- 上架成功/失败时检查是否配置了铃声,有则播放
- 保留现有震动反馈,铃声与震动并行
## 涉及文件
1. `pubspec.yaml` — 添加依赖
2. `lib/services/sound_service.dart` — 新建,封装音频播放
3. `lib/pages/settings_page.dart` — 新增铃声配置 UI
4. `lib/pages/registration_page.dart` — 集成铃声播放

View File

@@ -0,0 +1,519 @@
# 装箱编号模块 Implementation Plan — Flutter
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 实现 PRD v1.3 中定义的装箱编号模块 Flutter 页面,支持三种装箱模式(一码一箱、一码多箱、多码一箱),包含扫码识别、信息查询、自动填充、重复校验和提交功能。
**Architecture:** 在现有 Flutter 应用基础上扩展,使用 setState 管理 UI 状态EventChannel 接收扫码数据http 包调用后端 API。新增 BoxingPage 和 BoxingDetailPage 两个页面。
**Tech Stack:** Flutter 3.x, Dart, http, shared_preferences, Android native EventChannel (SEUIC scanner)
---
## 页面结构
```
HomePage
├── 上架登记 (RegistrationPage) ← 已完成
├── 装箱编号 (BoxingPage) ← 本次实现
│ └── 装箱详情 (BoxingDetailPage) ← 子页面
└── 货架查询 ← 规划中
```
## 状态机设计
三种装箱模式对应不同的状态流转:
```
一码一箱: 等待扫码 → 扫码后自动填充箱号+数量 → 确认 → 成功(1.5s) → 重置等待扫码
一码多箱: 等待扫码 → 扫码后自动填充箱号 → 手动填数量 → 确认 → 成功
→ "继续添加": 预填箱号+1、预填上次数量 → 确认 → ...
→ "返回": 重置等待扫码
多码一箱: 等待扫码 → 扫码后填充+锁定箱号+数量 → 确认 → 成功
→ 自动等待下一个扫码 → 箱号锁定 → 自动填充新数量 → 确认 → ...
→ "返回": 重置等待扫码
```
---
### Task 1: ApiService 扩展 — 装箱 API 方法
**Files:**
- Modify: `lib/services/api_service.dart`
**Step 1: 添加装箱相关的数据类和 API 方法**
在文件末尾(`ApiService` 类内部)新增以下方法,在文件顶部新增数据类:
```dart
// === 装箱模块数据类 ===
/// 装箱信息查询结果
class BoxInfoResult {
final bool success;
final String? errorMessage;
final String? errorCode;
final String? zongpaiNo;
final String? paichanNo;
final int? quantity;
final List<BoxDetailData> existingBoxes;
final int maxBoxNo;
final int suggestedBoxNo;
BoxInfoResult({
required this.success,
this.errorMessage,
this.errorCode,
this.zongpaiNo,
this.paichanNo,
this.quantity,
this.existingBoxes = const [],
this.maxBoxNo = 0,
this.suggestedBoxNo = 1,
});
factory BoxInfoResult.ok(Map<String, dynamic> json) {
final boxes = (json['existing_boxes'] as List<dynamic>?)
?.map((b) => BoxDetailData.fromJson(b as Map<String, dynamic>))
.toList() ??
[];
return BoxInfoResult(
success: true,
zongpaiNo: json['zongpai_no'] as String?,
paichanNo: json['paichan_no'] as String?,
quantity: json['quantity'] as int?,
existingBoxes: boxes,
maxBoxNo: json['max_box_no'] as int? ?? 0,
suggestedBoxNo: json['suggested_box_no'] as int? ?? 1,
);
}
factory BoxInfoResult.error(String message, {String? errorCode}) {
return BoxInfoResult(success: false, errorMessage: message, errorCode: errorCode);
}
}
/// 箱号明细
class BoxDetailData {
final int boxNo;
final List<BoxItemData> items;
BoxDetailData({required this.boxNo, required this.items});
factory BoxDetailData.fromJson(Map<String, dynamic> json) {
final items = (json['items'] as List<dynamic>?)
?.map((i) => BoxItemData.fromJson(i as Map<String, dynamic>))
.toList() ??
[];
return BoxDetailData(boxNo: json['box_no'] as int, items: items);
}
}
/// 箱号内单个总排号明细
class BoxItemData {
final String zongpaiNo;
final int quantity;
BoxItemData({required this.zongpaiNo, required this.quantity});
factory BoxItemData.fromJson(Map<String, dynamic> json) {
return BoxItemData(
zongpaiNo: json['zongpai_no'] as String,
quantity: json['quantity'] as int,
);
}
}
/// 装箱保存结果
class BoxSaveResult {
final bool success;
final bool isDuplicate;
final String? errorMessage;
final String? paichanNo;
final int? boxNo;
BoxSaveResult({
required this.success,
this.isDuplicate = false,
this.errorMessage,
this.paichanNo,
this.boxNo,
});
factory BoxSaveResult.ok(Map<String, dynamic> json) {
return BoxSaveResult(
success: true,
paichanNo: json['paichan_no'] as String?,
boxNo: json['box_no'] as int?,
);
}
factory BoxSaveResult.duplicate(Map<String, dynamic> json) {
return BoxSaveResult(
success: false,
isDuplicate: true,
paichanNo: json['paichan_no'] as String?,
boxNo: json['box_no'] as int?,
);
}
factory BoxSaveResult.error(String message) {
return BoxSaveResult(success: false, errorMessage: message);
}
}
```
`ApiService` 类内新增两个方法:
```dart
/// 查询装箱信息 — GET /CargoTrace/box/info
Future<BoxInfoResult> fetchBoxInfo({
required String baseUrl,
required String zongpaiNo,
}) async {
final uri = Uri.parse('$baseUrl/CargoTrace/box/info').replace(
queryParameters: {'zongpai_no': zongpaiNo},
);
try {
final response = await _client.get(uri, headers: {
'Content-Type': 'application/json',
}).timeout(timeout);
switch (response.statusCode) {
case 200:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return BoxInfoResult.ok(body);
case 400:
final body = jsonDecode(response.body) as Map<String, dynamic>;
final errorCode = body['error_code']?.toString() ?? '';
return BoxInfoResult.error(
_errorMessage(errorCode),
errorCode: errorCode,
);
case 404:
return BoxInfoResult.error('未找到该总排号对应的排产号信息');
default:
return BoxInfoResult.error('查询失败 (${response.statusCode})');
}
} catch (e) {
return BoxInfoResult.error('网络异常,请检查网络连接');
}
}
/// 保存装箱记录 — POST /CargoTrace/box
Future<BoxSaveResult> saveBoxRecord({
required String baseUrl,
required String zongpaiNo,
required int boxNo,
required int quantity,
}) async {
final uri = Uri.parse('$baseUrl/CargoTrace/box');
try {
final response = await _client
.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'zongpai_no': zongpaiNo,
'box_no': boxNo,
'quantity': quantity,
}),
)
.timeout(timeout);
switch (response.statusCode) {
case 200:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return BoxSaveResult.ok(body);
case 400:
final body = jsonDecode(response.body) as Map<String, dynamic>;
final msg = body['message']?.toString() ?? '请求参数错误';
return BoxSaveResult.error(msg);
case 409:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return BoxSaveResult.duplicate(body);
case 404:
return BoxSaveResult.error('未找到该总排号对应的排产号信息');
default:
return BoxSaveResult.error('提交失败 (${response.statusCode})');
}
} catch (e) {
return BoxSaveResult.error('网络异常,请检查网络连接');
}
}
String _errorMessage(String errorCode) {
switch (errorCode) {
case 'INVALID_ZONGPAI':
return '无效的总排号格式';
default:
return '请求参数错误';
}
}
```
**Step 2: 验证编译**
Run: `flutter analyze lib/services/api_service.dart`
**Step 3: Commit**
```bash
git add lib/services/api_service.dart
git commit -m "feat: add box info query and save methods to ApiService"
```
---
### Task 2: BoxingDetailPage — 装箱详情页
**Files:**
- Create: `lib/pages/boxing_detail_page.dart`
**Step 1: 实现详情页**
只读页面展示排产号下的完整装箱明细PRD §7.3)。
```dart
import 'package:flutter/material.dart';
import 'package:pad_scanner/services/api_service.dart';
class BoxingDetailPage extends StatelessWidget {
final String paichanNo;
final List<BoxDetailData> existingBoxes;
const BoxingDetailPage({
super.key,
required this.paichanNo,
required this.existingBoxes,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Row(
children: [
const Text('装箱详情'),
const SizedBox(width: 12),
Text(
paichanNo,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
),
],
),
),
body: Column(
children: [
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: existingBoxes.length,
itemBuilder: (context, index) {
final box = existingBoxes[index];
return _buildBoxGroup(context, box);
},
),
),
// 底部汇总
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Text(
'${existingBoxes.length}',
style: const TextStyle(fontSize: 14),
),
),
],
),
);
}
Widget _buildBoxGroup(BuildContext context, BoxDetailData box) {
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),
),
const SizedBox(height: 4),
Container(
width: double.infinity,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(6),
),
child: Column(
children: box.items.map((item) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(item.zongpaiNo, style: const TextStyle(fontSize: 14)),
Text('数量:${item.quantity}', style: const TextStyle(fontSize: 14)),
],
),
);
}).toList(),
),
),
],
),
);
}
}
```
**Step 2: 验证编译**
Run: `flutter analyze lib/pages/boxing_detail_page.dart`
**Step 3: Commit**
```bash
git add lib/pages/boxing_detail_page.dart
git commit -m "feat: add BoxingDetailPage for boxing record details"
```
---
### Task 3: BoxingPage — 装箱编号主页面
**Files:**
- Create: `lib/pages/boxing_page.dart`
这是核心页面,实现 PRD §7 中的所有交互逻辑。
**Step 1: 实现装箱模式枚举和页面**
核心状态变量:
- `BoxingMode _mode` — 当前装箱模式
- `_BoxingPhase _phase` — 当前阶段waiting / scanned / submitted
- `String? _zongpaiNo` — 当前总排号
- `String? _paichanNo` — 排产号
- `int? _erpQuantity` — ERP 中的数量
- `List<BoxDetailData> _existingBoxes` — 已有箱号明细
- `int _maxBoxNo` — 最大箱号
- `TextEditingController _boxNoController` — 箱号输入
- `TextEditingController _quantityController` — 数量输入
- `bool _boxNoLocked` — 箱号是否锁定(多码一箱)
- `int? _lastBoxNo` / `int? _lastQuantity` — 上次提交的值(一码多箱用)
页面结构:
```
┌──────────────────────────────────┐
│ 装箱编号 [一码一箱 ○] │ ← AppBar + 模式切换
├──────────────────────────────────┤
│ 提示条区域 (错误/警告) │ ← 红色/amber/yellow 提示条
├──────────────────────────────────┤
│ ✓ 26BW0011 已识别 │ ← 扫码区
│ │
│ 排产号: W00009 │ ← 信息区
│ 已有箱数3箱 最大箱号3 │
│ [详情 →] │
│ ─────────────────────────────── │
│ 箱号[ 4 ] 数量[ 80 ] [确认] │ ← 输入区
│ │
├──────────────────────────────────┤
│ [返回] [继续添加] │ ← 操作栏(一码多箱/多码一箱)
├──────────────────────────────────┤
│ ● 等待扫码 │ ← 状态栏
└──────────────────────────────────┘
```
**扫码处理逻辑_onScan**
1. 解析码值 → 只接受 `CodeType.zongpaiNo`
2. 调用 `fetchBoxInfo` 查询后端
3. 成功后根据模式填充:
- 一码一箱:箱号 = suggested_box_no数量 = erp_quantity
- 一码多箱:箱号 = suggested_box_no数量为空
- 多码一箱(首次):箱号 = suggested_box_no数量 = erp_quantity箱号锁定
- 多码一箱(后续):箱号保持锁定,数量 = 新总排号的 erp_quantity
**重复箱号检测(本地):**
- 当箱号值变化时,比对 `_existingBoxes` 中是否已存在该箱号
- 重复时amber 边框 + 警告条 + 禁用确认按钮
**提交逻辑_submit**
1. 调用 `saveBoxRecord` 保存
2. 成功后根据模式处理后续:
- 一码一箱:显示成功 1.5s → 重置
- 一码多箱:记录 lastBoxNo/lastQuantity → 等待"继续添加"
- 多码一箱:保留箱号锁定 → 等待下一个扫码
**Step 2: 验证编译**
Run: `flutter analyze lib/pages/boxing_page.dart`
**Step 3: Commit**
```bash
git add lib/pages/boxing_page.dart
git commit -m "feat: add BoxingPage with three boxing modes"
```
---
### Task 4: HomePage 更新 — 接入装箱模块
**Files:**
- Modify: `lib/pages/home_page.dart`
**Step 1: 更新装箱模块状态**
`boxing` FeatureCard 的 `status``ModuleStatus.developing` 改为 `ModuleStatus.online`,设置 `version: 'v1.0'`
**Step 2: 更新导航逻辑**
`_navigateToModule``ModuleType.boxing` case 中:
- 添加 `import 'package:pad_scanner/pages/boxing_page.dart';`
- 替换 placeholder 为 `targetPage = const BoxingPage();`
**Step 3: 验证编译**
Run: `flutter analyze`
**Step 4: Commit**
```bash
git add lib/pages/home_page.dart
git commit -m "feat: activate boxing module in HomePage"
```
---
### Task 5: 最终验证
**Step 1: 全量分析**
Run: `flutter analyze`
Expected: No issues found
**Step 2: 运行测试**
Run: `flutter test`
Expected: All tests pass
**Step 3: 检查无遗漏引用**
确认 `BoxingPage` 已正确从 `HomePage` 导航访问。
---
## 文件变更汇总
| 操作 | 文件 | 说明 |
|------|------|------|
| Modify | `lib/services/api_service.dart` | 新增装箱 API 方法和数据类 |
| Create | `lib/pages/boxing_detail_page.dart` | 装箱详情只读页 |
| Create | `lib/pages/boxing_page.dart` | 装箱编号主页面(三种模式) |
| Modify | `lib/pages/home_page.dart` | 激活装箱模块导航 |

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,310 @@
# PRD · 模块一:上架登记
版本v1.0
状态:待评审
适用端:安卓扫码枪 Flutter 应用
---
## 1. 功能概述
上架登记模块是成品库房作业流程的第二步。工人完成分堆后,需要将每张执行卡对应的产品登记到具体的货位上。系统记录总排号与货位号的绑定关系,为后续的货架查询、清点和装箱编号提供数据基础。
本模块支持两种操作场景:
- **单次上架**:每次绑定一个总排号到一个货位;
- **连续上架(锁定模式)**:锁定一个货位后,连续扫描多个总排号,批量完成绑定。
---
## 2. 用户与使用场景
**使用人员**:成品库房工人
**典型场景**
|场景|描述|
|---|---|
|普通上架|工人将某个执行卡对应的产品放到货架上,扫码登记货架货位。|
|转运上架|工人判断某个产品可以直接进入转运流程,扫码登记转运特殊货位。|
|批量上架|同一批次多个执行卡产品放到同一货架位,锁定货位后连续扫执行卡。|
---
## 3. 设备能力约定
|项目|说明|
|---|---|
|扫码输出方式|广播输出Android Intent|
|屏幕尺寸|3.5 英寸|
|键盘|31 键实体键盘|
|网络|WiFi / 蓝牙 / 蜂窝,三种模式|
|反馈方式|声音、振动、LED 灯、屏幕显示|
Flutter 层通过 `EventChannel` 接收原生广播,不依赖输入框焦点捕获。
---
## 4. 码值识别规则
系统根据扫入码值的格式自动判断类型,不强制要求扫码顺序。
|码值类型|识别规则|示例|
|---|---|---|
|总排号|正则 `^\d{2}(B\|C\|T)\d+$\|^\d{2}(BW\|CW)\d{4}$`,温度计(BW/CW)流水号必须固定4位数字其余类型不补零|`26B1``26C12``26BW0001``26CW0015``26T3`|
|排产号|正则 `^[A-Z]{1,2}\d{5}(-J)?$`(当前模块暂不使用,预留)|`R00001``CY00012``R06936-J`|
|普通货位号|格式为 `[区域货架]-[层]-[格]`,全大写,横杠分隔|`A01-02-03`|
|转运特殊货位号|以 `TRANS-` 开头|`TRANS-01`|
|无效码|不符合以上任何规则|—|
**重复扫码规则**:在同一次提交动作完成前,如果同一类型的码被多次扫入,取最后一次扫入的值作为有效值,前一次自动覆盖。
---
## 5. 功能详细说明
### 5.1 单次上架模式
#### 5.1.1 进入方式
应用启动后默认进入单次上架模式。
#### 5.1.2 操作流程
```mermaid
flowchart TD
A([进入上架登记页]) --> B[等待扫码\n总排号或货位号顺序不限]
B --> C{识别码值类型}
C -->|总排号格式| D[填入总排号字段]
C -->|货位号格式| E[填入货位号字段]
C -->|无效码| F[提示无效码\n不影响已有字段]
F --> B
D -->|重复扫入同类型码| D
E -->|重复扫入同类型码| E
D --> G{两个字段\n是否均已填入}
E --> G
G -->|否| B
G -->|是| H[确认按钮高亮\n工人核查信息]
H --> I[按确认键提交]
I --> J[调用后端接口]
J --> K{结果}
K -->|成功| L[清空页面\n成功反馈 1.5s\n等待下一次扫码]
K -->|重复上架| M[错误弹窗\n显示已有货位与时间\n需手动关闭]
K -->|网络异常| N[保留数据\n提示检查网络\n可直接重试]
K -->|其他错误| O[保留数据\n显示错误描述]
L --> B
M --> B
N --> I
```
#### 5.1.3 提交前校验
|校验项|规则|处理方式|
|---|---|---|
|总排号格式|必须符合 `两位年份+类型字母(B/C/BW/CW/T)+流水号` 格式温度计类型流水号必须为4位|扫入时即提示"无效码"|
|货位号格式|必须符合普通货位或转运特殊货位规则|扫入时即提示"无效码"|
|两个字段均已填入|确认按钮才可点击|按钮置灰,无法提交|
|总排号已存在货位记录|后端返回冲突错误|显示错误提示,见 §6.2|
#### 5.1.4 提交成功后的状态
- 清空总排号字段和货位号字段;
- 页面回到初始等待扫码状态;
- 成功反馈持续约 1.5 秒后自动消失;
- 货位锁定模式未开启时,货位号字段也一并清空。
---
### 5.2 连续上架模式(锁定货位)
#### 5.2.1 进入方式
页面上提供"锁定货位"切换控件Toggle。工人在货位号字段已填入有效值时可以开启锁定模式。
#### 5.2.2 锁定模式行为
|行为|说明|
|---|---|
|货位号字段锁定|货位号字段显示为锁定状态,新扫入的货位号码值不会覆盖当前货位号|
|总排号字段正常工作|每次扫入总排号后,填入总排号字段,等待工人确认|
|确认提交|提交成功后,仅清空总排号字段,货位号保持不变,页面立即进入等待下一个总排号状态|
|解除锁定|工人再次点击控件解除锁定,货位号字段恢复可覆盖状态|
#### 5.2.3 锁定模式操作流程
```mermaid
flowchart TD
A([进入上架登记页]) --> B[扫入有效货位号]
B --> C[开启锁定货位 Toggle]
C --> D[货位号字段锁定\n新扫入货位码不覆盖]
D --> E[扫入总排号]
E --> F{总排号\n是否有效}
F -->|无效| G[提示无效码] --> E
F -->|有效| H[填入总排号字段]
H --> I[工人核查信息\n按确认键提交]
I --> J[调用后端接口]
J --> K{结果}
K -->|成功| L[仅清空总排号字段\n货位号保持锁定]
K -->|重复上架| M[错误弹窗\n需手动关闭]
K -->|失败| N[保留数据\n显示错误]
L --> O{继续\n锁定模式?}
M --> O
N --> O
O -->|是| E
O -->|解除锁定| P[关闭 Toggle\n货位号字段恢复可覆盖]
P --> Q([返回单次上架模式])
```
---
## 6. 异常与错误处理
### 6.1 无效码
扫入码值无法识别为总排号或货位号时:
- 屏幕顶部短暂显示红色提示条:"无效码,请重新扫描"
- 当前已填入的有效字段不受影响;
- 触发错误音效。
### 6.2 总排号重复上架(核心错误)
后端返回该总排号已存在货位记录:
- 页面显示明显的错误提示弹窗,内容包含:
- 当前总排号;
- 该总排号已登记的货位号;
- 登记时间;
- 提示工人核查实物,确认是否发生了错误操作;
- 弹窗需要工人主动关闭,不自动消失;
- 当前页面数据保留,等待工人决定下一步操作;
- 触发与普通失败不同的错误音效(更明显)。
> 设计说明:重复上架在正常业务中不应发生。一旦出现,说明现场可能有问题(如同一产品被两人同时操作、扫错执行卡等),需要工人停下来核查,不应允许静默通过。
### 6.3 网络异常
接口请求超时或网络不可达:
- 屏幕显示"网络异常,请检查网络连接"
- 当前已填入的数据保留;
- 工人可在网络恢复后直接重试,无需重新扫码;
- 触发失败音效。
### 6.4 后端其他错误
后端返回其他业务错误:
- 显示后端返回的错误描述(需后端统一错误格式);
- 当前数据保留,等待工人处理。
---
## 7. 反馈机制
|事件|屏幕|声音|振动|LED|
|---|---|---|---|---|
|扫入有效总排号|总排号字段填入,绿色高亮|短促提示音|短震|—|
|扫入有效货位号|货位号字段填入,绿色高亮|短促提示音|短震|—|
|扫入无效码|红色提示条|错误音|短震|红色闪烁|
|提交成功|全屏绿色提示 1.5 秒|成功音|长震|绿色常亮 1.5 秒|
|重复上架错误|红色错误弹窗(需手动关闭)|连续错误音|连续震|红色闪烁|
|网络异常|黄色提示条|失败音|短震|—|
---
## 8. 页面结构
### 8.1 主页面布局
```
┌──────────────────────────────┐
│ 上架登记 [锁定货位 ○] │ ← 顶部标题栏 + 锁定控件
├──────────────────────────────┤
│ │
│ 总排号 │
│ ┌────────────────────────┐ │
│ │ 26B1 │ │ ← 已扫入时绿色边框
│ └────────────────────────┘ │
│ │
│ 目标货位 │
│ ┌────────────────────────┐ │
│ │ A01-02-03 [普通货架] │ │ ← 货位类型标签
│ └────────────────────────┘ │ ← 锁定时显示锁图标
│ │
│ ┌────────────────────────┐ │
│ │ 确 认 上 架 │ │ ← 两个字段均已填入时高亮
│ └────────────────────────┘ │
│ │
├──────────────────────────────┤
│ ● 等待扫描总排号... │ ← 当前状态提示
└──────────────────────────────┘
```
### 8.2 状态提示栏文案
|当前状态|提示文案|
|---|---|
|两个字段均为空|等待扫描总排号或货位号…|
|仅总排号已填入|请扫描目标货位号|
|仅货位号已填入|请扫描执行卡|
|两个字段均已填入|请确认信息并提交|
|提交中|正在提交…|
|锁定模式下提交成功|货位已锁定,请扫描下一张执行卡|
### 8.3 货位类型标签
|货位类型|标签文字|标签颜色|
|---|---|---|
|普通货架货位|普通货架|蓝色|
|转运特殊货位|转运区域|橙色|
---
## 9. 接口依赖
### 9.1 上架登记接口
**POST** `/CargoTrace/location`
请求体:
```json
{
"zongpai_no": "26B1",
"location_code": "A01-02-03"
}
```
成功响应:`200 OK`
失败响应:
|HTTP 状态码|错误码|含义|
|---|---|---|
|409 Conflict|`DUPLICATE_LOCATION`|该总排号已存在货位记录|
|400 Bad Request|`INVALID_ZONGPAI`|总排号不存在|
|400 Bad Request|`INVALID_LOCATION`|货位号不合法|
|500|`SERVER_ERROR`|服务器内部错误|
---
## 10. 非功能要求
|项目|要求|
|---|---|
|接口响应时间|正常网络下 ≤ 500ms|
|扫码到字段填入延迟|≤ 100ms本地处理无网络请求|
|离线处理策略|断网时不允许提交,提示工人检查网络,数据保留在页面上|
|货位类型本地判断|货位类型(普通/转运)由客户端根据编码规则本地判断,不依赖接口|
---
## 11. 超出当前版本范围
以下内容在当前版本中不实现:
- 操作日志与操作人员记录(登录功能待定);
- 重复上架的强制覆盖功能;
- 离线缓存与联网同步;
- 总排号货位变更历史查询。

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`)的布局不做调整。
- 字体、主题色、品牌色系不做变更。
- 横屏/平板适配留作后续专项。

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:pad_scanner/pages/scan_page.dart';
import 'package:pad_scanner/pages/home_page.dart';
void main() {
runApp(const PadScannerApp());
@@ -11,12 +11,12 @@ class PadScannerApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'PAD Scanner',
title: 'CargoTrace',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const ScanPage(),
home: const HomePage(),
);
}
}

View File

@@ -1,21 +0,0 @@
enum SendStatus { pending, success, failed }
class ScanRecord {
final String barcode;
final String codeType;
final DateTime timestamp;
SendStatus status;
ScanRecord({
required this.barcode,
required this.codeType,
required this.timestamp,
this.status = SendStatus.pending,
});
Map<String, dynamic> toJson() => {
'barcode': barcode,
'code_type': codeType,
'timestamp': timestamp.toUtc().toIso8601String(),
};
}

View File

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

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

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

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

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

View File

@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:pad_scanner/services/api_service.dart';
class BoxingDetailPage extends StatelessWidget {
final String paichanNo;
final List<BoxDetailData> existingBoxes;
const BoxingDetailPage({
super.key,
required this.paichanNo,
required this.existingBoxes,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Row(
children: [
const Text('装箱详情'),
const SizedBox(width: 12),
Expanded(
child: Text(
paichanNo,
textAlign: TextAlign.end,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
body: Column(
children: [
Expanded(
child: existingBoxes.isEmpty
? const Center(child: Text('暂无装箱记录'))
: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: existingBoxes.length,
itemBuilder: (context, index) {
return _buildBoxGroup(context, existingBoxes[index]);
},
),
),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Text(
'${existingBoxes.length}',
style: const TextStyle(fontSize: 14),
),
),
],
),
);
}
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: [
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(
width: double.infinity,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(6),
),
child: Column(
children: box.items.map((item) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Expanded(
flex: 3,
child: Text(
item.zongpaiNo,
style: const TextStyle(fontSize: 14),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Expanded(
flex: 2,
child: Text(
item.workOrderNo ?? '--',
style: const TextStyle(fontSize: 14),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Text(
'${item.quantity}/${item.totalQuantity ?? '--'}',
style: const TextStyle(fontSize: 14),
),
],
),
);
}).toList(),
),
),
],
),
);
}
}

478
lib/pages/boxing_page.dart Normal file
View File

@@ -0,0 +1,478 @@
// ignore_for_file: unnecessary_this
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pad_scanner/services/scanner_service.dart';
import 'package:pad_scanner/services/code_parser.dart';
import 'package:pad_scanner/services/api_service.dart';
import 'package:pad_scanner/services/boxing_context.dart';
import 'package:pad_scanner/services/feedback_service.dart';
import 'package:pad_scanner/pages/boxing/boxing_api_actions.dart';
import 'package:pad_scanner/pages/boxing/boxing_box_mutations.dart'
as box_mutations;
import 'package:pad_scanner/pages/boxing/boxing_calculations.dart'
as boxing_calculations;
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
import 'package:pad_scanner/pages/boxing/boxing_status_presenter.dart';
import 'package:pad_scanner/pages/boxing/dialogs/cross_paichan_dialog.dart';
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
import 'package:pad_scanner/pages/boxing/widgets/boxing_notice_banners.dart';
import 'package:pad_scanner/pages/boxing/widgets/multi_code_body.dart';
import 'package:pad_scanner/pages/boxing/widgets/single_code_body.dart';
import 'package:pad_scanner/pages/boxing_detail_page.dart';
import 'package:pad_scanner/widgets/status_bar.dart';
export 'package:pad_scanner/pages/boxing/boxing_models.dart'
show BoxingMode, BoxingPageArguments;
part 'boxing/parts/boxing_box_mutation_part.dart';
part 'boxing/parts/boxing_multi_ops_part.dart';
part 'boxing/parts/boxing_scan_part.dart';
part 'boxing/parts/boxing_single_ops_part.dart';
part 'boxing/parts/boxing_status_part.dart';
part 'boxing/parts/boxing_submit_part.dart';
// === 主页面 ===
class BoxingPage extends StatefulWidget {
final BoxingPageArguments? arguments;
const BoxingPage({super.key, this.arguments});
@override
State<BoxingPage> createState() => _BoxingPageState();
}
class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
final _scannerService = ScannerService();
final _apiService = ApiService();
late final BoxingApiActions _apiActions;
final _feedbackService = FeedbackService();
final _focusNode = FocusNode();
StreamSubscription<ScanResult>? _scanSubscription;
// 模式
BoxingMode _mode = BoxingMode.singleCode;
// 阶段
BoxingPhase _phase = BoxingPhase.waiting;
// 当前总排号
String? _zongpaiNo;
// 排产号信息(来自后端查询)
String? _paichanNo;
String? _workOrderNo;
int? _erpQuantity;
List<CurrentZongpaiBoxData> _currentZongpaiBoxes = [];
List<BoxDetailData> _existingBoxes = [];
int _maxBoxNo = 0;
// 输入
final _boxNoController = TextEditingController();
final _quantityController = TextEditingController();
final _boxNoFocusNode = FocusNode();
final _quantityFocusNode = FocusNode();
// 多码凑箱:本次操作已确认装入当前箱的明细
final _manyToOnePackedItems = <ManyToOnePackedItem>[];
int? _editingPackedItemId;
String _editingPackedQuantity = '';
int? _deletingPackedItemId;
int? _editingAssignedItemId;
String _editingAssignedQuantity = '';
int? _deletingAssignedItemId;
String? _lastScannedPaichanNo;
String? _paichanSwitchNotice;
bool _crossPaichaPending = false;
// 提交中
bool _isSubmitting = false;
CurrentZongpaiBoxData? _editingBox;
// 重复箱号
bool _isDuplicateBoxNo = false;
// Status bar state
StatusDotColor _statusDot = StatusDotColor.blue;
String _statusText = '等待扫码';
String? _statusOverrideText;
StatusDotColor? _statusOverrideDot;
bool _isAutoProcessing = false;
final _autoWarnings = <String>[];
// 多码凑箱:已完成装箱时可跳转的箱号
int? _completedJumpBoxNo;
@override
void initState() {
super.initState();
_apiActions = BoxingApiActions(apiService: _apiService);
WidgetsBinding.instance.addObserver(this);
final arguments = widget.arguments;
if (arguments != null) {
_mode = arguments.initialMode;
}
_boxNoController.addListener(_onBoxNoTextChanged);
_scanSubscription = _scannerService.scanResults.listen(
(result) => this._onScan(result),
);
if (arguments != null && arguments.autoScanCodes.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
this._processAutoScanCodes(arguments.autoScanCodes);
});
}
WidgetsBinding.instance.addPostFrameCallback((_) => _requestFocus());
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_boxNoController.removeListener(_onBoxNoTextChanged);
_scanSubscription?.cancel();
_focusNode.dispose();
_boxNoController.dispose();
_quantityController.dispose();
_boxNoFocusNode.dispose();
_quantityFocusNode.dispose();
_feedbackService.dispose();
super.dispose();
}
void _requestFocus() {
if (mounted) {
_focusNode.requestFocus();
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_requestFocus();
}
}
void _onKeyEvent(KeyEvent event) {
if (event is! KeyDownEvent || _isAutoProcessing) return;
if (_isModeSwitchKey(event)) {
_cycleMode();
}
if (_isFinishBoxKey(event)) {
final hasItems = _visibleManyToOnePackedItems.isNotEmpty;
if (hasItems && _mode == BoxingMode.multiCode) {
this._finishManyToOneBox();
}
}
if (_completedJumpBoxNo != null && _isJumpToBoxKey(event)) {
this._jumpToCompletedBox();
}
}
bool _isModeSwitchKey(KeyEvent event) {
final key = event.logicalKey;
return key == LogicalKeyboardKey.select ||
key == LogicalKeyboardKey.gameButtonRight1;
}
bool _isFinishBoxKey(KeyEvent event) {
return event.logicalKey == LogicalKeyboardKey.gameButtonLeft2;
}
bool _isJumpToBoxKey(KeyEvent event) {
return event.logicalKey == LogicalKeyboardKey.gameButtonRight2;
}
void _onBoxNoTextChanged() {
if (!mounted || _mode != BoxingMode.multiCode) return;
setState(() {
_editingPackedItemId = null;
_editingPackedQuantity = '';
_deletingPackedItemId = null;
});
}
// === 模式切换 ===
String get _modeLabel {
switch (_mode) {
case BoxingMode.singleCode:
return '单码装箱';
case BoxingMode.multiCode:
return '多码凑箱';
}
}
void _cycleMode() {
_feedbackService.trigger(FeedbackEvent.modeSwitch);
setState(() {
switch (_mode) {
case BoxingMode.singleCode:
_mode = BoxingMode.multiCode;
case BoxingMode.multiCode:
_mode = BoxingMode.singleCode;
}
_resetState();
});
}
void _resetState() {
_phase = BoxingPhase.waiting;
_zongpaiNo = null;
_paichanNo = null;
_workOrderNo = null;
_erpQuantity = null;
_currentZongpaiBoxes = [];
_existingBoxes = [];
_maxBoxNo = 0;
_boxNoController.clear();
_quantityController.clear();
_manyToOnePackedItems.clear();
_editingPackedItemId = null;
_editingPackedQuantity = '';
_deletingPackedItemId = null;
_editingAssignedItemId = null;
_editingAssignedQuantity = '';
_deletingAssignedItemId = null;
_lastScannedPaichanNo = null;
_paichanSwitchNotice = null;
_crossPaichaPending = false;
_isSubmitting = false;
_editingBox = null;
_isDuplicateBoxNo = false;
_statusOverrideText = null;
_statusOverrideDot = null;
}
// === 重复箱号检测 ===
bool _boxNoIsDuplicate() {
return switch (_mode) {
BoxingMode.multiCode => false,
BoxingMode.singleCode => boxing_calculations.singleCodeBoxNoIsDuplicate(
boxNoText: _boxNoController.text,
currentBoxes: _currentZongpaiBoxes,
editingBoxItemId: _editingBox?.boxItemId,
),
};
}
void _checkDuplicateBoxNo() {
setState(() => _isDuplicateBoxNo = _boxNoIsDuplicate());
}
// === 提交 ===
int get _packedQuantity {
return boxing_calculations.packedQuantity(_currentZongpaiBoxes);
}
int get _remainingQuantity {
return boxing_calculations.remainingQuantity(
totalQuantity: _erpQuantity,
currentBoxes: _currentZongpaiBoxes,
);
}
bool get _quantityTooHigh {
return boxing_calculations.quantityTooHigh(
remainingQuantity: _remainingQuantity,
quantityText: _quantityController.text,
);
}
bool get _canSubmit {
return boxing_calculations.canSubmitBoxing(
isSubmitting: _isSubmitting,
phase: _phase,
zongpaiNo: _zongpaiNo,
boxNoText: _boxNoController.text,
quantityText: _quantityController.text,
remainingQuantity: _remainingQuantity,
quantityTooHigh: _quantityTooHigh,
isDuplicateBoxNo: _isDuplicateBoxNo,
);
}
void _submitFromKeyboard() {
if (_canSubmit) {
this._submit();
}
}
void _focusQuantityInput() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_quantityFocusNode.requestFocus();
_quantityController.selection = TextSelection(
baseOffset: 0,
extentOffset: _quantityController.text.length,
);
});
}
int? get _currentManyToOneBoxNo {
return boxing_calculations.currentManyToOneBoxNo(_boxNoController.text);
}
List<ManyToOnePackedItem> get _visibleManyToOnePackedItems {
return boxing_calculations.visibleManyToOnePackedItems(
boxNo: _currentManyToOneBoxNo,
existingBoxes: _existingBoxes,
packedItems: _manyToOnePackedItems,
paichanNo: _paichanNo,
);
}
// === Build ===
@override
Widget build(BuildContext context) {
this._updateBaseStatus();
final effectiveDot = _statusOverrideDot ?? _statusDot;
final effectiveText = _statusOverrideText ?? _statusText;
return KeyboardListener(
focusNode: _focusNode,
onKeyEvent: _onKeyEvent,
child: Scaffold(
appBar: AppBar(
title: Row(
children: [
const Text('装箱编号'),
const SizedBox(width: 8),
BoxingModePill(
isSingle: _mode == BoxingMode.singleCode,
label: _modeLabel,
enabled: !_isAutoProcessing,
onTap: _cycleMode,
),
],
),
toolbarHeight: 44,
titleSpacing: 12,
),
body: Column(
children: [
BoxingNoticeBanners(
isAutoProcessing: _isAutoProcessing,
autoWarnings: _autoWarnings,
paichanSwitchNotice: _paichanSwitchNotice,
isDuplicateBoxNo: _isDuplicateBoxNo,
quantityTooHigh: _quantityTooHigh,
phase: _phase,
boxNoText: _boxNoController.text,
remainingQuantity: _remainingQuantity,
zongpaiNo: _zongpaiNo,
completedJumpBoxNo: _completedJumpBoxNo,
onJumpToCompletedBox: () => this._jumpToCompletedBox(),
),
Expanded(
child: _mode == BoxingMode.multiCode
? MultiCodeBody(
hasScan:
_zongpaiNo != null && _phase == BoxingPhase.scanned,
zongpaiNo: _zongpaiNo,
paichanNo: _paichanNo,
workOrderNo: _workOrderNo,
erpQuantity: _erpQuantity,
maxBoxNo: _maxBoxNo,
existingBoxes: _existingBoxes,
visibleItems: _visibleManyToOnePackedItems,
boxNoController: _boxNoController,
quantityController: _quantityController,
boxNoFocusNode: _boxNoFocusNode,
quantityFocusNode: _quantityFocusNode,
isSubmitting: _isSubmitting,
quantityTooHigh: _quantityTooHigh,
canSubmit: _canSubmit,
editingPackedItemId: _editingPackedItemId,
editingPackedQuantity: _editingPackedQuantity,
deletingPackedItemId: _deletingPackedItemId,
onOpenDetail: () => this._openDetail(),
onSubmitFromKeyboard: _submitFromKeyboard,
onSubmit: () => this._submit(),
onQuantityChanged: () => setState(() {}),
onFinishBox: () => this._finishManyToOneBox(),
onEditingPackedQuantityChanged: (value) =>
_editingPackedQuantity = value,
onSavePackedItem: (item) => this._savePackedItem(item),
onCancelEditPackedItem: () =>
this._cancelEditPackedItem(),
onStartEditPackedItem: (item) =>
this._startEditPackedItem(item),
onStartDeletePackedItem: (item) {
setState(() {
_deletingPackedItemId = item.boxItemId;
_editingPackedItemId = null;
_editingPackedQuantity = '';
});
},
onDeletePackedItem: (item) =>
this._deletePackedItem(item),
onCancelDeletePackedItem: () =>
setState(() => _deletingPackedItemId = null),
)
: SingleCodeBody(
isWaiting:
_phase == BoxingPhase.waiting && _zongpaiNo == null,
isFinished:
_phase == BoxingPhase.scanned &&
_zongpaiNo != null &&
_remainingQuantity <= 0,
zongpaiNo: _zongpaiNo,
paichanNo: _paichanNo,
workOrderNo: _workOrderNo,
erpQuantity: _erpQuantity,
packedQuantity: _packedQuantity,
maxBoxNo: _maxBoxNo,
assignedItems: _currentZongpaiBoxes,
existingBoxes: _existingBoxes,
boxNoController: _boxNoController,
quantityController: _quantityController,
boxNoFocusNode: _boxNoFocusNode,
quantityFocusNode: _quantityFocusNode,
isSubmitting: _isSubmitting,
isDuplicateBoxNo: _isDuplicateBoxNo,
quantityTooHigh: _quantityTooHigh,
canSubmit: _canSubmit,
editingAssignedItemId: _editingAssignedItemId,
editingAssignedQuantity: _editingAssignedQuantity,
deletingAssignedItemId: _deletingAssignedItemId,
onOpenDetail: () => this._openDetail(),
onCheckDuplicateBoxNo: _checkDuplicateBoxNo,
onSubmitFromKeyboard: _submitFromKeyboard,
onSubmit: () => this._submit(),
onQuantityChanged: () => setState(() {}),
onEditingAssignedQuantityChanged: (value) =>
_editingAssignedQuantity = value,
onSaveAssignedItem: (item) =>
this._saveAssignedItem(item),
onCancelEditAssigned: () => this._cancelEditAssigned(),
onStartEditAssigned: (item) =>
this._startEditAssigned(item),
onStartDeleteAssigned: (item) {
setState(() {
_deletingAssignedItemId = item.boxItemId;
_editingAssignedItemId = null;
_editingAssignedQuantity = '';
});
},
onDeleteAssignedItem: (item) =>
this._deleteAssignedItem(item),
onCancelDeleteAssigned: () =>
setState(() => _deletingAssignedItemId = null),
),
),
// 底部状态栏
StatusBar(dotColor: effectiveDot, text: effectiveText),
],
),
),
);
}
}

349
lib/pages/home_page.dart Normal file
View File

@@ -0,0 +1,349 @@
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/services/api_service.dart';
/// Module type enum for each available feature card
enum ModuleType { registration, boxing, shelfQuery }
/// Module status for PRD state tracking
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 ModuleStatus status;
final String route;
const FeatureCard({
required this.type,
required this.shortcut,
required this.icon,
required this.title,
required this.status,
required this.route,
});
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final _apiService = ApiService();
final _focusNode = FocusNode();
bool _isCheckingConnection = false;
bool _isConnected = false;
String _apiAddress = '';
/// Module registry from PRD §3.4
static const _modules = [
FeatureCard(
type: ModuleType.registration,
shortcut: '1',
icon: Icons.local_shipping_outlined,
title: '上架登记',
status: ModuleStatus.online,
route: '/registration',
),
FeatureCard(
type: ModuleType.boxing,
shortcut: '2',
icon: Icons.view_list_outlined,
title: '装箱编号',
status: ModuleStatus.online,
route: '/boxing',
),
FeatureCard(
type: ModuleType.shelfQuery,
shortcut: '3',
icon: Icons.search_outlined,
title: '货架查询',
status: ModuleStatus.planning,
route: '',
),
];
@override
void initState() {
super.initState();
_checkConnection();
WidgetsBinding.instance.addPostFrameCallback((_) => _requestFocus());
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
void _requestFocus() {
if (mounted) {
_focusNode.requestFocus();
}
}
void _onKeyEvent(KeyEvent event) {
if (event is! KeyDownEvent) return;
final module = _moduleFromShortcut(event);
if (module == null) return;
_onCardTap(module);
}
FeatureCard? _moduleFromShortcut(KeyEvent event) {
final shortcut = _shortcutNumberFromKeyEvent(event);
if (shortcut == 1) {
return _modules[0];
}
if (shortcut == 2) {
return _modules[1];
}
if (shortcut == 3) {
return _modules[2];
}
return null;
}
int? _shortcutNumberFromKeyEvent(KeyEvent event) {
final key = event.logicalKey;
if (key == LogicalKeyboardKey.digit1 ||
key == LogicalKeyboardKey.numpad1 ||
event.character == '1') {
return 1;
}
if (key == LogicalKeyboardKey.digit2 ||
key == LogicalKeyboardKey.numpad2 ||
event.character == '2') {
return 2;
}
if (key == LogicalKeyboardKey.digit3 ||
key == LogicalKeyboardKey.numpad3 ||
event.character == '3') {
return 3;
}
return null;
}
Future<void> _checkConnection() async {
final configService = AppConfigService();
final url = await configService.getString('api_url') ?? '';
if (url.isEmpty) {
setState(() {
_isConnected = false;
_apiAddress = '';
_isCheckingConnection = false;
});
return;
}
setState(() => _isCheckingConnection = true);
final ok = await _apiService.testConnection(url);
if (!mounted) return;
setState(() {
_isConnected = ok;
_apiAddress = url;
_isCheckingConnection = false;
});
}
void _onCardTap(FeatureCard card) {
switch (card.status) {
case ModuleStatus.online:
_navigateToModule(card.type);
case ModuleStatus.developing:
_showToast('该功能正在开发中');
case ModuleStatus.planning:
_showToast('该功能规划中');
}
}
void _showToast(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
);
}
void _navigateToModule(ModuleType type) {
late Widget targetPage;
switch (type) {
case ModuleType.registration:
targetPage = const RegistrationPage();
break;
case ModuleType.boxing:
targetPage = const BoxingPage();
break;
case ModuleType.shelfQuery:
return; // 规划中,不导航
}
Navigator.push(context, MaterialPageRoute(builder: (_) => targetPage)).then(
(_) {
_checkConnection();
_requestFocus();
},
);
}
void _navigateToSettings() {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsPage()),
).then((_) {
_checkConnection();
_requestFocus();
});
}
Color _getConnectionStatusColor() {
if (_isCheckingConnection) return Colors.orange;
return _isConnected ? Colors.green : Colors.red;
}
String _getConnectionStatusText() {
if (_isCheckingConnection) return '● 正在检测连接...';
if (!_isConnected || _apiAddress.isEmpty) return '● 未连接,请检查设置';
return '● 已连接 $_apiAddress';
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
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.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,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
_getConnectionStatusText(),
style: const TextStyle(fontSize: 13),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
),
);
}
Widget _buildFeatureCard(BuildContext context, FeatureCard card) {
final canNavigate = card.status == ModuleStatus.online;
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: InkWell(
onTap: () => _onCardTap(card),
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: canNavigate ? Colors.white : Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
Icon(
card.icon,
size: 22,
color: canNavigate ? Colors.blue : Colors.grey,
),
const SizedBox(width: 8),
Text(
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,357 @@
// ignore_for_file: invalid_use_of_protected_member
part of '../../registration_page.dart';
extension _RegistrationDialogsPart on _RegistrationPageState {
void _showLocationSwitchDialog(
String newLocationCode,
CodeType newLocationType,
) {
final count = _zongpaiNos.length;
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: Colors.orange.shade50,
title: Row(
children: [
Icon(Icons.warning_amber, color: Colors.orange.shade800),
const SizedBox(width: 8),
Text('切换货架号', style: TextStyle(color: Colors.orange.shade900)),
],
),
content: Text(
'当前已有 $count 条待上架数据,切换货架号不会清除已扫描的总排号。',
style: const TextStyle(fontSize: 14),
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(ctx);
},
child: const Text('取消'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
_feedbackService.trigger(FeedbackEvent.scanValid);
setState(() {
_locationCode = newLocationCode;
_locationType = newLocationType;
_clearStatusOverride();
});
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange.shade700,
foregroundColor: Colors.white,
),
child: const Text('确认切换'),
),
],
),
);
}
void _showDuplicateDialog(String zongpaiNo, Map<String, dynamic>? info) {
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: Colors.red.shade50,
title: const Row(
children: [
Icon(Icons.warning, color: Colors.red),
SizedBox(width: 8),
Text('重复上架', style: TextStyle(color: Colors.red)),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('总排号:$zongpaiNo'),
const SizedBox(height: 4),
Text('已登记货位:${info?["location_code"] ?? "未知"}'),
const SizedBox(height: 4),
Text('登记时间:${info?["registered_at"] ?? "未知"}'),
const SizedBox(height: 12),
const Text(
'请核查实物,确认是否操作错误。',
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
actions: [
TextButton(
onPressed: () {
_feedbackService.stopAlert();
Navigator.pop(ctx);
},
child: const Text('关闭'),
),
],
),
);
}
void _showLocationConflictDialog({
required List<PaichaOverviewItem> onShelfItems,
required List<PaichaOverviewItem> transferredItems,
List<PaichaOverviewItem> tempStoredItems = const [],
}) {
final contentParts = <Widget>[];
if (onShelfItems.isNotEmpty) {
contentParts.add(
Text(
'重复上架',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.red.shade700,
),
),
);
for (final item in onShelfItems) {
contentParts.addAll([
Text('总排号:${item.zongpaiNo}'),
Text('已登记货位:${item.locationCode ?? "未知"}'),
const SizedBox(height: 6),
]);
}
}
if (transferredItems.isNotEmpty) {
if (onShelfItems.isNotEmpty) {
contentParts.add(const Divider());
contentParts.add(const SizedBox(height: 4));
}
contentParts.add(
Text(
'货物已转运',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.orange.shade700,
),
),
);
for (final item in transferredItems) {
contentParts.addAll([
Text('总排号:${item.zongpaiNo}'),
Text('转运货位:${item.locationCode ?? "未知"}'),
const SizedBox(height: 6),
]);
}
}
if (tempStoredItems.isNotEmpty) {
if (onShelfItems.isNotEmpty || transferredItems.isNotEmpty) {
contentParts.add(const Divider());
contentParts.add(const SizedBox(height: 4));
}
contentParts.add(
Text(
'暂存中',
style: TextStyle(
fontWeight: FontWeight.bold,
color: const Color(0xFF7B1FA2),
),
),
);
for (final item in tempStoredItems) {
contentParts.addAll([
Text('总排号:${item.zongpaiNo}'),
Text('暂存货位:${item.locationCode ?? "未知"}'),
const SizedBox(height: 6),
]);
}
}
contentParts.add(const SizedBox(height: 4));
contentParts.add(
const Text(
'请核查实物,确认是否操作错误。',
style: TextStyle(fontWeight: FontWeight.bold),
),
);
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: Colors.red.shade50,
title: const Row(
children: [
Icon(Icons.warning, color: Colors.red),
SizedBox(width: 8),
Text('操作冲突', style: TextStyle(color: Colors.red)),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: contentParts,
),
actions: [
TextButton(
onPressed: () {
_feedbackService.stopAlert();
Navigator.pop(ctx);
},
child: const Text('关闭'),
),
],
),
);
}
Future<void> _showCrossPaichaDialog(String newZongpaiNo) async {
final currentPaicha = _overview?.paichaNo ?? '--';
final shouldSwitch = await _confirmCrossPaichaSwitch(
newZongpaiNo: newZongpaiNo,
currentPaicha: currentPaicha,
);
if (!mounted) return;
if (shouldSwitch) {
_switchToNewPaicha(newZongpaiNo);
return;
}
setState(() {
_crossPaichaPending = false;
});
}
Future<bool> _confirmCrossPaichaSwitch({
required String newZongpaiNo,
required String currentPaicha,
}) async {
final dialogFocus = FocusNode();
var dismissed = false;
var selectedIndex = 0; // 0 = "否" (default), 1 = "是"
void dismissAsNo() {
if (dismissed) return;
dismissed = true;
Navigator.of(context).pop(false);
}
void confirmSwitch() {
if (dismissed) return;
dismissed = true;
Navigator.of(context).pop(true);
}
final dialogFuture = showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) {
return KeyboardListener(
focusNode: dialogFocus,
onKeyEvent: (event) {
if (event is! KeyDownEvent) return;
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
setDialogState(() => selectedIndex = 0);
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
setDialogState(() => selectedIndex = 1);
} else if (event.logicalKey == LogicalKeyboardKey.enter) {
selectedIndex == 0 ? dismissAsNo() : confirmSwitch();
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
dismissAsNo();
}
},
child: PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) dismissAsNo();
},
child: AlertDialog(
backgroundColor: Colors.orange.shade50,
title: Row(
children: [
Icon(Icons.warning_amber, color: Colors.orange.shade800),
const SizedBox(width: 8),
Text(
'跨排产号扫描',
style: TextStyle(color: Colors.orange.shade900),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'总排号 $newZongpaiNo 不属于当前排产号($currentPaicha'
'是否切换到新的排产号?\n\n'
'选择「是」将放弃当前已扫描的所有数据。',
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 16),
_dialogOptionBtn(
label: '',
selected: selectedIndex == 0,
onTap: dismissAsNo,
),
const SizedBox(height: 8),
_dialogOptionBtn(
label: '是,切换排产号',
selected: selectedIndex == 1,
onTap: confirmSwitch,
),
],
),
),
),
);
},
),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
dialogFocus.requestFocus();
});
final shouldSwitch = await dialogFuture;
return shouldSwitch ?? false;
}
Widget _dialogOptionBtn({
required String label,
required bool selected,
required VoidCallback onTap,
}) {
return SizedBox(
width: double.infinity,
height: 40,
child: ElevatedButton(
onPressed: onTap,
style: ElevatedButton.styleFrom(
backgroundColor: selected
? Colors.blue.shade700
: Colors.grey.shade200,
foregroundColor: selected ? Colors.white : Colors.black87,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: Text(
label,
style: TextStyle(
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
),
),
),
);
}
void _switchToNewPaicha(String newZongpaiNo) {
_feedbackService.trigger(FeedbackEvent.paichanSwitch);
setState(() {
_crossPaichaPending = false;
_zongpaiNos.clear();
_zongpaiNos.add(newZongpaiNo);
_overview = null;
_overviewNotFound = false;
_overviewError = null;
_clearStatusOverride();
});
_loadOverview(newZongpaiNo);
}
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,116 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pad_scanner/services/app_config_service.dart';
import 'package:pad_scanner/services/scanner_service.dart';
import 'package:pad_scanner/services/code_parser.dart';
import 'package:pad_scanner/services/api_service.dart';
import 'package:pad_scanner/services/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});
@override
State<RegistrationPage> createState() => _RegistrationPageState();
}
class _RegistrationPageState extends State<RegistrationPage> {
final _scannerService = ScannerService();
final _apiService = ApiService();
final _feedbackService = FeedbackService();
final _focusNode = FocusNode();
StreamSubscription<ScanResult>? _scanSubscription;
final _zongpaiNos = <String>[];
String? _locationCode;
CodeType? _locationType;
RegistrationMode _mode = RegistrationMode.singleCode;
bool _isSubmitting = false;
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();
_startScanListening();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_scanSubscription?.cancel();
_focusNode.dispose();
_feedbackService.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
_updateBaseStatus();
final effectiveDot = _statusOverrideDot ?? _statusDot;
final effectiveText = _statusOverrideText ?? _statusText;
return KeyboardListener(
focusNode: _focusNode,
onKeyEvent: _onKeyEvent,
child: Scaffold(
appBar: AppBar(
title: Row(
children: [
const Text('上架登记'),
const SizedBox(width: 12),
Expanded(child: _buildModePill()),
],
),
titleSpacing: 12,
),
body: Column(
children: [
_buildPaichaHeader(),
if (_overview?.success == true) _buildTableHead(),
Expanded(child: _buildListBody()),
_buildBottomBar(colorScheme),
StatusBar(dotColor: effectiveDot, text: effectiveText),
],
),
),
);
}
}

View File

@@ -1,127 +0,0 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:pad_scanner/models/scan_record.dart';
import 'package:pad_scanner/services/scanner_service.dart';
import 'package:pad_scanner/services/api_service.dart';
import 'package:pad_scanner/pages/settings_page.dart';
class ScanPage extends StatefulWidget {
const ScanPage({super.key});
@override
State<ScanPage> createState() => _ScanPageState();
}
class _ScanPageState extends State<ScanPage> {
final _scannerService = ScannerService();
final _apiService = ApiService();
final _records = <ScanRecord>[];
String _status = 'Waiting for scan...';
@override
void initState() {
super.initState();
_startListening();
}
void _startListening() {
_scannerService.scanResults.listen((result) async {
final record = ScanRecord(
barcode: result.barcode,
codeType: result.codeType,
timestamp: DateTime.now(),
);
setState(() {
_records.insert(0, record);
_status = 'Sending...';
});
final prefs = await SharedPreferences.getInstance();
final url = prefs.getString('api_url') ?? '';
if (url.isEmpty) {
setState(() {
record.status = SendStatus.failed;
_status = 'No URL configured. Go to Settings.';
});
return;
}
final ok = await _apiService.sendScanData(
url,
barcode: record.barcode,
codeType: record.codeType,
);
if (mounted) {
setState(() {
record.status = ok ? SendStatus.success : SendStatus.failed;
_status = ok ? 'Sent successfully' : 'Send failed';
});
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('PAD Scanner'),
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsPage()),
),
),
],
),
body: Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Text(
_status,
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
),
Expanded(
child: _records.isEmpty
? const Center(
child: Text(
'No scans yet.\nPress the scan button on the device.',
textAlign: TextAlign.center))
: ListView.builder(
itemCount: _records.length,
itemBuilder: (context, index) {
final r = _records[index];
return ListTile(
title: Text(r.barcode),
subtitle: Text(
'${r.codeType} ${r.timestamp.toLocal().toIso8601String().substring(0, 19)}'),
trailing: Icon(
r.status == SendStatus.success
? Icons.check_circle
: r.status == SendStatus.failed
? Icons.error
: Icons.hourglass_empty,
color: r.status == SendStatus.success
? Colors.green
: r.status == SendStatus.failed
? Colors.red
: Colors.orange,
),
);
},
),
),
],
),
);
}
}

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:pad_scanner/services/app_config_service.dart';
import 'package:file_picker/file_picker.dart';
import 'package:pad_scanner/services/api_service.dart';
import 'package:pad_scanner/services/sound_service.dart';
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
@@ -12,93 +14,347 @@ class SettingsPage extends StatefulWidget {
class _SettingsPageState extends State<SettingsPage> {
final _controller = TextEditingController();
final _apiService = ApiService();
final _soundService = SoundService();
bool _saving = false;
bool _testing = false;
String? _successPath;
String? _failurePath;
String? _beepPath;
String? _errorPath;
String? _alertPath;
@override
void initState() {
super.initState();
_loadUrl();
_loadSoundPaths();
}
Future<void> _loadUrl() async {
final prefs = await SharedPreferences.getInstance();
_controller.text = prefs.getString('api_url') ?? '';
final configService = AppConfigService();
final url = await configService.getString('api_url') ?? '';
_controller.text = url;
}
Future<void> _loadSoundPaths() async {
final results = await Future.wait([
_soundService.getSuccessPath(),
_soundService.getFailurePath(),
_soundService.getBeepPath(),
_soundService.getErrorPath(),
_soundService.getAlertPath(),
]);
if (mounted) {
setState(() {
_successPath = results[0];
_failurePath = results[1];
_beepPath = results[2];
_errorPath = results[3];
_alertPath = results[4];
});
}
}
Future<void> _saveUrl() async {
final url = _controller.text.trim().replaceAll(RegExp(r'/+$'), '');
if (url.isEmpty) {
_showSnackBar('请输入 API 地址', isError: true);
return;
}
setState(() => _saving = true);
final prefs = await SharedPreferences.getInstance();
await prefs.setString('api_url', _controller.text.trim());
// Load config once, apply all changes, then save once to avoid race conditions
final configService = AppConfigService();
final config = await configService.loadConfig();
config['api_url'] = url;
if (_successPath != null) {
config['sound_success'] = _successPath;
} else {
config.remove('sound_success');
}
if (_failurePath != null) {
config['sound_failure'] = _failurePath;
} else {
config.remove('sound_failure');
}
if (_beepPath != null) {
config['sound_beep'] = _beepPath;
} else {
config.remove('sound_beep');
}
if (_errorPath != null) {
config['sound_error'] = _errorPath;
} else {
config.remove('sound_error');
}
if (_alertPath != null) {
config['sound_alert'] = _alertPath;
} else {
config.remove('sound_alert');
}
await configService.saveConfig(config);
setState(() => _saving = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('URL saved')),
);
_showSnackBar('设置已保存');
}
}
Future<void> _testConnection() async {
final url = _controller.text.trim();
if (url.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please enter a URL first')),
);
_showSnackBar('请先输入 API 地址', isError: true);
return;
}
final ok = await _apiService.sendScanData(
url,
barcode: 'TEST_BARCODE',
codeType: 'TEST',
);
setState(() => _testing = true);
final ok = await _apiService.testConnection(url);
setState(() => _testing = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(ok ? 'Connection OK' : 'Connection failed')),
);
_showSnackBar(ok ? '连接成功' : '连接失败,请检查地址和网络', isError: !ok);
}
}
Future<void> _pickSound(String key) async {
final result = await FilePicker.pickFiles(
type: FileType.audio,
);
if (result != null && result.files.single.path != null) {
final path = result.files.single.path!;
setState(() {
switch (key) {
case 'success':
_successPath = path;
case 'failure':
_failurePath = path;
case 'beep':
_beepPath = path;
case 'error':
_errorPath = path;
case 'alert':
_alertPath = path;
}
});
}
}
Future<void> _clearSound(String key) async {
setState(() {
switch (key) {
case 'success':
_successPath = null;
case 'failure':
_failurePath = null;
case 'beep':
_beepPath = null;
case 'error':
_errorPath = null;
case 'alert':
_alertPath = null;
}
});
}
Future<void> _previewSound(String path) async {
await _soundService.play(path);
}
String _fileName(String? path) {
if (path == null) return '未设置';
return path.split('/').last;
}
void _showSnackBar(String message, {bool isError = false}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor:
isError ? Colors.red.shade700 : Colors.green.shade700,
duration: const Duration(seconds: 2),
),
);
}
@override
void dispose() {
_controller.dispose();
_soundService.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: Padding(
appBar: AppBar(title: const Text('设置')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ---- API 地址 ----
const Text('API 服务器地址', style: TextStyle(fontSize: 14)),
const SizedBox(height: 8),
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'API URL',
hintText: 'http://192.168.1.100:8000/scan',
hintText: 'http://192.168.1.100:8000',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.url,
),
const SizedBox(height: 16),
ElevatedButton(
const SizedBox(height: 20),
ElevatedButton.icon(
onPressed: _saving ? null : _saveUrl,
child: _saving
icon: _saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Save'),
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: const Text('保存设置'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: _testConnection,
child: const Text('Test Connection'),
OutlinedButton.icon(
onPressed: _testing ? null : _testConnection,
icon: _testing
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.wifi),
label: const Text('测试连接'),
),
const SizedBox(height: 24),
// ---- 铃声设置 ----
const Divider(),
const SizedBox(height: 8),
const Text('铃声设置',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
// 扫码提示音
_buildSoundRow(
label: '扫码提示音',
path: _beepPath,
onPick: () => _pickSound('beep'),
onPreview: _beepPath != null
? () => _previewSound(_beepPath!)
: null,
onClear: _beepPath != null ? () => _clearSound('beep') : null,
),
const SizedBox(height: 12),
// 成功铃声
_buildSoundRow(
label: '成功铃声',
path: _successPath,
onPick: () => _pickSound('success'),
onPreview: _successPath != null
? () => _previewSound(_successPath!)
: null,
onClear: _successPath != null ? () => _clearSound('success') : null,
),
const SizedBox(height: 12),
// 失败铃声
_buildSoundRow(
label: '失败铃声',
path: _failurePath,
onPick: () => _pickSound('failure'),
onPreview: _failurePath != null
? () => _previewSound(_failurePath!)
: null,
onClear: _failurePath != null ? () => _clearSound('failure') : null,
),
const SizedBox(height: 12),
// 错误铃声(无效码)
_buildSoundRow(
label: '错误铃声(无效码)',
path: _errorPath,
onPick: () => _pickSound('error'),
onPreview: _errorPath != null
? () => _previewSound(_errorPath!)
: null,
onClear: _errorPath != null ? () => _clearSound('error') : null,
),
const SizedBox(height: 12),
// 警报铃声(重复上架)
_buildSoundRow(
label: '警报铃声(重复上架)',
path: _alertPath,
onPick: () => _pickSound('alert'),
onPreview: _alertPath != null
? () => _previewSound(_alertPath!)
: null,
onClear: _alertPath != null ? () => _clearSound('alert') : null,
),
],
),
),
);
}
Widget _buildSoundRow({
required String label,
required String? path,
required VoidCallback onPick,
required VoidCallback? onPreview,
required VoidCallback? onClear,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontSize: 14)),
const SizedBox(height: 6),
Row(
children: [
Expanded(
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_fileName(path),
style: TextStyle(
fontSize: 13,
color: path != null ? Colors.black87 : Colors.grey,
),
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.folder_open),
tooltip: '选择文件',
onPressed: onPick,
),
if (onPreview != null)
IconButton(
icon: const Icon(Icons.play_arrow),
tooltip: '试听',
onPressed: onPreview,
),
if (onClear != null)
IconButton(
icon: const Icon(Icons.clear, size: 20),
tooltip: '清除',
onPressed: onClear,
),
],
),
],
);
}
}

View File

@@ -1,29 +1,535 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
/// Result of a registration API call.
class RegistrationResult {
final bool success;
final bool isDuplicate;
final bool isAlreadyOffShelf;
final bool isOffShelfSuccess;
final String? errorMessage;
final Map<String, dynamic>? duplicateInfo;
final Map<String, dynamic>? conflictInfo;
RegistrationResult({
required this.success,
this.isDuplicate = false,
this.isAlreadyOffShelf = false,
this.isOffShelfSuccess = false,
this.errorMessage,
this.duplicateInfo,
this.conflictInfo,
});
factory RegistrationResult.ok() => RegistrationResult(success: true);
factory RegistrationResult.offShelfSuccess() =>
RegistrationResult(success: true, isOffShelfSuccess: true);
factory RegistrationResult.duplicate(Map<String, dynamic> info) =>
RegistrationResult(
success: false,
isDuplicate: true,
duplicateInfo: info,
conflictInfo: info,
);
factory RegistrationResult.alreadyOffShelf(Map<String, dynamic> info) =>
RegistrationResult(
success: false,
isAlreadyOffShelf: true,
errorMessage: info['message']?.toString() ?? '该总排号已下架至转运区域,不可重新上架',
conflictInfo: info,
);
factory RegistrationResult.error(String message) =>
RegistrationResult(success: false, errorMessage: message);
}
class PaichaOverviewItem {
final String zongpaiNo;
final String? workOrderNo;
final int quantity;
final String? locationCode;
final String status;
PaichaOverviewItem({
required this.zongpaiNo,
this.workOrderNo,
required this.quantity,
this.locationCode,
required this.status,
});
factory PaichaOverviewItem.fromJson(Map<String, dynamic> json) {
return PaichaOverviewItem(
zongpaiNo: json['zongpai_no'] as String,
workOrderNo: json['work_order_no']?.toString(),
quantity: json['quantity'] as int? ?? 0,
locationCode: json['location_code']?.toString(),
status: json['status']?.toString() ?? 'not_shelved',
);
}
}
class PaichaOverviewResult {
final bool success;
final bool notFound;
final String? errorMessage;
final String? paichaNo;
final int totalCount;
final List<PaichaOverviewItem> items;
PaichaOverviewResult({
required this.success,
this.notFound = false,
this.errorMessage,
this.paichaNo,
this.totalCount = 0,
this.items = const [],
});
factory PaichaOverviewResult.ok(Map<String, dynamic> json) {
final items =
(json['items'] as List<dynamic>?)
?.map(
(item) =>
PaichaOverviewItem.fromJson(item as Map<String, dynamic>),
)
.toList() ??
[];
return PaichaOverviewResult(
success: true,
paichaNo: json['paicha_no'] as String?,
totalCount: json['total_count'] as int? ?? items.length,
items: items,
);
}
factory PaichaOverviewResult.notFoundResult() => PaichaOverviewResult(
success: false,
notFound: true,
errorMessage: '暂无排产信息',
);
factory PaichaOverviewResult.error(String message) =>
PaichaOverviewResult(success: false, errorMessage: message);
}
// === 装箱模块数据类 ===
/// 箱号内单个总排号明细
class BoxItemData {
final int? boxItemId;
final String zongpaiNo;
final String? workOrderNo;
final int quantity;
final int? totalQuantity;
BoxItemData({
this.boxItemId,
required this.zongpaiNo,
this.workOrderNo,
required this.quantity,
this.totalQuantity,
});
factory BoxItemData.fromJson(Map<String, dynamic> json) {
return BoxItemData(
boxItemId: json['box_item_id'] as int?,
zongpaiNo: json['zongpai_no'] as String,
workOrderNo: json['work_order_no']?.toString(),
quantity: json['quantity'] as int,
totalQuantity: json['total_quantity'] as int?,
);
}
}
/// 箱号明细
class BoxDetailData {
final int boxNo;
final List<BoxItemData> items;
BoxDetailData({required this.boxNo, required this.items});
factory BoxDetailData.fromJson(Map<String, dynamic> json) {
final items =
(json['items'] as List<dynamic>?)
?.map((i) => BoxItemData.fromJson(i as Map<String, dynamic>))
.toList() ??
[];
return BoxDetailData(boxNo: json['box_no'] as int, items: items);
}
}
/// 当前总排号已分配的装箱明细
class CurrentZongpaiBoxData {
final int boxItemId;
final int boxNo;
final int quantity;
CurrentZongpaiBoxData({
required this.boxItemId,
required this.boxNo,
required this.quantity,
});
factory CurrentZongpaiBoxData.fromJson(Map<String, dynamic> json) {
return CurrentZongpaiBoxData(
boxItemId: json['box_item_id'] as int,
boxNo: json['box_no'] as int,
quantity: json['quantity'] as int,
);
}
}
/// 装箱信息查询结果
class BoxInfoResult {
final bool success;
final String? errorMessage;
final String? zongpaiNo;
final String? paichanNo;
final String? workOrderNo;
final int? quantity;
final List<CurrentZongpaiBoxData> currentZongpaiBoxes;
final List<BoxDetailData> existingBoxes;
final int maxBoxNo;
final int suggestedBoxNo;
BoxInfoResult({
required this.success,
this.errorMessage,
this.zongpaiNo,
this.paichanNo,
this.workOrderNo,
this.quantity,
this.currentZongpaiBoxes = const [],
this.existingBoxes = const [],
this.maxBoxNo = 0,
this.suggestedBoxNo = 1,
});
factory BoxInfoResult.ok(Map<String, dynamic> json) {
final boxes =
(json['existing_boxes'] as List<dynamic>?)
?.map((b) => BoxDetailData.fromJson(b as Map<String, dynamic>))
.toList() ??
[];
final currentBoxes =
(json['current_zongpai_boxes'] as List<dynamic>?)
?.map(
(b) => CurrentZongpaiBoxData.fromJson(b as Map<String, dynamic>),
)
.toList() ??
[];
return BoxInfoResult(
success: true,
zongpaiNo: json['zongpai_no'] as String?,
paichanNo: json['paichan_no'] as String?,
workOrderNo: json['work_order_no']?.toString(),
quantity: json['quantity'] as int?,
currentZongpaiBoxes: currentBoxes,
existingBoxes: boxes,
maxBoxNo: json['max_box_no'] as int? ?? 0,
suggestedBoxNo: json['suggested_box_no'] as int? ?? 1,
);
}
factory BoxInfoResult.error(String message) {
return BoxInfoResult(success: false, errorMessage: message);
}
}
/// 装箱保存结果
class BoxSaveResult {
final bool success;
final bool isDuplicate;
final String? errorMessage;
final String? errorCode;
final int? boxItemId;
final String? paichanNo;
final String? zongpaiNo;
final int? boxNo;
final int? quantity;
BoxSaveResult({
required this.success,
this.isDuplicate = false,
this.errorMessage,
this.errorCode,
this.boxItemId,
this.paichanNo,
this.zongpaiNo,
this.boxNo,
this.quantity,
});
factory BoxSaveResult.ok(Map<String, dynamic> json) {
return BoxSaveResult(
success: true,
boxItemId: json['box_item_id'] as int?,
paichanNo: json['paichan_no'] as String?,
zongpaiNo: json['zongpai_no'] as String?,
boxNo: json['box_no'] as int?,
quantity: json['quantity'] as int?,
);
}
factory BoxSaveResult.duplicate(Map<String, dynamic> json) {
return BoxSaveResult(
success: false,
isDuplicate: true,
errorCode: json['error_code']?.toString(),
errorMessage: json['message']?.toString(),
paichanNo: json['paichan_no'] as String?,
zongpaiNo: json['zongpai_no'] as String?,
boxNo: json['box_no'] as int?,
);
}
factory BoxSaveResult.error(String message) {
return BoxSaveResult(success: false, errorMessage: message);
}
}
class BoxDeleteResult {
final bool success;
final String? errorMessage;
BoxDeleteResult({required this.success, this.errorMessage});
}
class ApiService {
final http.Client _client;
final Duration timeout;
ApiService({http.Client? client}) : _client = client ?? http.Client();
ApiService({http.Client? client, this.timeout = const Duration(seconds: 5)})
: _client = client ?? http.Client();
Future<bool> sendScanData(
String url, {
required String barcode,
required String codeType,
/// Submit a shelf registration (上架登记).
Future<RegistrationResult> registerLocation({
required String baseUrl,
required String zongpaiNo,
required String locationCode,
}) async {
final uri = Uri.parse('$baseUrl/CargoTrace/location');
try {
final response = await _client
.post(
Uri.parse(url),
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'barcode': barcode,
'code_type': codeType,
'timestamp': DateTime.now().toUtc().toIso8601String(),
'zongpai_no': zongpaiNo,
'location_code': locationCode,
}),
)
.timeout(const Duration(seconds: 5));
return response.statusCode >= 200 && response.statusCode < 300;
.timeout(timeout);
switch (response.statusCode) {
case 200:
final body = jsonDecode(response.body) as Map<String, dynamic>;
if (body['previous_location'] != null) {
return RegistrationResult.offShelfSuccess();
}
return RegistrationResult.ok();
case 400:
final body = jsonDecode(response.body) as Map<String, dynamic>;
final msg = body['message']?.toString() ?? '请求参数错误';
return RegistrationResult.error(msg);
case 409:
final body = jsonDecode(response.body) as Map<String, dynamic>;
final errorCode = body['error_code']?.toString();
if (errorCode == 'DUPLICATE_LOCATION') {
return RegistrationResult.duplicate(body);
}
if (errorCode == 'ALREADY_OFF_SHELF') {
return RegistrationResult.alreadyOffShelf(body);
}
final msg = body['message']?.toString() ?? '提交失败';
return RegistrationResult.error(msg);
default:
final body = jsonDecode(response.body) as Map<String, dynamic>;
final msg =
body['message'] ??
body['error'] ??
'Unknown error (${response.statusCode})';
return RegistrationResult.error(msg.toString());
}
} catch (e) {
return RegistrationResult.error('网络异常,请检查网络连接');
}
}
Future<PaichaOverviewResult> fetchPaichaOverview({
required String baseUrl,
required String zongpaiNo,
}) async {
final uri = Uri.parse(
'$baseUrl/CargoTrace/location/paicha-overview',
).replace(queryParameters: {'zongpai_no': zongpaiNo});
try {
final response = await _client
.get(uri, headers: {'Content-Type': 'application/json'})
.timeout(timeout);
switch (response.statusCode) {
case 200:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return PaichaOverviewResult.ok(body);
case 400:
return PaichaOverviewResult.error('无效的总排号格式');
case 404:
return PaichaOverviewResult.notFoundResult();
default:
return PaichaOverviewResult.error('加载失败,点击重试');
}
} catch (e) {
return PaichaOverviewResult.error('加载失败,点击重试');
}
}
/// 查询装箱信息 — GET /CargoTrace/box/info
Future<BoxInfoResult> fetchBoxInfo({
required String baseUrl,
required String zongpaiNo,
}) async {
final uri = Uri.parse(
'$baseUrl/CargoTrace/box/info',
).replace(queryParameters: {'zongpai_no': zongpaiNo});
try {
final response = await _client
.get(uri, headers: {'Content-Type': 'application/json'})
.timeout(timeout);
switch (response.statusCode) {
case 200:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return BoxInfoResult.ok(body);
case 400:
return BoxInfoResult.error('无效的总排号格式');
case 404:
return BoxInfoResult.error('未找到该总排号对应的排产号信息');
default:
return BoxInfoResult.error('查询失败 (${response.statusCode})');
}
} catch (e) {
return BoxInfoResult.error('网络异常,请检查网络连接');
}
}
/// 保存装箱记录 — POST /CargoTrace/box
Future<BoxSaveResult> saveBoxRecord({
required String baseUrl,
required String zongpaiNo,
required int boxNo,
required int quantity,
}) async {
final uri = Uri.parse('$baseUrl/CargoTrace/box');
try {
final response = await _client
.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'zongpai_no': zongpaiNo,
'box_no': boxNo,
'quantity': quantity,
}),
)
.timeout(timeout);
switch (response.statusCode) {
case 200:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return BoxSaveResult.ok(body);
case 400:
final body = jsonDecode(response.body) as Map<String, dynamic>;
final msg = body['message']?.toString() ?? '请求参数错误';
return BoxSaveResult.error(msg);
case 409:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return BoxSaveResult.duplicate(body);
case 404:
return BoxSaveResult.error('未找到该总排号对应的排产号信息');
default:
return BoxSaveResult.error('提交失败 (${response.statusCode})');
}
} catch (e) {
return BoxSaveResult.error('网络异常,请检查网络连接');
}
}
/// 更新装箱明细 — PATCH /CargoTrace/box/{boxItemId}
Future<BoxSaveResult> updateBoxRecord({
required String baseUrl,
required int boxItemId,
required int boxNo,
required int quantity,
}) async {
final uri = Uri.parse('$baseUrl/CargoTrace/box/$boxItemId');
try {
final response = await _client
.patch(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'box_no': boxNo, 'quantity': quantity}),
)
.timeout(timeout);
switch (response.statusCode) {
case 200:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return BoxSaveResult.ok(body);
case 400:
final body = jsonDecode(response.body) as Map<String, dynamic>;
final msg = body['message']?.toString() ?? '请求参数错误';
return BoxSaveResult.error(msg);
case 409:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return BoxSaveResult.duplicate(body);
case 404:
return BoxSaveResult.error('指定装箱明细不存在');
default:
return BoxSaveResult.error('修改失败 (${response.statusCode})');
}
} catch (e) {
return BoxSaveResult.error('网络异常,请检查网络连接');
}
}
/// 删除装箱明细 — DELETE /CargoTrace/box/{boxItemId}
Future<BoxDeleteResult> deleteBoxRecord({
required String baseUrl,
required int boxItemId,
}) async {
final uri = Uri.parse('$baseUrl/CargoTrace/box/$boxItemId');
try {
final response = await _client
.delete(uri, headers: {'Content-Type': 'application/json'})
.timeout(timeout);
switch (response.statusCode) {
case 200:
return BoxDeleteResult(success: true);
case 404:
return BoxDeleteResult(success: false, errorMessage: '指定装箱明细不存在');
default:
return BoxDeleteResult(
success: false,
errorMessage: '删除失败 (${response.statusCode})',
);
}
} catch (e) {
return BoxDeleteResult(success: false, errorMessage: '网络异常,请检查网络连接');
}
}
/// Test connectivity by making a HEAD request to the base URL.
Future<bool> testConnection(String baseUrl) async {
try {
final uri = Uri.parse(baseUrl);
final response = await _client.head(uri).timeout(timeout);
return response.statusCode < 500;
} catch (_) {
return false;
}

View File

@@ -0,0 +1,53 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:convert';
/// 应用全局配置服务 (替代 SharedPreferences)
/// 配置数据持久化为本地 JSON 文件,覆盖安装应用时数据不丢失。
class AppConfigService {
static const String _kConfigFileName = 'app_config.json';
static final Map<String, dynamic> _defaults = {'api_url': ''};
Future<String> get _filePath async {
final directory = await getApplicationDocumentsDirectory();
return '${directory.path}/$_kConfigFileName';
}
/// 读取完整配置字典
Future<Map<String, dynamic>> loadConfig() async {
try {
final file = File(await _filePath);
if (await file.exists()) {
final jsonString = await file.readAsString();
return jsonDecode(jsonString) as Map<String, dynamic>;
}
} catch (e) {
debugPrint('[AppConfig] 读取配置失败: $e');
}
return Map<String, dynamic>.from(_defaults);
}
/// 读取字符串配置项
Future<String?> getString(String key) async {
final config = await loadConfig();
return config[key] as String?;
}
/// 保存字符串配置项
Future<void> setString(String key, String value) async {
final config = await loadConfig();
config[key.toString()] = value.toString();
await saveConfig(config);
}
/// 完整写入 JSON 文件
Future<void> saveConfig(Map<String, dynamic> config) async {
try {
final file = File(await _filePath);
await file.writeAsString(jsonEncode(config));
} catch (e) {
debugPrint('[AppConfig] 保存配置失败: $e');
}
}
}

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

@@ -0,0 +1,49 @@
// lib/services/code_parser.dart
class CodeParser {
static final _zongpaiRegex = RegExp(r'^\d{2}(B|C|T)\d+$|^\d{2}(BW|CW)\d{4}$');
static final _locationRegex = RegExp(r'^[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$');
static final _transitRegex = RegExp(r'^TRANS-');
static ParseResult parse(String code) {
final trimmed = code.trim();
if (trimmed.isEmpty) {
return ParseResult(type: CodeType.invalid, value: code);
}
final normalized = trimmed.toUpperCase();
if (_transitRegex.hasMatch(normalized)) {
return ParseResult(type: CodeType.locationTransit, value: normalized);
}
if (_zongpaiRegex.hasMatch(normalized)) {
return ParseResult(type: CodeType.zongpaiNo, value: normalized);
}
if (_locationRegex.hasMatch(normalized)) {
if (normalized.startsWith('B')) {
return ParseResult(
type: CodeType.locationTempStorage,
value: normalized,
);
}
return ParseResult(type: CodeType.locationNormal, value: normalized);
}
return ParseResult(type: CodeType.invalid, value: code);
}
static bool isLocation(CodeType type) =>
type == CodeType.locationNormal ||
type == CodeType.locationTransit ||
type == CodeType.locationTempStorage;
}
enum CodeType {
zongpaiNo,
locationNormal,
locationTransit,
locationTempStorage,
invalid,
}
class ParseResult {
final CodeType type;
final String value;
ParseResult({required this.type, required this.value});
}

View File

@@ -0,0 +1,98 @@
import 'package:vibration/vibration.dart';
import 'package:pad_scanner/services/sound_service.dart';
/// Unified feedback events used across the app.
enum FeedbackEvent {
scanValid, // Valid barcode scanned → beep + short vibrate
scanInvalid, // Invalid barcode → error sound + short vibrate
submitSuccess, // Submit succeeded → success sound + long vibrate
submitFailure, // Submit failed → failure sound + short vibrate
duplicateError, // Duplicate (shelf) → alert loop + continuous vibrate
networkError, // Network error → failure sound + short vibrate
duplicateBoxNo, // Duplicate box no → failure sound + short vibrate
alreadyCompleted, // Zongpai already boxed → beep + double vibrate
modeSwitch, // Mode toggle → beep only
paichanSwitch, // Paichan changed → beep + short vibrate
}
/// Centralized feedback dispatcher.
///
/// Each page manages its own bottom status bar state; this service handles
/// the sound, vibration channels only.
class FeedbackService {
final SoundService _soundService;
FeedbackService({SoundService? soundService})
: _soundService = soundService ?? SoundService();
/// Trigger feedback for the given [event].
Future<void> trigger(FeedbackEvent event) async {
switch (event) {
case FeedbackEvent.scanValid:
_soundService.playBeep();
_vibrateShort();
case FeedbackEvent.scanInvalid:
_soundService.playError();
_vibrateShort();
case FeedbackEvent.submitSuccess:
_soundService.playSuccess();
_vibrateLong();
case FeedbackEvent.submitFailure:
_soundService.playFailure();
_vibrateShort();
case FeedbackEvent.duplicateError:
_soundService.playAlertLoop();
_vibratePattern();
case FeedbackEvent.networkError:
_soundService.playFailure();
_vibrateShort();
case FeedbackEvent.duplicateBoxNo:
_soundService.playFailure();
_vibrateShort();
case FeedbackEvent.alreadyCompleted:
_soundService.playBeep();
_vibrateDouble();
case FeedbackEvent.modeSwitch:
_soundService.playBeep();
case FeedbackEvent.paichanSwitch:
_soundService.playBeep();
_vibrateShort();
}
}
/// Stop any ongoing alert (e.g. when duplicate dialog is closed).
Future<void> stopAlert() async {
_soundService.stopAlert();
}
void _vibrateShort() {
Vibration.vibrate(duration: 100);
}
void _vibrateLong() {
Vibration.vibrate(duration: 200);
}
/// Continuous vibration pattern: 3 bursts to signal critical error.
void _vibratePattern() {
Vibration.vibrate(pattern: [0, 300, 100, 300, 100, 300]);
}
/// Two short bursts to signal an already-completed item.
void _vibrateDouble() {
Vibration.vibrate(pattern: [0, 200, 100, 200]);
}
Future<void> dispose() async {
await _soundService.dispose();
}
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:pad_scanner/services/code_parser.dart';
const batchPaichaMismatchMessage = '批量凑箱仅支持同排产号产品,请移除不属于同一批次的总排号';
bool isTransitTarget(CodeType? locationType, String? locationCode) {
return locationType == CodeType.locationTransit ||
(locationCode?.startsWith('TRANS-') ?? false);
}
bool isTempStorageTarget(CodeType? locationType, String? locationCode) {
return locationType == CodeType.locationTempStorage ||
(locationCode?.startsWith('B') ?? false);
}
String registrationSubmitLabel({
required bool isTransitTarget,
required bool isMultiCode,
required int zongpaiCount,
}) {
if (!isTransitTarget) {
return isMultiCode && zongpaiCount > 1
? '批量上架($zongpaiCount 条)'
: '确 认 上 架';
}
return isMultiCode && zongpaiCount > 1 ? '批量转运并凑箱($zongpaiCount 条)' : '转运并装箱';
}
Color registrationSubmitColor({
required bool canSubmit,
required bool isTransitTarget,
required Color primaryColor,
}) {
if (!canSubmit) return Colors.grey.shade300;
return isTransitTarget ? Colors.orange : primaryColor;
}
bool allSamePaicha(Iterable<String?> paichaNos) {
String? first;
for (final paichaNo in paichaNos) {
if (paichaNo == null || paichaNo.isEmpty) return false;
first ??= paichaNo;
if (paichaNo != first) return false;
}
return first != null;
}

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

@@ -0,0 +1,128 @@
import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:pad_scanner/services/app_config_service.dart';
class SoundService {
static const _keySuccess = 'sound_success';
static const _keyFailure = 'sound_failure';
static const _keyBeep = 'sound_beep';
static const _keyError = 'sound_error';
static const _keyAlert = 'sound_alert';
final _player = AudioPlayer();
final _alertPlayer = AudioPlayer();
final _configService = AppConfigService();
Timer? _alertTimer;
// --- Path getters ---
Future<String?> getSuccessPath() =>
_configService.getString(_keySuccess);
Future<String?> getFailurePath() =>
_configService.getString(_keyFailure);
Future<String?> getBeepPath() =>
_configService.getString(_keyBeep);
Future<String?> getErrorPath() =>
_configService.getString(_keyError);
Future<String?> getAlertPath() =>
_configService.getString(_keyAlert);
// --- Path setters ---
Future<void> setSuccessPath(String? path) =>
_setConfigPath(_keySuccess, path);
Future<void> setFailurePath(String? path) =>
_setConfigPath(_keyFailure, path);
Future<void> setBeepPath(String? path) =>
_setConfigPath(_keyBeep, path);
Future<void> setErrorPath(String? path) =>
_setConfigPath(_keyError, path);
Future<void> setAlertPath(String? path) =>
_setConfigPath(_keyAlert, path);
Future<void> _setConfigPath(String key, String? path) async {
if (path != null) {
await _configService.setString(key, path);
} else {
final config = await _configService.loadConfig();
config.remove(key);
}
}
// --- Playback methods ---
Future<void> playSuccess() async {
final path = await getSuccessPath();
if (path != null) {
await _player.play(DeviceFileSource(path));
}
}
Future<void> playFailure() async {
final path = await getFailurePath();
if (path != null) {
await _player.play(DeviceFileSource(path));
}
}
Future<void> playBeep() async {
final path = await getBeepPath();
if (path != null) {
await _player.play(DeviceFileSource(path));
}
}
Future<void> playError() async {
final path = await getErrorPath();
if (path != null) {
await _player.play(DeviceFileSource(path));
}
}
/// Play alert sound in a loop until [stopAlert] is called.
/// Loops up to 10 times as a safety guard (~15 seconds max).
Future<void> playAlertLoop() async {
final path = await getAlertPath();
if (path == null) return;
_alertTimer?.cancel();
var count = 0;
_alertTimer = Timer.periodic(const Duration(milliseconds: 1500), (_) async {
if (count >= 10) {
_alertTimer?.cancel();
return;
}
await _alertPlayer.play(DeviceFileSource(path));
count++;
});
// Play immediately first time
await _alertPlayer.play(DeviceFileSource(path));
count++;
}
/// Stop the alert loop.
void stopAlert() {
_alertTimer?.cancel();
_alertTimer = null;
_alertPlayer.stop();
}
Future<void> play(String path) async {
await _player.play(DeviceFileSource(path));
}
Future<void> dispose() async {
_alertTimer?.cancel();
await _player.dispose();
await _alertPlayer.dispose();
}
}

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
/// Status bar dot colors matching PRD requirements.
enum StatusDotColor {
blue, // idle / waiting
orange, // submitting
green, // success
red, // error (invalid code / submit failure / duplicate)
yellow, // network error
amber, // warning (duplicate box number)
}
/// Reusable bottom status bar with a colored dot and text.
class StatusBar extends StatelessWidget {
final StatusDotColor dotColor;
final String text;
const StatusBar({super.key, required this.dotColor, required this.text});
Color _resolveDotColor(BuildContext context) {
return switch (dotColor) {
StatusDotColor.blue => Colors.blue,
StatusDotColor.orange => Colors.orange,
StatusDotColor.green => Colors.green,
StatusDotColor.red => Colors.red,
StatusDotColor.yellow => Colors.yellow.shade700,
StatusDotColor.amber => Colors.amber,
};
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: double.infinity,
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 12),
color: colorScheme.surfaceContainerHighest,
child: Row(
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: _resolveDotColor(context),
shape: BoxShape.circle,
),
),
const SizedBox(width: 7),
Expanded(
child: Text(
text,
style: const TextStyle(fontSize: 11),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}

View File

@@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_linux/audioplayers_linux_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
}

View File

@@ -3,9 +3,11 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)

View File

@@ -5,8 +5,12 @@
import FlutterMacOS
import Foundation
import shared_preferences_foundation
import audioplayers_darwin
import device_info_plus
import file_picker
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
}

View File

@@ -1,14 +1,78 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.0"
version: "2.13.1"
audioplayers:
dependency: "direct main"
description:
name: audioplayers
sha256: a72dd459d1a48f61a6fb9c0134dba26597c9236af40639ff0eb70eb4e0baab70
url: "https://pub.dev"
source: hosted
version: "6.6.0"
audioplayers_android:
dependency: transitive
description:
name: audioplayers_android
sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605"
url: "https://pub.dev"
source: hosted
version: "5.2.1"
audioplayers_darwin:
dependency: transitive
description:
name: audioplayers_darwin
sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91
url: "https://pub.dev"
source: hosted
version: "6.4.0"
audioplayers_linux:
dependency: transitive
description:
name: audioplayers_linux
sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001
url: "https://pub.dev"
source: hosted
version: "4.2.1"
audioplayers_platform_interface:
dependency: transitive
description:
name: audioplayers_platform_interface
sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656"
url: "https://pub.dev"
source: hosted
version: "7.1.1"
audioplayers_web:
dependency: transitive
description:
name: audioplayers_web
sha256: faa8fa6587f996a6f604433b53af44c57a1407d4fe8dff5766cf63d6875e8de9
url: "https://pub.dev"
source: hosted
version: "5.2.0"
audioplayers_windows:
dependency: transitive
description:
name: audioplayers_windows
sha256: bafff2b38b6f6d331887558ba6e0a01c9c208d9dbb3ad0005234db065122a734
url: "https://pub.dev"
source: hosted
version: "4.3.0"
boolean_selector:
dependency: transitive
description:
@@ -33,6 +97,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
collection:
dependency: transitive
description:
@@ -41,14 +113,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev"
source: hosted
version: "0.3.5+2"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
source: hosted
version: "1.0.8"
version: "1.0.9"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.12"
device_info_plus:
dependency: transitive
description:
name: device_info_plus
sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd
url: "https://pub.dev"
source: hosted
version: "12.4.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
url: "https://pub.dev"
source: hosted
version: "7.0.3"
fake_async:
dependency: transitive
description:
@@ -73,6 +185,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: "27c58ff4ec17d8e05b8b27fa66175bf5786b9ff9bd94ca418a571c7f6771b27b"
url: "https://pub.dev"
source: hosted
version: "11.0.0"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
@@ -86,6 +214,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
url: "https://pub.dev"
source: hosted
version: "2.0.34"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -96,6 +232,22 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
hooks:
dependency: transitive
description:
name: hooks
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
http:
dependency: "direct main"
description:
@@ -112,6 +264,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.dev"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
leak_tracker:
dependency: transitive
description:
@@ -144,6 +312,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.1.1"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
@@ -168,6 +344,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.17.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.dev"
source: hosted
version: "0.17.6"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
@@ -176,6 +376,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
@@ -200,6 +424,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
@@ -216,62 +448,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
url: "https://pub.dev"
source: hosted
version: "2.5.3"
shared_preferences_android:
pub_semver:
dependency: transitive
description:
name: shared_preferences_android
sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.4.13"
shared_preferences_foundation:
version: "2.2.0"
record_use:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
version: "0.6.0"
sky_engine:
dependency: transitive
description: flutter
@@ -281,10 +473,10 @@ packages:
dependency: transitive
description:
name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.1"
version: "1.10.2"
stack_trace:
dependency: transitive
description:
@@ -309,6 +501,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
url: "https://pub.dev"
source: hosted
version: "3.4.0+1"
term_glyph:
dependency: transitive
description:
@@ -333,6 +533,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math:
dependency: transitive
description:
@@ -341,14 +549,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vibration:
dependency: "direct main"
description:
name: vibration
sha256: "9bb06614c69260f8bd11c80fe01ed7988905cf00e3417d656c2647e41f261d87"
url: "https://pub.dev"
source: hosted
version: "3.1.8"
vibration_platform_interface:
dependency: transitive
description:
name: vibration_platform_interface
sha256: "258c273268f8aa40c88d29741137c536874a738779b92ddb8aa32ed093721ec5"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.0.0"
version: "15.2.0"
web:
dependency: transitive
description:
@@ -357,6 +581,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
xdg_directories:
dependency: transitive
description:
@@ -365,6 +605,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.9.0-0 <4.0.0"
flutter: ">=3.29.0"
dart: ">=3.11.0 <4.0.0"
flutter: ">=3.38.4"

View File

@@ -35,7 +35,10 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
http: ^1.2.0
shared_preferences: ^2.2.0
path_provider: ^2.1.1
vibration: ^3.1.8
file_picker: 11.0.0
audioplayers: ^6.6.0
dev_dependencies:
flutter_test:

View File

@@ -1,29 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pad_scanner/models/scan_record.dart';
void main() {
group('ScanRecord', () {
test('creates with correct defaults', () {
final record = ScanRecord(
barcode: '1234567890',
codeType: 'CODE128',
timestamp: DateTime.parse('2026-05-07T10:30:00Z'),
);
expect(record.barcode, '1234567890');
expect(record.codeType, 'CODE128');
expect(record.status, SendStatus.pending);
});
test('toJson produces correct map', () {
final record = ScanRecord(
barcode: 'ABC123',
codeType: 'QR',
timestamp: DateTime.parse('2026-05-07T10:30:00Z'),
);
final json = record.toJson();
expect(json['barcode'], 'ABC123');
expect(json['code_type'], 'QR');
expect(json['timestamp'], '2026-05-07T10:30:00.000Z');
});
});
}

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

@@ -1,34 +1,391 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:pad_scanner/services/api_service.dart';
void main() {
group('ApiService', () {
test('sendScanData returns true on 200', () async {
final client = MockClient((request) async {
return http.Response('{"status":"ok"}', 200);
group('ApiService.registerLocation', () {
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',
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
});
final service = ApiService(client: client);
final result = await service.sendScanData(
'http://localhost:8000/scan',
barcode: '123456',
codeType: 'CODE128',
final svc = ApiService(client: mockClient);
final result = await svc.registerLocation(
baseUrl: 'http://localhost',
zongpaiNo: '26B1',
locationCode: 'A01-02-03',
);
expect(result, isTrue);
expect(result.success, isTrue);
expect(result.isDuplicate, isFalse);
expect(result.isOffShelfSuccess, isFalse);
});
test('sendScanData returns false on error', () async {
final client = MockClient((request) async {
return http.Response('error', 500);
test('returns off-shelf success on 200 with previous_location', () async {
final mockClient = _MockClient((request) async {
return http.Response(
jsonEncode({
'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 service = ApiService(client: client);
final result = await service.sendScanData(
'http://localhost:8000/scan',
barcode: '123456',
codeType: 'CODE128',
final svc = ApiService(client: mockClient);
final result = await svc.registerLocation(
baseUrl: 'http://localhost',
zongpaiNo: '26B1',
locationCode: 'TRANS-01',
);
expect(result, isFalse);
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'},
);
});
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, 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',
}),
400,
headers: {'content-type': 'application/json; charset=utf-8'},
);
});
final svc = ApiService(client: mockClient);
final result = await svc.registerLocation(
baseUrl: 'http://localhost',
zongpaiNo: 'INVALID',
locationCode: 'A01-02-03',
);
expect(result.success, isFalse);
expect(result.isDuplicate, isFalse);
expect(result.errorMessage, 'INVALID_ZONGPAI');
});
test('returns error on network failure', () async {
final mockClient = _MockClient((request) async {
throw Exception('Connection refused');
});
final svc = ApiService(client: mockClient, timeout: Duration(seconds: 1));
final result = await svc.registerLocation(
baseUrl: 'http://localhost',
zongpaiNo: '26B1',
locationCode: 'A01-02-03',
);
expect(result.success, isFalse);
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 {
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,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,65 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pad_scanner/services/code_parser.dart';
void main() {
group('CodeParser.parse', () {
test('identifies zongpaiNo type B', () {
final result = CodeParser.parse('26B1');
expect(result.type, CodeType.zongpaiNo);
expect(result.value, '26B1');
});
test('identifies zongpaiNo type C', () {
final result = CodeParser.parse('26C12');
expect(result.type, CodeType.zongpaiNo);
});
test('identifies zongpaiNo type T', () {
final result = CodeParser.parse('26T3');
expect(result.type, CodeType.zongpaiNo);
});
test('identifies zongpaiNo type BW (4 digits)', () {
final result = CodeParser.parse('26BW0001');
expect(result.type, CodeType.zongpaiNo);
});
test('identifies zongpaiNo type CW (4 digits)', () {
final result = CodeParser.parse('26CW0015');
expect(result.type, CodeType.zongpaiNo);
});
test('rejects BW with non-4-digit serial', () {
expect(CodeParser.parse('26BW01').type, CodeType.invalid);
});
test('rejects CW with non-4-digit serial', () {
expect(CodeParser.parse('26CW15').type, CodeType.invalid);
});
test('identifies normal location code', () {
final result = CodeParser.parse('A01-02-03');
expect(result.type, CodeType.locationNormal);
expect(result.value, 'A01-02-03');
});
test('identifies transit location code', () {
final result = CodeParser.parse('TRANS-01');
expect(result.type, CodeType.locationTransit);
});
test('normalizes lowercase zongpaiNo to uppercase', () {
final result = CodeParser.parse('26b1');
expect(result.type, CodeType.zongpaiNo);
expect(result.value, '26B1');
});
test('normalizes lowercase location code to uppercase', () {
final result = CodeParser.parse('a01-02-03');
expect(result.type, CodeType.locationNormal);
expect(result.value, 'A01-02-03');
});
test('rejects invalid code', () {
expect(CodeParser.parse('HELLO123').type, CodeType.invalid);
});
test('rejects empty string', () {
expect(CodeParser.parse('').type, CodeType.invalid);
});
test('isLocation helper', () {
expect(CodeParser.isLocation(CodeType.locationNormal), true);
expect(CodeParser.isLocation(CodeType.locationTransit), true);
expect(CodeParser.isLocation(CodeType.zongpaiNo), false);
expect(CodeParser.isLocation(CodeType.invalid), false);
});
});
}

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

View File

@@ -6,6 +6,9 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_windows/audioplayers_windows_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
AudioplayersWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
}

View File

@@ -3,9 +3,11 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)