From 599139320e6a64139ad44755593df60b40ba0a81 Mon Sep 17 00:00:00 2001 From: clever-max <2942247417@qq.com> Date: Fri, 3 Jul 2026 03:26:15 +0800 Subject: [PATCH 1/5] feat(library): add scan-to-borrow flow --- assets/flutter_i18n/en_US.yaml | 23 + assets/flutter_i18n/zh_CN.yaml | 23 + assets/flutter_i18n/zh_TW.yaml | 23 + lib/page/library/book_detail_card.dart | 70 ++- lib/page/library/borrow_info_card.dart | 17 +- lib/page/library/library_window.dart | 14 + lib/page/library/scan_borrow_window.dart | 585 ++++++++++++++++++ lib/page/library/search_book_window.dart | 21 +- .../xidian_ids/library_session.dart | 184 +++++- 9 files changed, 938 insertions(+), 22 deletions(-) create mode 100644 lib/page/library/scan_borrow_window.dart diff --git a/assets/flutter_i18n/en_US.yaml b/assets/flutter_i18n/en_US.yaml index d4feac76..5a84ce6a 100644 --- a/assets/flutter_i18n/en_US.yaml +++ b/assets/flutter_i18n/en_US.yaml @@ -638,6 +638,29 @@ library: search_here: Search here book_detail: Book details no_result: No result, change parameter or start your search + scan_borrow: Scan to Borrow + toggle_flash: Toggle flash + scan_book_barcode: Align the book barcode, then confirm borrowing + scan_expected_book_barcode: 'Scan the holding barcode on "{book}"' + scan_barcode_help: Keep the one-dimensional holding barcode horizontal and fill the frame as much as possible + loading_scanned_book: Loading book information + scan_book_found: Book identified + scan_borrow_available: "Book identified. Borrowable location: {location}" + scan_borrow_unavailable: "Book identified, but unavailable now: {status}" + scan_borrow_no_copy: Book identified, but no borrowable copy was found + scan_expected_book_mismatch: 'Scanned "{actual}", not the book selected in search: "{expected}"' + confirm_borrow_title: Borrow this book? + confirm_borrow: Confirm borrow + confirm_borrow_real_action: Confirm borrow (will really borrow) + borrow_submitting_notice: Submitting borrow request, please wait + rescan: Scan again + scanned_barcode: "Barcode: {barcode}" + borrow_this_book: Borrow this book + scan_to_borrow_this_book: Scan to borrow this book + search_borrow_warning: Search results cannot be borrowed directly to avoid mistakes. Take the book in the library, scan its holding barcode, then the final confirm action will really submit the borrow request. + borrow_unavailable: No borrowable copy + borrowing_book: Requesting borrow + confirm_search_borrow_message: 'Borrow "{book}" from {location}?' library_card: title: Library status fetching: Fetching diff --git a/assets/flutter_i18n/zh_CN.yaml b/assets/flutter_i18n/zh_CN.yaml index 80c89e5e..27964447 100644 --- a/assets/flutter_i18n/zh_CN.yaml +++ b/assets/flutter_i18n/zh_CN.yaml @@ -568,6 +568,29 @@ library: search_here: 在此搜索 book_detail: 书籍详细信息 no_result: 没有结果,请修改搜索参数或者开始你的搜索 + scan_borrow: 扫码借书 + toggle_flash: 切换闪光灯 + scan_book_barcode: 对准图书条码,扫描后确认借书 + scan_expected_book_barcode: 请扫描《{book}》书上的馆藏条码 + scan_barcode_help: 请横向对准图书上的一维馆藏条码,尽量让条码充满框内 + loading_scanned_book: 正在读取图书信息 + scan_book_found: 已识别图书 + scan_borrow_available: 已识别图书,可借位置:{location} + scan_borrow_unavailable: 已识别图书,但当前不可借:{status} + scan_borrow_no_copy: 已识别图书,但没有找到可借馆藏 + scan_expected_book_mismatch: 扫到的是《{actual}》,不是你在搜索页选择的《{expected}》 + confirm_borrow_title: 确认借阅这本书? + confirm_borrow: 确认借书 + confirm_borrow_real_action: 确认借书(将真实借出) + borrow_submitting_notice: 正在提交借书申请,请稍候 + rescan: 重新扫描 + scanned_barcode: 条码:{barcode} + borrow_this_book: 借阅此书 + scan_to_borrow_this_book: 扫码借阅此书 + search_borrow_warning: 为避免误借,搜索结果不能直接借出。请到馆拿到这本书后,扫描书上的馆藏条码;最终点击确认借书会真实发起借阅。 + borrow_unavailable: 暂无可借馆藏 + borrowing_book: 正在申请借书 + confirm_search_borrow_message: 确认借阅《{book}》?馆藏位置:{location} library_card: title: 图书馆当前状况 fetching: 正在获取图书馆信息 diff --git a/assets/flutter_i18n/zh_TW.yaml b/assets/flutter_i18n/zh_TW.yaml index 3f466097..7cb6d446 100644 --- a/assets/flutter_i18n/zh_TW.yaml +++ b/assets/flutter_i18n/zh_TW.yaml @@ -566,6 +566,29 @@ library: search_here: 在此搜索 book_detail: 書籍詳細信息 no_result: 沒有結果,請修改搜索參數或者開始你的搜索 + scan_borrow: 掃碼借書 + toggle_flash: 切換閃光燈 + scan_book_barcode: 對準圖書條碼,掃描後確認借書 + scan_expected_book_barcode: 請掃描《{book}》書上的館藏條碼 + scan_barcode_help: 請橫向對準圖書上的一維館藏條碼,盡量讓條碼充滿框內 + loading_scanned_book: 正在讀取圖書信息 + scan_book_found: 已識別圖書 + scan_borrow_available: 已識別圖書,可借位置:{location} + scan_borrow_unavailable: 已識別圖書,但當前不可借:{status} + scan_borrow_no_copy: 已識別圖書,但沒有找到可借館藏 + scan_expected_book_mismatch: 掃到的是《{actual}》,不是你在搜索頁選擇的《{expected}》 + confirm_borrow_title: 確認借閱這本書? + confirm_borrow: 確認借書 + confirm_borrow_real_action: 確認借書(將真實借出) + borrow_submitting_notice: 正在提交借書申請,請稍候 + rescan: 重新掃描 + scanned_barcode: 條碼:{barcode} + borrow_this_book: 借閱此書 + scan_to_borrow_this_book: 掃碼借閱此書 + search_borrow_warning: 為避免誤借,搜索結果不能直接借出。請到館拿到這本書後,掃描書上的館藏條碼;最終點擊確認借書會真實發起借閱。 + borrow_unavailable: 暫無可借館藏 + borrowing_book: 正在申請借書 + confirm_search_borrow_message: 確認借閱《{book}》?館藏位置:{location} library_card: title: 圖書館當前狀況 fetching: 正在獲取圖書館信息 diff --git a/lib/page/library/book_detail_card.dart b/lib/page/library/book_detail_card.dart index 1b933938..3021c8bb 100644 --- a/lib/page/library/book_detail_card.dart +++ b/lib/page/library/book_detail_card.dart @@ -14,14 +14,78 @@ import 'package:watermeter/repository/logger.dart'; class BookDetailCard extends StatefulWidget { final BookInfo toUse; + final bool showBorrowAction; + final void Function(BuildContext context, BookInfo bookInfo)? onBorrowRequest; - const BookDetailCard({super.key, required this.toUse}); + const BookDetailCard({ + super.key, + required this.toUse, + this.showBorrowAction = true, + this.onBorrowRequest, + }); @override State createState() => _BookDetailCardState(); } class _BookDetailCardState extends State { + BookLocation? get _firstBorrowableLocation { + final items = widget.toUse.items; + if (items == null) return null; + for (final item in items) { + if (item.processType == "在架" && (item.barCode?.isNotEmpty ?? false)) { + return item; + } + } + return null; + } + + Widget _buildBorrowAction(BuildContext context) { + final borrowableLocation = _firstBorrowableLocation; + if (borrowableLocation == null) { + return OutlinedButton.icon( + onPressed: null, + icon: const Icon(Icons.block), + label: Text( + FlutterI18n.translate(context, "library.borrow_unavailable"), + ), + ); + } + + return [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: [ + Icon( + Icons.warning_rounded, + color: Theme.of(context).colorScheme.onErrorContainer, + ), + const SizedBox(width: 10), + Text( + FlutterI18n.translate(context, "library.search_borrow_warning"), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onErrorContainer, + ), + ).expanded(), + ].toRow(), + ), + const SizedBox(height: 8), + FilledButton.icon( + onPressed: widget.onBorrowRequest == null + ? null + : () => widget.onBorrowRequest!(context, widget.toUse), + icon: const Icon(Icons.qr_code_scanner), + label: Text( + FlutterI18n.translate(context, "library.scan_to_borrow_this_book"), + ), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch); + } + @override Widget build(BuildContext context) { return ListView( @@ -241,6 +305,10 @@ class _BookDetailCardState extends State { .flexible(), ], ), + if (widget.showBorrowAction) ...[ + const SizedBox(height: 12), + _buildBorrowAction(context), + ], Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: Column( diff --git a/lib/page/library/borrow_info_card.dart b/lib/page/library/borrow_info_card.dart index 21691848..504d8f4b 100644 --- a/lib/page/library/borrow_info_card.dart +++ b/lib/page/library/borrow_info_card.dart @@ -9,6 +9,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_i18n/flutter_i18n.dart'; import 'package:intl/intl.dart'; +import 'package:watermeter/controller/library_controller.dart'; import 'package:watermeter/page/public_widget/toast.dart'; import 'package:sn_progress_dialog/progress_dialog.dart'; import 'package:styled_widget/styled_widget.dart'; @@ -181,18 +182,26 @@ class BorrowInfoCard extends StatelessWidget { ), ).padding(bottom: 10); final button = TextButton( - onPressed: () { + onPressed: () async { if (!isOverdue) { ProgressDialog pd = ProgressDialog(context: context); pd.show( msg: FlutterI18n.translate(context, "library.renewing"), ); - LibrarySession().renew(toUse).then((value) { + try { + final value = await LibrarySession().renew(toUse); + if (!context.mounted) return; + showToast(context: context, msg: value); + await LibraryController.i.reloadBorrowList(); + } catch (e, s) { + log.handle(e, s, "[BorrowInfoCard][renew] Failed."); + if (!context.mounted) return; + showToast(context: context, msg: e.toString()); + } finally { if (context.mounted) { pd.close(); - showToast(context: context, msg: value); } - }); + } } }, child: Text( diff --git a/lib/page/library/library_window.dart b/lib/page/library/library_window.dart index 180141ab..ea34feb5 100644 --- a/lib/page/library/library_window.dart +++ b/lib/page/library/library_window.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_i18n/flutter_i18n.dart'; import 'package:watermeter/page/library/borrow_list_window.dart'; +import 'package:watermeter/page/library/scan_borrow_window.dart'; import 'package:watermeter/page/library/search_book_window.dart'; class LibraryWindow extends StatelessWidget { @@ -18,6 +19,19 @@ class LibraryWindow extends StatelessWidget { child: Scaffold( appBar: AppBar( title: Text(FlutterI18n.translate(context, "library.title")), + actions: [ + IconButton( + icon: const Icon(Icons.qr_code_scanner), + tooltip: FlutterI18n.translate(context, "library.scan_borrow"), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const ScanBorrowWindow(), + ), + ); + }, + ), + ], bottom: TabBar( tabs: [ Tab( diff --git a/lib/page/library/scan_borrow_window.dart b/lib/page/library/scan_borrow_window.dart new file mode 100644 index 00000000..f327218f --- /dev/null +++ b/lib/page/library/scan_borrow_window.dart @@ -0,0 +1,585 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +import 'package:flutter/material.dart'; +import 'package:flutter_i18n/flutter_i18n.dart'; +import 'package:ming_cute_icons/ming_cute_icons.dart'; +import 'package:qr_code_dart_scan/qr_code_dart_scan.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:watermeter/controller/library_controller.dart'; +import 'package:watermeter/model/xidian_ids/library.dart'; +import 'package:watermeter/page/library/book_detail_card.dart'; +import 'package:watermeter/page/public_widget/toast.dart'; +import 'package:watermeter/repository/logger.dart'; +import 'package:watermeter/repository/xidian_ids/library_session.dart'; + +class ScanBorrowWindow extends StatefulWidget { + final BookInfo? expectedBook; + + const ScanBorrowWindow({super.key, this.expectedBook}); + + @override + State createState() => _ScanBorrowWindowState(); +} + +class _ScanBorrowWindowState extends State { + final QRCodeDartScanController _scannerController = + QRCodeDartScanController(); + final LibrarySession _session = LibrarySession(); + + bool _flashOn = false; + bool _handlingScan = false; + bool _loadingBook = false; + bool _submitting = false; + String? _barcode; + BookInfo? _bookInfo; + + @override + void dispose() { + _scannerController.dispose(); + super.dispose(); + } + + Future _handleCapture(Result result) async { + if (_handlingScan) return; + + final barcode = result.text.trim(); + if (barcode.isEmpty) return; + + _handlingScan = true; + await _scannerController.stopScan(); + if (!mounted) return; + + setState(() { + _barcode = barcode; + _bookInfo = null; + _loadingBook = true; + }); + + try { + final book = await _session.getScannedBorrowBook(barcode); + if (!mounted) return; + setState(() { + _bookInfo = book; + }); + showToast(context: context, msg: _borrowStatusFor(context, book).message); + } catch (e, s) { + log.handle(e, s, "[ScanBorrowWindow][_handleCapture] Failed."); + if (!mounted) return; + showToast(context: context, msg: e.toString()); + await _resumeScan(); + } finally { + if (mounted) { + setState(() { + _loadingBook = false; + }); + } + } + } + + Future _resumeScan() async { + setState(() { + _barcode = null; + _bookInfo = null; + _handlingScan = false; + _loadingBook = false; + _submitting = false; + }); + await _scannerController.startScan(); + } + + BookLocation? _findLocationByBarcode(BookInfo bookInfo, String barcode) { + final items = bookInfo.items; + final normalizedBarcode = barcode.trim(); + if (items == null || normalizedBarcode.isEmpty) return null; + + for (final item in items) { + if (item.barCode?.trim() == normalizedBarcode) { + return item; + } + } + return null; + } + + BookLocation? _firstBorrowableLocation(BookInfo bookInfo) { + final items = bookInfo.items; + if (items == null) return null; + + for (final item in items) { + if (_isBorrowable(item)) { + return item; + } + } + return null; + } + + bool _isBorrowable(BookLocation location) => + location.processType == "在架" && (location.barCode?.isNotEmpty ?? false); + + bool _matchesExpectedBook(BookInfo bookInfo) { + final expectedBook = widget.expectedBook; + if (expectedBook == null) return true; + if (expectedBook.docNumber == bookInfo.docNumber) return true; + + final expectedIsbn = expectedBook.isbn?.trim(); + final scannedIsbn = bookInfo.isbn?.trim(); + if (expectedIsbn != null && + expectedIsbn.isNotEmpty && + scannedIsbn != null && + scannedIsbn.isNotEmpty && + expectedIsbn == scannedIsbn && + expectedBook.bookName == bookInfo.bookName) { + return true; + } + + return false; + } + + String _firstSearchCode(BookInfo bookInfo) { + final searchCodes = bookInfo.searchCode; + if (searchCodes == null) return ""; + + for (final searchCode in searchCodes) { + if (searchCode.isNotEmpty) return searchCode; + } + return ""; + } + + String _locationLabel(BuildContext context, BookLocation? location) => + location?.locationName ?? + FlutterI18n.translate(context, "library.not_provided"); + + String _unavailableReason(BookLocation location) { + final processType = location.processType.trim(); + if (processType.isNotEmpty && processType != "在架") { + return processType; + } + + final noBorrowMessages = location.noBorrowMessages?.trim(); + if (noBorrowMessages != null && noBorrowMessages.isNotEmpty) { + return noBorrowMessages; + } + + final borrowStatus = location.borrowStatus?.trim(); + if (borrowStatus != null && borrowStatus.isNotEmpty) { + return borrowStatus; + } + + return location.circAttr; + } + + _ScanBorrowStatus _borrowStatusFor(BuildContext context, BookInfo bookInfo) { + final expectedBook = widget.expectedBook; + if (expectedBook != null && !_matchesExpectedBook(bookInfo)) { + return _ScanBorrowStatus( + canBorrow: false, + location: null, + message: FlutterI18n.translate( + context, + "library.scan_expected_book_mismatch", + translationParams: { + "expected": expectedBook.bookName, + "actual": bookInfo.bookName, + }, + ), + ); + } + + final barcode = _barcode?.trim() ?? ""; + final scannedLocation = _findLocationByBarcode(bookInfo, barcode); + + if (scannedLocation != null) { + if (_isBorrowable(scannedLocation)) { + return _ScanBorrowStatus( + canBorrow: true, + location: scannedLocation, + message: FlutterI18n.translate( + context, + "library.scan_borrow_available", + translationParams: { + "location": _locationLabel(context, scannedLocation), + }, + ), + ); + } + + return _ScanBorrowStatus( + canBorrow: false, + location: scannedLocation, + message: FlutterI18n.translate( + context, + "library.scan_borrow_unavailable", + translationParams: {"status": _unavailableReason(scannedLocation)}, + ), + ); + } + + final borrowableLocation = _firstBorrowableLocation(bookInfo); + if (borrowableLocation != null) { + return _ScanBorrowStatus( + canBorrow: true, + location: borrowableLocation, + message: FlutterI18n.translate( + context, + "library.scan_borrow_available", + translationParams: { + "location": _locationLabel(context, borrowableLocation), + }, + ), + ); + } + + return _ScanBorrowStatus( + canBorrow: false, + location: null, + message: FlutterI18n.translate(context, "library.scan_borrow_no_copy"), + ); + } + + Future _submitBorrow() async { + final barcode = _barcode; + final bookInfo = _bookInfo; + if (barcode == null || bookInfo == null || _submitting) return; + + final borrowStatus = _borrowStatusFor(context, bookInfo); + if (!borrowStatus.canBorrow) { + showToast(context: context, msg: borrowStatus.message); + return; + } + + setState(() { + _submitting = true; + }); + showToast( + context: context, + msg: FlutterI18n.translate(context, "library.borrow_submitting_notice"), + ); + + try { + final message = await _session.borrowBook( + barcode: barcode, + bookInfo: bookInfo, + searchCode: + borrowStatus.location?.searchCode ?? _firstSearchCode(bookInfo), + ); + if (!mounted) return; + showToast(context: context, msg: message); + await LibraryController.i.reloadBorrowList(); + if (!mounted) return; + Navigator.of(context).pop(); + } catch (e, s) { + log.handle(e, s, "[ScanBorrowWindow][_submitBorrow] Failed."); + if (!mounted) return; + showToast(context: context, msg: e.toString()); + setState(() { + _submitting = false; + }); + } + } + + Future _toggleFlash() async { + await _scannerController.toggleFlash(); + if (!mounted) return; + setState(() { + _flashOn = _scannerController.isFlashOn; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(FlutterI18n.translate(context, "library.scan_borrow")), + actions: [ + IconButton( + icon: Icon(_flashOn ? Icons.flash_on : Icons.flash_off), + onPressed: _toggleFlash, + tooltip: FlutterI18n.translate(context, "library.toggle_flash"), + ), + ], + ), + body: Stack( + children: [ + Positioned.fill( + child: QRCodeDartScanView( + controller: _scannerController, + resolutionPreset: QRCodeDartScanResolutionPreset.veryHigh, + imageDecodeOrientation: ImageDecodeOrientation.portrait, + intervalScan: const Duration(milliseconds: 400), + onCapture: _handleCapture, + child: const ColoredBox(color: Colors.black), + ), + ), + Positioned.fill(child: IgnorePointer(child: _ScanOverlay())), + Positioned( + left: 16, + right: 16, + bottom: 24, + child: SafeArea(child: _buildBottomPanel(context)), + ), + ], + ), + ); + } + + Widget _buildBottomPanel(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + if (_loadingBook) { + return Card( + child: [ + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + Text(FlutterI18n.translate(context, "library.loading_scanned_book")), + ].toRow().padding(all: 16), + ); + } + + final bookInfo = _bookInfo; + if (bookInfo == null) { + return Card( + color: colorScheme.surface.withValues(alpha: 0.94), + child: [ + Icon(MingCuteIcons.mgc_barcode_line, color: colorScheme.primary), + const SizedBox(width: 12), + [ + Text( + widget.expectedBook == null + ? FlutterI18n.translate(context, "library.scan_book_barcode") + : FlutterI18n.translate( + context, + "library.scan_expected_book_barcode", + translationParams: { + "book": widget.expectedBook!.bookName, + }, + ), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + FlutterI18n.translate(context, "library.scan_barcode_help"), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch).expanded(), + ].toRow().padding(all: 16), + ); + } + + final borrowStatus = _borrowStatusFor(context, bookInfo); + return Card( + color: colorScheme.surface.withValues(alpha: 0.97), + child: SizedBox( + height: MediaQuery.sizeOf(context).height * 0.64, + child: + [ + Text( + FlutterI18n.translate(context, "library.scan_book_found"), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 10), + _buildBorrowStatusBanner(context, borrowStatus), + const SizedBox(height: 12), + BookDetailCard( + toUse: bookInfo, + showBorrowAction: false, + ).expanded(), + const SizedBox(height: 12), + [ + OutlinedButton( + onPressed: _submitting ? null : _resumeScan, + child: Text( + FlutterI18n.translate(context, "library.rescan"), + ), + ).expanded(), + const SizedBox(width: 12), + FilledButton( + onPressed: _submitting || !borrowStatus.canBorrow + ? null + : _submitBorrow, + child: _submitting + ? [ + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + const SizedBox(width: 8), + Text( + FlutterI18n.translate( + context, + "library.borrowing_book", + ), + ), + ].toRow(mainAxisAlignment: MainAxisAlignment.center) + : Text( + FlutterI18n.translate( + context, + "library.confirm_borrow_real_action", + ), + ), + ).expanded(), + ].toRow(), + ] + .toColumn(crossAxisAlignment: CrossAxisAlignment.stretch) + .padding(all: 16), + ), + ); + } + + Widget _buildBorrowStatusBanner( + BuildContext context, + _ScanBorrowStatus borrowStatus, + ) { + final colorScheme = Theme.of(context).colorScheme; + final foreground = borrowStatus.canBorrow + ? Colors.green.shade900 + : colorScheme.onErrorContainer; + final background = borrowStatus.canBorrow + ? Colors.green.shade100 + : colorScheme.errorContainer; + final barcode = _barcode ?? ""; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(12), + ), + child: [ + Icon( + borrowStatus.canBorrow ? Icons.check_circle : Icons.info, + color: foreground, + ), + const SizedBox(width: 10), + [ + Text( + borrowStatus.message, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: foreground, + fontWeight: FontWeight.w600, + ), + ), + if (barcode.isNotEmpty) + Text( + FlutterI18n.translate( + context, + "library.scanned_barcode", + translationParams: {"barcode": barcode}, + ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: foreground), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch).expanded(), + ].toRow(), + ); + } +} + +class _ScanBorrowStatus { + final bool canBorrow; + final BookLocation? location; + final String message; + + const _ScanBorrowStatus({ + required this.canBorrow, + required this.location, + required this.message, + }); +} + +class _ScanOverlay extends StatefulWidget { + @override + State<_ScanOverlay> createState() => _ScanOverlayState(); +} + +class _ScanOverlayState extends State<_ScanOverlay> + with SingleTickerProviderStateMixin { + late final AnimationController _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1600), + )..repeat(reverse: true); + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.2), + ), + ), + ), + Center( + child: SizedBox( + width: (MediaQuery.sizeOf(context).width - 48) + .clamp(280.0, 420.0) + .toDouble(), + height: 170, + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: Stack( + fit: StackFit.expand, + children: [ + AnimatedBuilder( + animation: _animationController, + builder: (context, child) => Align( + alignment: Alignment( + 0, + -0.92 + _animationController.value * 1.84, + ), + child: child, + ), + child: Container( + height: 4, + margin: const EdgeInsets.symmetric(horizontal: 18), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + gradient: LinearGradient( + colors: [ + colorScheme.primary.withValues(alpha: 0.0), + colorScheme.primary.withValues(alpha: 0.95), + colorScheme.primary.withValues(alpha: 0.0), + ], + ), + boxShadow: [ + BoxShadow( + color: colorScheme.primary.withValues(alpha: 0.55), + blurRadius: 14, + spreadRadius: 2, + ), + ], + ), + ), + ), + DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + border: Border.all(color: colorScheme.primary, width: 3), + ), + ), + ], + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/page/library/search_book_window.dart b/lib/page/library/search_book_window.dart index 50aa0e51..6ce92e43 100644 --- a/lib/page/library/search_book_window.dart +++ b/lib/page/library/search_book_window.dart @@ -14,6 +14,7 @@ import 'package:watermeter/repository/xidian_ids/library_session.dart' import 'package:watermeter/model/xidian_ids/library.dart'; import 'package:watermeter/page/library/book_detail_card.dart'; import 'package:watermeter/page/library/book_info_card.dart'; +import 'package:watermeter/page/library/scan_borrow_window.dart'; enum SearchField { keyWord, title, author, isbn, barcode, callNo } @@ -191,7 +192,25 @@ class _SearchBookWindowState extends State context, "library.book_detail", ), - child: BookDetailCard(toUse: item), + child: BookDetailCard( + toUse: item, + onBorrowRequest: (sheetContext, bookInfo) { + final navigator = Navigator.of(context); + Navigator.of(sheetContext).pop(); + WidgetsBinding.instance + .addPostFrameCallback((_) { + if (!context.mounted) return; + navigator.push( + MaterialPageRoute( + builder: (context) => + ScanBorrowWindow( + expectedBook: bookInfo, + ), + ), + ); + }); + }, + ), ), ) .padding(horizontal: 12, vertical: 2) diff --git a/lib/repository/xidian_ids/library_session.dart b/lib/repository/xidian_ids/library_session.dart index 3eee20bb..26cff876 100644 --- a/lib/repository/xidian_ids/library_session.dart +++ b/lib/repository/xidian_ids/library_session.dart @@ -104,22 +104,163 @@ class LibrarySession extends IDSSession { ) .then((value) => value.data["data"]["duxiuImageUrl"]?.toString() ?? ""); - Future renew(BorrowData toUse) => dio - .post( - "https://shuwo.xidian.edu.cn/xidian_book/api/borrow/renewBook.html", - data: { - "libraryId": 5, - "userId": userId, - "token": token, - "cardNumber": preference.getString(preference.Preference.idsAccount), - "barNumber": toUse.barcode, - }, - ) - .then((value) => value.data["msg"]?.toString() ?? "接口返回错误") - .onError((e, s) { - log.handle(e, s); - return "获取过程遇到错误"; - }); + Future renew(BorrowData toUse) async { + if (userId == 0 || token.isEmpty) { + await initSession(); + } + + final response = await dio.post( + "https://shuwo.xidian.edu.cn/xidian_book/api/borrow/renewBook.html", + data: { + "libraryId": 5, + "userId": userId, + "token": token, + "cardNumber": preference.getString(preference.Preference.idsAccount), + "barNumber": toUse.barcode, + "bookName": toUse.title, + "isbn": toUse.isbn, + "author": toUse.author, + }, + ); + + final data = response.data; + _throwIfLibrarySessionExpired(data); + if (data is Map && data["code"]?.toString() == "1") { + return "续借成功"; + } + + throw LibraryOperationException( + message: data is Map ? data["msg"]?.toString() ?? "续借失败" : "续借失败", + ); + } + + Future getScannedBorrowBook(String barcode) async { + if (barcode.isEmpty) { + throw LibraryOperationException(message: "图书条码为空"); + } + if (userId == 0 || token.isEmpty) { + await initSession(); + } + + final response = await dio.post( + "https://shuwo.xidian.edu.cn/xidian_book/api/search/list.html", + data: { + "libraryId": 5, + "userId": userId, + "token": token, + "cardNumber": preference.getString(preference.Preference.idsAccount), + "searchWord": barcode, + "searchFiled": "barcode", + }, + ); + + final data = response.data; + _throwIfLibrarySessionExpired(data); + if (data is Map && data["code"] != null && data["code"] != 1) { + throw LibraryOperationException( + message: data["msg"]?.toString() ?? "查询图书失败", + ); + } + + final rawList = data["data"]?["list"]; + if (rawList is! List || rawList.isEmpty) { + throw LibraryOperationException(message: "未查找到相关书籍"); + } + + final book = BookInfo.fromJson(rawList.first as Map); + book.imageUrl = await bookCover( + book.bookName, + book.isbn ?? "", + book.docNumber, + ); + return book; + } + + Future borrowScannedBook({ + required String barcode, + required BookInfo bookInfo, + }) => borrowBook(barcode: barcode, bookInfo: bookInfo); + + Future borrowBook({ + required String barcode, + required BookInfo bookInfo, + String? searchCode, + }) async { + if (barcode.isEmpty) { + throw LibraryOperationException(message: "图书条码为空"); + } + if (userId == 0 || token.isEmpty) { + await initSession(); + } + + final userBarcode = await _getUserBarcode(); + final searchCodeToUse = searchCode ?? bookInfo.searchCode?.firstOrNull; + if (searchCodeToUse == null || searchCodeToUse.isEmpty) { + throw LibraryOperationException(message: "缺少索书号,无法申请借书"); + } + + final response = await dio.post( + "https://shuwo.xidian.edu.cn/xidian_book/api/borrow/borrow.html", + data: { + "libraryId": 5, + "userId": userId, + "token": token, + "barNumber": barcode, + "cardNumber": userBarcode, + "bookName": bookInfo.bookName, + "author": bookInfo.author ?? "", + "searchCode": searchCodeToUse, + }, + ); + + final data = response.data; + _throwIfLibrarySessionExpired(data); + if (data is Map && data["code"] == 1) { + return data["msg"]?.toString() ?? data["data"]?.toString() ?? "借书申请已提交"; + } + throw LibraryOperationException( + message: data is Map ? data["msg"]?.toString() ?? "申请借书失败" : "申请借书失败", + ); + } + + Future _getUserBarcode() async { + if (userId == 0 || token.isEmpty) { + await initSession(); + } + + final response = await dio.post( + "https://shuwo.xidian.edu.cn/xidian_book/api/borrow/getUserInfo", + data: { + "libraryId": 5, + "userId": userId, + "token": token, + "cardNumber": preference.getString(preference.Preference.idsAccount), + }, + ); + + final data = response.data; + _throwIfLibrarySessionExpired(data); + if (data is Map && data["code"] == 1) { + final userBarcode = data["data"]?["userBarcode"]?.toString(); + if (userBarcode != null && userBarcode.isNotEmpty) { + return userBarcode; + } + } + + throw LibraryOperationException( + message: data is Map ? data["msg"]?.toString() ?? "获取读者条码失败" : "获取读者条码失败", + ); + } + + void _throwIfLibrarySessionExpired(dynamic data) { + if (data is Map && data["code"] == -1) { + userId = 0; + token = ""; + throw NotFetchLibraryException( + message: data["msg"]?.toString() ?? "图书馆登录已失效", + ); + } + } Future> getBorrowList() async { log.info( @@ -391,4 +532,15 @@ class LibrarySession extends IDSSession { class NotFetchLibraryException implements Exception { final String message; NotFetchLibraryException({this.message = "Error detected."}); + + @override + String toString() => message; +} + +class LibraryOperationException implements Exception { + final String message; + LibraryOperationException({required this.message}); + + @override + String toString() => message; } From d62235e61572a7ab85f33aef9159f1a01c08ef85 Mon Sep 17 00:00:00 2001 From: clever-max <2942247417@qq.com> Date: Sat, 4 Jul 2026 00:08:25 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(library):=20=E7=BB=9F=E4=B8=80=E6=9C=AF?= =?UTF-8?q?=E8=AF=AD=E3=80=8C=E5=9B=BE=E4=B9=A6=E6=9D=A1=E7=A0=81=E3=80=8D?= =?UTF-8?q?=E2=86=92=E3=80=8C=E9=A6=86=E8=97=8F=E6=9D=A1=E7=A0=81/holding?= =?UTF-8?q?=20barcode=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/flutter_i18n/en_US.yaml | 2 +- assets/flutter_i18n/zh_CN.yaml | 2 +- assets/flutter_i18n/zh_TW.yaml | 2 +- lib/repository/xidian_ids/library_session.dart | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/assets/flutter_i18n/en_US.yaml b/assets/flutter_i18n/en_US.yaml index 5a84ce6a..42976843 100644 --- a/assets/flutter_i18n/en_US.yaml +++ b/assets/flutter_i18n/en_US.yaml @@ -640,7 +640,7 @@ library: no_result: No result, change parameter or start your search scan_borrow: Scan to Borrow toggle_flash: Toggle flash - scan_book_barcode: Align the book barcode, then confirm borrowing + scan_book_barcode: Align the holding barcode, then confirm borrowing scan_expected_book_barcode: 'Scan the holding barcode on "{book}"' scan_barcode_help: Keep the one-dimensional holding barcode horizontal and fill the frame as much as possible loading_scanned_book: Loading book information diff --git a/assets/flutter_i18n/zh_CN.yaml b/assets/flutter_i18n/zh_CN.yaml index 27964447..cc1af54b 100644 --- a/assets/flutter_i18n/zh_CN.yaml +++ b/assets/flutter_i18n/zh_CN.yaml @@ -570,7 +570,7 @@ library: no_result: 没有结果,请修改搜索参数或者开始你的搜索 scan_borrow: 扫码借书 toggle_flash: 切换闪光灯 - scan_book_barcode: 对准图书条码,扫描后确认借书 + scan_book_barcode: 对准馆藏条码,扫描后确认借书 scan_expected_book_barcode: 请扫描《{book}》书上的馆藏条码 scan_barcode_help: 请横向对准图书上的一维馆藏条码,尽量让条码充满框内 loading_scanned_book: 正在读取图书信息 diff --git a/assets/flutter_i18n/zh_TW.yaml b/assets/flutter_i18n/zh_TW.yaml index 7cb6d446..4dcd617c 100644 --- a/assets/flutter_i18n/zh_TW.yaml +++ b/assets/flutter_i18n/zh_TW.yaml @@ -568,7 +568,7 @@ library: no_result: 沒有結果,請修改搜索參數或者開始你的搜索 scan_borrow: 掃碼借書 toggle_flash: 切換閃光燈 - scan_book_barcode: 對準圖書條碼,掃描後確認借書 + scan_book_barcode: 對準館藏條碼,掃描後確認借書 scan_expected_book_barcode: 請掃描《{book}》書上的館藏條碼 scan_barcode_help: 請橫向對準圖書上的一維館藏條碼,盡量讓條碼充滿框內 loading_scanned_book: 正在讀取圖書信息 diff --git a/lib/repository/xidian_ids/library_session.dart b/lib/repository/xidian_ids/library_session.dart index 26cff876..ba1ab0b0 100644 --- a/lib/repository/xidian_ids/library_session.dart +++ b/lib/repository/xidian_ids/library_session.dart @@ -136,7 +136,7 @@ class LibrarySession extends IDSSession { Future getScannedBorrowBook(String barcode) async { if (barcode.isEmpty) { - throw LibraryOperationException(message: "图书条码为空"); + throw LibraryOperationException(message: "馆藏条码为空"); } if (userId == 0 || token.isEmpty) { await initSession(); @@ -187,7 +187,7 @@ class LibrarySession extends IDSSession { String? searchCode, }) async { if (barcode.isEmpty) { - throw LibraryOperationException(message: "图书条码为空"); + throw LibraryOperationException(message: "馆藏条码为空"); } if (userId == 0 || token.isEmpty) { await initSession(); From 57f41adad6de36a06708a665269793ed50d659ad Mon Sep 17 00:00:00 2001 From: clever-max <2942247417@qq.com> Date: Sat, 4 Jul 2026 22:34:00 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(library):=20=E4=BF=AE=E5=A4=8D=E7=A9=BA?= =?UTF-8?q?toast=E3=80=81=E5=BC=82=E5=B8=B8=E5=9B=BD=E9=99=85=E5=8C=96?= =?UTF-8?q?=E3=80=81=E7=A1=AE=E8=AE=A4=E5=BC=B9=E7=AA=97=E3=80=81=E7=BC=96?= =?UTF-8?q?=E6=8E=92=E5=8F=B7=E7=A0=81=E7=A7=BB=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/flutter_i18n/en_US.yaml | 9 ++ assets/flutter_i18n/zh_CN.yaml | 9 ++ assets/flutter_i18n/zh_TW.yaml | 9 ++ lib/page/library/book_detail_card.dart | 24 ---- lib/page/library/borrow_confirm_dialog.dart | 133 ++++++++++++++++++ lib/page/library/borrow_info_card.dart | 5 +- lib/page/library/scan_borrow_window.dart | 36 +++-- .../xidian_ids/library_session.dart | 37 ++++- 8 files changed, 222 insertions(+), 40 deletions(-) create mode 100644 lib/page/library/borrow_confirm_dialog.dart diff --git a/assets/flutter_i18n/en_US.yaml b/assets/flutter_i18n/en_US.yaml index 42976843..2462111e 100644 --- a/assets/flutter_i18n/en_US.yaml +++ b/assets/flutter_i18n/en_US.yaml @@ -649,6 +649,7 @@ library: scan_borrow_unavailable: "Book identified, but unavailable now: {status}" scan_borrow_no_copy: Book identified, but no borrowable copy was found scan_expected_book_mismatch: 'Scanned "{actual}", not the book selected in search: "{expected}"' + scan_borrow_fallback: Book identified, borrowable (exact copy location not matched) confirm_borrow_title: Borrow this book? confirm_borrow: Confirm borrow confirm_borrow_real_action: Confirm borrow (will really borrow) @@ -661,6 +662,14 @@ library: borrow_unavailable: No borrowable copy borrowing_book: Requesting borrow confirm_search_borrow_message: 'Borrow "{book}" from {location}?' + error_barcode_empty: Holding barcode is empty + error_search_failed: Book search failed + error_book_not_found: No matching books found + error_no_search_code: Missing call number, cannot borrow + error_user_barcode_failed: Failed to get reader barcode + error_borrow_failed: Borrow request failed + error_renew_failed: Renew failed + error_session_expired: Library session expired library_card: title: Library status fetching: Fetching diff --git a/assets/flutter_i18n/zh_CN.yaml b/assets/flutter_i18n/zh_CN.yaml index cc1af54b..eb4c9a11 100644 --- a/assets/flutter_i18n/zh_CN.yaml +++ b/assets/flutter_i18n/zh_CN.yaml @@ -579,6 +579,7 @@ library: scan_borrow_unavailable: 已识别图书,但当前不可借:{status} scan_borrow_no_copy: 已识别图书,但没有找到可借馆藏 scan_expected_book_mismatch: 扫到的是《{actual}》,不是你在搜索页选择的《{expected}》 + scan_borrow_fallback: 已识别图书,可借(未能匹配精确馆藏位置) confirm_borrow_title: 确认借阅这本书? confirm_borrow: 确认借书 confirm_borrow_real_action: 确认借书(将真实借出) @@ -591,6 +592,14 @@ library: borrow_unavailable: 暂无可借馆藏 borrowing_book: 正在申请借书 confirm_search_borrow_message: 确认借阅《{book}》?馆藏位置:{location} + error_barcode_empty: 馆藏条码为空 + error_search_failed: 查询图书失败 + error_book_not_found: 未查找到相关书籍 + error_no_search_code: 缺少索书号,无法申请借书 + error_user_barcode_failed: 获取读者条码失败 + error_borrow_failed: 申请借书失败 + error_renew_failed: 续借失败 + error_session_expired: 图书馆登录已失效 library_card: title: 图书馆当前状况 fetching: 正在获取图书馆信息 diff --git a/assets/flutter_i18n/zh_TW.yaml b/assets/flutter_i18n/zh_TW.yaml index 4dcd617c..4db62bf8 100644 --- a/assets/flutter_i18n/zh_TW.yaml +++ b/assets/flutter_i18n/zh_TW.yaml @@ -577,6 +577,7 @@ library: scan_borrow_unavailable: 已識別圖書,但當前不可借:{status} scan_borrow_no_copy: 已識別圖書,但沒有找到可借館藏 scan_expected_book_mismatch: 掃到的是《{actual}》,不是你在搜索頁選擇的《{expected}》 + scan_borrow_fallback: 已識別圖書,可借(未能匹配精確館藏位置) confirm_borrow_title: 確認借閱這本書? confirm_borrow: 確認借書 confirm_borrow_real_action: 確認借書(將真實借出) @@ -589,6 +590,14 @@ library: borrow_unavailable: 暫無可借館藏 borrowing_book: 正在申請借書 confirm_search_borrow_message: 確認借閱《{book}》?館藏位置:{location} + error_barcode_empty: 館藏條碼為空 + error_search_failed: 查詢圖書失敗 + error_book_not_found: 未查找到相關書籍 + error_no_search_code: 缺少索書號,無法申請借書 + error_user_barcode_failed: 獲取讀者條碼失敗 + error_borrow_failed: 申請借書失敗 + error_renew_failed: 續借失敗 + error_session_expired: 圖書館登錄已失效 library_card: title: 圖書館當前狀況 fetching: 正在獲取圖書館信息 diff --git a/lib/page/library/book_detail_card.dart b/lib/page/library/book_detail_card.dart index 3021c8bb..b4ca19e1 100644 --- a/lib/page/library/book_detail_card.dart +++ b/lib/page/library/book_detail_card.dart @@ -276,30 +276,6 @@ class _BookDetailCardState extends State { ], ), ), - Text.rich( - TextSpan( - children: [ - TextSpan( - text: FlutterI18n.translate( - context, - "library.arrangement_code", - ), - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Color(0xFFBFBFBF), - ), - ), - TextSpan( - text: widget.toUse.barCodesStr, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), ] .toColumn(crossAxisAlignment: CrossAxisAlignment.stretch) .flexible(), diff --git a/lib/page/library/borrow_confirm_dialog.dart b/lib/page/library/borrow_confirm_dialog.dart new file mode 100644 index 00000000..4a8363e7 --- /dev/null +++ b/lib/page/library/borrow_confirm_dialog.dart @@ -0,0 +1,133 @@ +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_i18n/flutter_i18n.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class BorrowConfirmDialog extends StatefulWidget { + final String? coverUrl; + final String bookName; + final String? locationName; + + const BorrowConfirmDialog({ + super.key, + this.coverUrl, + required this.bookName, + this.locationName, + }); + + @override + State createState() => _BorrowConfirmDialogState(); +} + +class _BorrowConfirmDialogState extends State { + int _countdown = 5; + Timer? _timer; + + @override + void initState() { + super.initState(); + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (_countdown <= 1) { + _timer?.cancel(); + if (mounted) Navigator.of(context).pop(false); + return; + } + setState(() { + _countdown--; + }); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CachedNetworkImage( + imageUrl: widget.coverUrl ?? "", + placeholder: (_, _) => Image.asset( + "assets/art/pda_empty_cover.jpg", + width: 100, + height: 125, + fit: BoxFit.fill, + ), + errorWidget: (_, _, _) => Image.asset( + "assets/art/pda_empty_cover.jpg", + width: 100, + height: 125, + fit: BoxFit.fill, + ), + width: 100, + height: 125, + fit: BoxFit.fitHeight, + alignment: Alignment.center, + ) + .clipRRect(all: 12) + .padding(all: 2) + .decorated( + border: Border.all(color: const Color(0xFFE8E8E8), width: 2), + borderRadius: const BorderRadius.all(Radius.circular(14)), + ), + const SizedBox(height: 16), + Text( + widget.bookName, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (widget.locationName != null) ...[ + const SizedBox(height: 8), + Text( + widget.locationName!, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + actionsAlignment: MainAxisAlignment.center, + actionsPadding: const EdgeInsets.fromLTRB(16, 4, 16, 16), + actions: [ + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text( + FlutterI18n.translate(context, "library.confirm_borrow"), + ), + ), + const SizedBox(height: 8), + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text( + "${FlutterI18n.translate(context, "cancel")} ($_countdown)", + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/page/library/borrow_info_card.dart b/lib/page/library/borrow_info_card.dart index 504d8f4b..4c56fcd4 100644 --- a/lib/page/library/borrow_info_card.dart +++ b/lib/page/library/borrow_info_card.dart @@ -196,7 +196,10 @@ class BorrowInfoCard extends StatelessWidget { } catch (e, s) { log.handle(e, s, "[BorrowInfoCard][renew] Failed."); if (!context.mounted) return; - showToast(context: context, msg: e.toString()); + final msg = e is LibraryOperationException && e.i18nKey != null + ? FlutterI18n.translate(context, e.i18nKey!) + : e.toString(); + showToast(context: context, msg: msg); } finally { if (context.mounted) { pd.close(); diff --git a/lib/page/library/scan_borrow_window.dart b/lib/page/library/scan_borrow_window.dart index f327218f..a31549dd 100644 --- a/lib/page/library/scan_borrow_window.dart +++ b/lib/page/library/scan_borrow_window.dart @@ -9,6 +9,7 @@ import 'package:styled_widget/styled_widget.dart'; import 'package:watermeter/controller/library_controller.dart'; import 'package:watermeter/model/xidian_ids/library.dart'; import 'package:watermeter/page/library/book_detail_card.dart'; +import 'package:watermeter/page/library/borrow_confirm_dialog.dart'; import 'package:watermeter/page/public_widget/toast.dart'; import 'package:watermeter/repository/logger.dart'; import 'package:watermeter/repository/xidian_ids/library_session.dart'; @@ -66,7 +67,7 @@ class _ScanBorrowWindowState extends State { } catch (e, s) { log.handle(e, s, "[ScanBorrowWindow][_handleCapture] Failed."); if (!mounted) return; - showToast(context: context, msg: e.toString()); + showToast(context: context, msg: _exceptionMsg(e)); await _resumeScan(); } finally { if (mounted) { @@ -77,6 +78,16 @@ class _ScanBorrowWindowState extends State { } } + String _exceptionMsg(Object e) { + if (e is LibraryOperationException && e.i18nKey != null) { + return FlutterI18n.translate(context, e.i18nKey!); + } + if (e is NotFetchLibraryException && e.i18nKey != null) { + return FlutterI18n.translate(context, e.i18nKey!); + } + return e.toString(); + } + Future _resumeScan() async { setState(() { _barcode = null; @@ -218,13 +229,10 @@ class _ScanBorrowWindowState extends State { if (borrowableLocation != null) { return _ScanBorrowStatus( canBorrow: true, - location: borrowableLocation, + location: null, message: FlutterI18n.translate( context, - "library.scan_borrow_available", - translationParams: { - "location": _locationLabel(context, borrowableLocation), - }, + "library.scan_borrow_fallback", ), ); } @@ -247,6 +255,18 @@ class _ScanBorrowWindowState extends State { return; } + final confirmed = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => BorrowConfirmDialog( + coverUrl: bookInfo.imageUrl, + bookName: bookInfo.bookName, + locationName: borrowStatus.location?.locationName, + ), + ); + + if (confirmed != true || !mounted) return; + setState(() { _submitting = true; }); @@ -270,7 +290,7 @@ class _ScanBorrowWindowState extends State { } catch (e, s) { log.handle(e, s, "[ScanBorrowWindow][_submitBorrow] Failed."); if (!mounted) return; - showToast(context: context, msg: e.toString()); + showToast(context: context, msg: _exceptionMsg(e)); setState(() { _submitting = false; }); @@ -422,7 +442,7 @@ class _ScanBorrowWindowState extends State { : Text( FlutterI18n.translate( context, - "library.confirm_borrow_real_action", + "library.confirm_borrow", ), ), ).expanded(), diff --git a/lib/repository/xidian_ids/library_session.dart b/lib/repository/xidian_ids/library_session.dart index ba1ab0b0..a27e1415 100644 --- a/lib/repository/xidian_ids/library_session.dart +++ b/lib/repository/xidian_ids/library_session.dart @@ -131,12 +131,16 @@ class LibrarySession extends IDSSession { throw LibraryOperationException( message: data is Map ? data["msg"]?.toString() ?? "续借失败" : "续借失败", + i18nKey: "library.error_renew_failed", ); } Future getScannedBorrowBook(String barcode) async { if (barcode.isEmpty) { - throw LibraryOperationException(message: "馆藏条码为空"); + throw LibraryOperationException( + message: "馆藏条码为空", + i18nKey: "library.error_barcode_empty", + ); } if (userId == 0 || token.isEmpty) { await initSession(); @@ -159,12 +163,16 @@ class LibrarySession extends IDSSession { if (data is Map && data["code"] != null && data["code"] != 1) { throw LibraryOperationException( message: data["msg"]?.toString() ?? "查询图书失败", + i18nKey: "library.error_search_failed", ); } final rawList = data["data"]?["list"]; if (rawList is! List || rawList.isEmpty) { - throw LibraryOperationException(message: "未查找到相关书籍"); + throw LibraryOperationException( + message: "未查找到相关书籍", + i18nKey: "library.error_book_not_found", + ); } final book = BookInfo.fromJson(rawList.first as Map); @@ -187,7 +195,10 @@ class LibrarySession extends IDSSession { String? searchCode, }) async { if (barcode.isEmpty) { - throw LibraryOperationException(message: "馆藏条码为空"); + throw LibraryOperationException( + message: "馆藏条码为空", + i18nKey: "library.error_barcode_empty", + ); } if (userId == 0 || token.isEmpty) { await initSession(); @@ -196,7 +207,10 @@ class LibrarySession extends IDSSession { final userBarcode = await _getUserBarcode(); final searchCodeToUse = searchCode ?? bookInfo.searchCode?.firstOrNull; if (searchCodeToUse == null || searchCodeToUse.isEmpty) { - throw LibraryOperationException(message: "缺少索书号,无法申请借书"); + throw LibraryOperationException( + message: "缺少索书号,无法申请借书", + i18nKey: "library.error_no_search_code", + ); } final response = await dio.post( @@ -216,10 +230,15 @@ class LibrarySession extends IDSSession { final data = response.data; _throwIfLibrarySessionExpired(data); if (data is Map && data["code"] == 1) { - return data["msg"]?.toString() ?? data["data"]?.toString() ?? "借书申请已提交"; + final msg = data["msg"]?.toString(); + if (msg != null && msg.isNotEmpty) return msg; + final dataStr = data["data"]?.toString(); + if (dataStr != null && dataStr.isNotEmpty) return dataStr; + return "借书申请已提交"; } throw LibraryOperationException( message: data is Map ? data["msg"]?.toString() ?? "申请借书失败" : "申请借书失败", + i18nKey: "library.error_borrow_failed", ); } @@ -249,6 +268,7 @@ class LibrarySession extends IDSSession { throw LibraryOperationException( message: data is Map ? data["msg"]?.toString() ?? "获取读者条码失败" : "获取读者条码失败", + i18nKey: "library.error_user_barcode_failed", ); } @@ -258,6 +278,7 @@ class LibrarySession extends IDSSession { token = ""; throw NotFetchLibraryException( message: data["msg"]?.toString() ?? "图书馆登录已失效", + i18nKey: "library.error_session_expired", ); } } @@ -531,7 +552,8 @@ class LibrarySession extends IDSSession { class NotFetchLibraryException implements Exception { final String message; - NotFetchLibraryException({this.message = "Error detected."}); + final String? i18nKey; + NotFetchLibraryException({this.message = "Error detected.", this.i18nKey}); @override String toString() => message; @@ -539,7 +561,8 @@ class NotFetchLibraryException implements Exception { class LibraryOperationException implements Exception { final String message; - LibraryOperationException({required this.message}); + final String? i18nKey; + LibraryOperationException({required this.message, this.i18nKey}); @override String toString() => message; From d0c294d7c91edc5921b6f1824009b45dc3bde1e6 Mon Sep 17 00:00:00 2001 From: clever-max <2942247417@qq.com> Date: Sun, 5 Jul 2026 16:52:00 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(library):=20=E4=BF=AE=E5=A4=8D=E5=80=9F?= =?UTF-8?q?=E9=98=85=E5=88=97=E8=A1=A8=E5=81=B6=E5=8F=910=E6=9C=AC?= =?UTF-8?q?=E3=80=81=E6=90=9C=E7=B4=A2=E7=BB=93=E6=9E=9C=E4=B8=8D=E4=BF=9D?= =?UTF-8?q?=E5=BA=8F=E3=80=81=E5=B0=81=E9=9D=A2=E8=8E=B7=E5=8F=96=E6=B5=AA?= =?UTF-8?q?=E8=B4=B9=E3=80=81=E6=8E=92=E5=BA=8F=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xidian_ids/library_session.dart | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/lib/repository/xidian_ids/library_session.dart b/lib/repository/xidian_ids/library_session.dart index a27e1415..51350268 100644 --- a/lib/repository/xidian_ids/library_session.dart +++ b/lib/repository/xidian_ids/library_session.dart @@ -72,20 +72,21 @@ class LibrarySession extends IDSSession { } }); - List toReturn = []; + final toReturn = List.generate( + rawData.length, + (index) => BookInfo.fromJson(rawData[index]), + ); final pool = Pool(5); - await Future.wait([ - ...List.generate( - rawData.length, - (index) => BookInfo.fromJson(rawData[index]), - ).map( - (e) => pool.withResource(() async { - e.imageUrl = await bookCover(e.bookName, e.isbn ?? "", e.docNumber); - toReturn.add(e); - }), - ), - ]); + await Future.wait( + List.generate(toReturn.length, (i) => pool.withResource(() async { + toReturn[i].imageUrl = await bookCover( + toReturn[i].bookName, + toReturn[i].isbn ?? "", + toReturn[i].docNumber, + ); + })), + ); return toReturn; } @@ -289,7 +290,7 @@ class LibrarySession extends IDSSession { "Getting borrow list", ); - if (userId == 0 && token == "") { + if (userId == 0 || token.isEmpty) { await initSession(); } @@ -306,7 +307,10 @@ class LibrarySession extends IDSSession { "page": 0, }, ) - .then((value) => value.data["data"]); + .then((value) { + _throwIfLibrarySessionExpired(value.data); + return value.data["data"]; + }); List toAppend = []; final pool = Pool(5); @@ -319,7 +323,7 @@ class LibrarySession extends IDSSession { e.imageUrl = await searchBook(e.barcode, 1, searchField: "barcode") .then( (books) => books.isNotEmpty - ? LibrarySession().bookCover( + ? bookCover( books.first.bookName, books.first.isbn ?? "", books.first.docNumber, @@ -332,9 +336,11 @@ class LibrarySession extends IDSSession { ]); toAppend.sort( - (a, b) => - a.normReturnDateTime.millisecondsSinceEpoch - - b.normReturnDateTime.millisecondsSinceEpoch, + (a, b) { + final returnCmp = a.normReturnDateTime.compareTo(b.normReturnDateTime); + if (returnCmp != 0) return returnCmp; + return a.loanDateTime.compareTo(b.loanDateTime); + }, ); return toAppend; From 0995b159aebe072981a273d62b30a27d725726f7 Mon Sep 17 00:00:00 2001 From: clever-max <2942247417@qq.com> Date: Sun, 5 Jul 2026 17:27:09 +0800 Subject: [PATCH 5/5] fix(library): i18n success messages, remove dead barCodesStr --- assets/flutter_i18n/en_US.yaml | 2 ++ assets/flutter_i18n/zh_CN.yaml | 2 ++ assets/flutter_i18n/zh_TW.yaml | 2 ++ lib/model/xidian_ids/library.dart | 8 -------- lib/page/library/borrow_info_card.dart | 10 ++++++++-- lib/page/library/scan_borrow_window.dart | 7 +++++-- 6 files changed, 19 insertions(+), 12 deletions(-) diff --git a/assets/flutter_i18n/en_US.yaml b/assets/flutter_i18n/en_US.yaml index 2462111e..cff1b650 100644 --- a/assets/flutter_i18n/en_US.yaml +++ b/assets/flutter_i18n/en_US.yaml @@ -632,6 +632,7 @@ library: can_be_renewable: Renewable cannot_be_renewable: Not renewable renewing: Renewing + renew_success: Renewed successfully empty_borrow_list: No borrowed books found borrow_list_info: Borrowing {borrow} book(s), among which {dued} book(s) have expired search_book_window: null @@ -661,6 +662,7 @@ library: search_borrow_warning: Search results cannot be borrowed directly to avoid mistakes. Take the book in the library, scan its holding barcode, then the final confirm action will really submit the borrow request. borrow_unavailable: No borrowable copy borrowing_book: Requesting borrow + borrow_submitted: Borrow request submitted confirm_search_borrow_message: 'Borrow "{book}" from {location}?' error_barcode_empty: Holding barcode is empty error_search_failed: Book search failed diff --git a/assets/flutter_i18n/zh_CN.yaml b/assets/flutter_i18n/zh_CN.yaml index eb4c9a11..0dadc706 100644 --- a/assets/flutter_i18n/zh_CN.yaml +++ b/assets/flutter_i18n/zh_CN.yaml @@ -560,6 +560,7 @@ library: can_be_renewable: 续借 cannot_be_renewable: 不可续借 renewing: 正在续借 + renew_success: 续借成功 empty_borrow_list: "目前没有查询到在借图书 不借书就要变成上面的小呆瓜咯" @@ -591,6 +592,7 @@ library: search_borrow_warning: 为避免误借,搜索结果不能直接借出。请到馆拿到这本书后,扫描书上的馆藏条码;最终点击确认借书会真实发起借阅。 borrow_unavailable: 暂无可借馆藏 borrowing_book: 正在申请借书 + borrow_submitted: 借书申请已提交 confirm_search_borrow_message: 确认借阅《{book}》?馆藏位置:{location} error_barcode_empty: 馆藏条码为空 error_search_failed: 查询图书失败 diff --git a/assets/flutter_i18n/zh_TW.yaml b/assets/flutter_i18n/zh_TW.yaml index 4db62bf8..14aae1a9 100644 --- a/assets/flutter_i18n/zh_TW.yaml +++ b/assets/flutter_i18n/zh_TW.yaml @@ -558,6 +558,7 @@ library: can_be_renewable: 續借 cannot_be_renewable: 不可續借 renewing: 正在續借 + renew_success: 續借成功 empty_borrow_list: '目前沒有查詢到在借圖書 不借書就要變成上面的小呆瓜咯' @@ -589,6 +590,7 @@ library: search_borrow_warning: 為避免誤借,搜索結果不能直接借出。請到館拿到這本書後,掃描書上的館藏條碼;最終點擊確認借書會真實發起借閱。 borrow_unavailable: 暫無可借館藏 borrowing_book: 正在申請借書 + borrow_submitted: 借書申請已提交 confirm_search_borrow_message: 確認借閱《{book}》?館藏位置:{location} error_barcode_empty: 館藏條碼為空 error_search_failed: 查詢圖書失敗 diff --git a/lib/model/xidian_ids/library.dart b/lib/model/xidian_ids/library.dart index 511cb76b..818a0eb8 100644 --- a/lib/model/xidian_ids/library.dart +++ b/lib/model/xidian_ids/library.dart @@ -137,16 +137,8 @@ class BookInfo { } return searchCode!.first; } - - String get barCodesStr { - if (barCodes == null || barCodes!.isEmpty) { - return "未提供"; - } - return barCodes!.first ?? "未提供"; - } } -@JsonSerializable() class BookLocation { final String? yearVol; final String? locationName; diff --git a/lib/page/library/borrow_info_card.dart b/lib/page/library/borrow_info_card.dart index 4c56fcd4..6904b53d 100644 --- a/lib/page/library/borrow_info_card.dart +++ b/lib/page/library/borrow_info_card.dart @@ -189,9 +189,15 @@ class BorrowInfoCard extends StatelessWidget { msg: FlutterI18n.translate(context, "library.renewing"), ); try { - final value = await LibrarySession().renew(toUse); + await LibrarySession().renew(toUse); if (!context.mounted) return; - showToast(context: context, msg: value); + showToast( + context: context, + msg: FlutterI18n.translate( + context, + "library.renew_success", + ), + ); await LibraryController.i.reloadBorrowList(); } catch (e, s) { log.handle(e, s, "[BorrowInfoCard][renew] Failed."); diff --git a/lib/page/library/scan_borrow_window.dart b/lib/page/library/scan_borrow_window.dart index a31549dd..8c638e0c 100644 --- a/lib/page/library/scan_borrow_window.dart +++ b/lib/page/library/scan_borrow_window.dart @@ -276,14 +276,17 @@ class _ScanBorrowWindowState extends State { ); try { - final message = await _session.borrowBook( + await _session.borrowBook( barcode: barcode, bookInfo: bookInfo, searchCode: borrowStatus.location?.searchCode ?? _firstSearchCode(bookInfo), ); if (!mounted) return; - showToast(context: context, msg: message); + showToast( + context: context, + msg: FlutterI18n.translate(context, "library.borrow_submitted"), + ); await LibraryController.i.reloadBorrowList(); if (!mounted) return; Navigator.of(context).pop();