diff --git a/client/lib/core/api/api_client.dart b/client/lib/core/api/api_client.dart index 48a26c0..154aaa0 100644 --- a/client/lib/core/api/api_client.dart +++ b/client/lib/core/api/api_client.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../auth/auth_state.dart'; @@ -14,17 +15,34 @@ final _publicDio = Dio(BaseOptions( final apiClientProvider = Provider((ref) { final authState = ref.watch(authStateProvider); - return ApiClient( - token: authState.user?.accessToken, + final myToken = authState.user?.accessToken; + + final client = ApiClient( + token: myToken, refreshToken: authState.user?.refreshToken, - onTokenRefreshed: (newToken) => - ref.read(authStateProvider.notifier).updateAccessToken(newToken), - onAuthFailed: () => ref.read(authStateProvider.notifier).logout(), + onTokenRefreshed: (newToken) { + debugPrint('[ApiClient] token refreshed'); + if (ref.read(authStateProvider).user?.accessToken == myToken) { + ref.read(authStateProvider.notifier).updateAccessToken(newToken); + } + }, + onAuthFailed: () { + debugPrint('[ApiClient] onAuthFailed! myToken prefix: ${(myToken ?? '').substring(0, (myToken ?? '').length.clamp(0, 20))}'); + if (ref.read(authStateProvider).user?.accessToken == myToken) { + debugPrint('[ApiClient] calling logout()'); + ref.read(authStateProvider.notifier).logout(); + } else { + debugPrint('[ApiClient] skipping logout (stale client)'); + } + }, ); + ref.onDispose(client.dispose); + return client; }); class ApiClient { late final Dio _dio; + bool _disposed = false; ApiClient({ String? token, @@ -42,27 +60,35 @@ class ApiClient { }, )); - // 401 auto-refresh interceptor + // 401 auto-refresh 拦截器 if (refreshToken != null) { _dio.interceptors.add( InterceptorsWrapper( onError: (DioException e, ErrorInterceptorHandler handler) async { + // 如果这个 client 已被替换,忽略所有回调 + if (_disposed) { + return handler.next(e); + } if (e.response?.statusCode == 401 && refreshToken.isNotEmpty) { + debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...'); try { final resp = await _publicDio.post('/auth/refresh', data: { 'refresh_token': refreshToken, }); final newToken = resp.data['data']['access_token'] as String; - onTokenRefreshed?.call(newToken); - // Retry original request with new token + if (!_disposed) onTokenRefreshed?.call(newToken); + // 用新 token 重试原请求 final opts = e.requestOptions; opts.headers['Authorization'] = 'Bearer $newToken'; final retryResp = await _dio.fetch(opts); return handler.resolve(retryResp); - } catch (_) { - onAuthFailed?.call(); + } catch (refreshErr) { + debugPrint('[ApiClient] refresh failed: $refreshErr'); + if (!_disposed) onAuthFailed?.call(); } + } else if (e.response?.statusCode == 401) { + debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token'); } return handler.next(e); }, @@ -71,6 +97,12 @@ class ApiClient { } } + /// 取消所有进行中的请求,标记实例为已废弃 + void dispose() { + _disposed = true; + _dio.close(force: true); + } + Future get(String path, {Map? params}) => _dio.get(path, queryParameters: params); diff --git a/client/lib/core/auth/auth_state.dart b/client/lib/core/auth/auth_state.dart index 904e436..515ce41 100644 --- a/client/lib/core/auth/auth_state.dart +++ b/client/lib/core/auth/auth_state.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -5,24 +6,24 @@ const _kAccessToken = 'access_token'; const _kRefreshToken = 'refresh_token'; const _kUsername = 'username'; const _kRealName = 'real_name'; -const _kHotelNo = 'hotel_no'; -const _kHotelId = 'hotel_id'; +const _kShopNo = 'shop_no'; +const _kShopId = 'shop_id'; class AuthUser { final String accessToken; final String refreshToken; final String username; final String realName; - final String hotelNo; - final int hotelId; + final String shopNo; + final int shopId; const AuthUser({ required this.accessToken, required this.refreshToken, required this.username, required this.realName, - required this.hotelNo, - required this.hotelId, + required this.shopNo, + required this.shopId, }); // Kept for ApiClient compatibility @@ -47,8 +48,8 @@ class AuthNotifier extends StateNotifier { final refreshToken = prefs.getString(_kRefreshToken); final username = prefs.getString(_kUsername); final realName = prefs.getString(_kRealName); - final hotelNo = prefs.getString(_kHotelNo); - final hotelIdStr = prefs.getString(_kHotelId); + final shopNo = prefs.getString(_kShopNo); + final shopIdStr = prefs.getString(_kShopId); if (accessToken != null && refreshToken != null && username != null) { state = AuthState( @@ -58,8 +59,8 @@ class AuthNotifier extends StateNotifier { refreshToken: refreshToken, username: username, realName: realName ?? username, - hotelNo: hotelNo ?? '', - hotelId: int.tryParse(hotelIdStr ?? '') ?? 0, + shopNo: shopNo ?? '', + shopId: int.tryParse(shopIdStr ?? '') ?? 0, ), ); return; @@ -71,14 +72,17 @@ class AuthNotifier extends StateNotifier { } Future login(AuthUser user) async { + debugPrint('[Auth] login() called, username=${user.username} shopId=${user.shopId}'); final prefs = await SharedPreferences.getInstance(); await prefs.setString(_kAccessToken, user.accessToken); await prefs.setString(_kRefreshToken, user.refreshToken); await prefs.setString(_kUsername, user.username); await prefs.setString(_kRealName, user.realName); - await prefs.setString(_kHotelNo, user.hotelNo); - await prefs.setString(_kHotelId, user.hotelId.toString()); + await prefs.setString(_kShopNo, user.shopNo); + await prefs.setString(_kShopId, user.shopId.toString()); + debugPrint('[Auth] login() setting state, token prefix: ${user.accessToken.substring(0, user.accessToken.length.clamp(0, 20))}...'); state = AuthState(initialized: true, user: user); + debugPrint('[Auth] login() state set, isLoggedIn=${state.isLoggedIn}'); } void updateAccessToken(String newToken) { @@ -92,20 +96,21 @@ class AuthNotifier extends StateNotifier { refreshToken: state.user!.refreshToken, username: state.user!.username, realName: state.user!.realName, - hotelNo: state.user!.hotelNo, - hotelId: state.user!.hotelId, + shopNo: state.user!.shopNo, + shopId: state.user!.shopId, ), ); } Future logout() async { + debugPrint('[Auth] logout() called! stack: ${StackTrace.current}'); final prefs = await SharedPreferences.getInstance(); await prefs.remove(_kAccessToken); await prefs.remove(_kRefreshToken); await prefs.remove(_kUsername); await prefs.remove(_kRealName); - await prefs.remove(_kHotelNo); - await prefs.remove(_kHotelId); + await prefs.remove(_kShopNo); + await prefs.remove(_kShopId); state = const AuthState(initialized: true); } } diff --git a/client/lib/core/exceptions.dart b/client/lib/core/exceptions.dart new file mode 100644 index 0000000..e84c0d7 --- /dev/null +++ b/client/lib/core/exceptions.dart @@ -0,0 +1,7 @@ +class AppException implements Exception { + final String message; + final int? statusCode; + const AppException(this.message, {this.statusCode}); + @override + String toString() => message; +} diff --git a/client/lib/core/models/page_result.dart b/client/lib/core/models/page_result.dart new file mode 100644 index 0000000..60aaf2f --- /dev/null +++ b/client/lib/core/models/page_result.dart @@ -0,0 +1,27 @@ +class PageResult { + final List data; + final int total; + final int page; + final int pageSize; + + const PageResult({ + required this.data, + required this.total, + required this.page, + required this.pageSize, + }); + + factory PageResult.fromJson( + Map json, + T Function(Map) fromJson, + ) { + return PageResult( + data: (json['data'] as List) + .map((e) => fromJson(e as Map)) + .toList(), + total: json['total'] as int, + page: json['page'] as int, + pageSize: json['page_size'] as int, + ); + } +} diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index 1ce69ef..604330a 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -17,18 +17,55 @@ import '../auth/auth_state.dart'; Page _noTransition(Widget child) => NoTransitionPage(child: child); -final appRouterProvider = Provider((ref) { - final authState = ref.watch(authStateProvider); +/// ChangeNotifier that bridges Riverpod auth state → GoRouter refreshListenable. +class _RouterNotifier extends ChangeNotifier { + final Ref _ref; - return GoRouter( + _RouterNotifier(this._ref) { + _ref.listen(authStateProvider, (prev, next) { + debugPrint('[Router] authState changed:' + ' initialized=${next.initialized}' + ' isLoggedIn=${next.isLoggedIn}' + ' user=${next.user?.username}'); + notifyListeners(); + }); + } + + String? redirect(BuildContext context, GoRouterState state) { + final authState = _ref.read(authStateProvider); + final isLoggedIn = authState.isLoggedIn; + final isLoginRoute = state.matchedLocation == '/login'; + final result = !authState.initialized + ? null + : (!isLoggedIn && !isLoginRoute) + ? '/login' + : (isLoggedIn && isLoginRoute) + ? '/stock-in' + : null; + debugPrint('[Router] redirect: location=${state.matchedLocation}' + ' initialized=${authState.initialized}' + ' isLoggedIn=$isLoggedIn' + ' → ${result ?? "null (no redirect)"}'); + return result; + } +} + +/// Separate provider so that appRouterProvider has NO dependencies and +/// is never rebuilt when auth state changes (prevents router reset to /login). +final _routerNotifierProvider = Provider<_RouterNotifier>((ref) { + final notifier = _RouterNotifier(ref); + ref.onDispose(notifier.dispose); + return notifier; +}); + +final appRouterProvider = Provider((ref) { + // Use ref.read (not ref.watch) so appRouterProvider never rebuilds on auth change. + final notifier = ref.read(_routerNotifierProvider); + + final router = GoRouter( initialLocation: '/login', - redirect: (context, state) { - final isLoggedIn = authState.isLoggedIn; - final isLoginRoute = state.matchedLocation == '/login'; - if (!isLoggedIn && !isLoginRoute) return '/login'; - if (isLoggedIn && isLoginRoute) return '/stock-in'; - return null; - }, + refreshListenable: notifier, + redirect: notifier.redirect, routes: [ GoRoute( path: '/login', @@ -70,4 +107,8 @@ final appRouterProvider = Provider((ref) { ), ], ); + + ref.onDispose(router.dispose); + + return router; }); diff --git a/client/lib/main.dart b/client/lib/main.dart index 3473184..78e7d2e 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'core/auth/auth_state.dart'; @@ -5,7 +7,21 @@ import 'core/router/app_router.dart'; import 'core/theme/app_theme.dart'; void main() { - runApp(const ProviderScope(child: JiuApp())); + FlutterError.onError = (details) { + FlutterError.presentError(details); + debugPrint('═══ FlutterError ════════════════════════════'); + debugPrint(details.exceptionAsString()); + debugPrint(details.stack.toString()); + }; + + runZonedGuarded( + () => runApp(const ProviderScope(child: JiuApp())), + (error, stack) { + debugPrint('═══ Zone Error ═══════════════════════════════'); + debugPrint(error.toString()); + debugPrint(stack.toString()); + }, + ); } class JiuApp extends ConsumerWidget { diff --git a/client/lib/models/inventory.dart b/client/lib/models/inventory.dart new file mode 100644 index 0000000..ddb418b --- /dev/null +++ b/client/lib/models/inventory.dart @@ -0,0 +1,88 @@ +class Inventory { + final int warehouseId; + final String? warehouseName; + final int productId; + final String? productName; + final String? productCode; + final String? productSpec; + final String? productUnit; + final String? productBrand; + final int? minStock; + final double quantity; + + const Inventory({ + required this.warehouseId, + this.warehouseName, + required this.productId, + this.productName, + this.productCode, + this.productSpec, + this.productUnit, + this.productBrand, + this.minStock, + required this.quantity, + }); + + factory Inventory.fromJson(Map json) => Inventory( + warehouseId: (json['warehouse_id'] as num).toInt(), + warehouseName: json['warehouse_name'] as String?, + productId: (json['product_id'] as num).toInt(), + productName: json['product_name'] as String?, + productCode: json['product_code'] as String?, + productSpec: json['product_spec'] as String?, + productUnit: json['product_unit'] as String?, + productBrand: json['product_brand'] as String?, + minStock: json['min_stock'] != null + ? (json['min_stock'] as num).toInt() + : null, + quantity: (json['quantity'] as num).toDouble(), + ); +} + +class InventoryLog { + final int warehouseId; + final String? warehouseName; + final int productId; + final String? productName; + final String direction; // in | out + final double quantity; + final double? qtyBefore; + final double? qtyAfter; + final String? refType; + final int? refId; + final String? createdAt; + + const InventoryLog({ + required this.warehouseId, + this.warehouseName, + required this.productId, + this.productName, + required this.direction, + required this.quantity, + this.qtyBefore, + this.qtyAfter, + this.refType, + this.refId, + this.createdAt, + }); + + factory InventoryLog.fromJson(Map json) => InventoryLog( + warehouseId: (json['warehouse_id'] as num).toInt(), + warehouseName: json['warehouse_name'] as String?, + productId: (json['product_id'] as num).toInt(), + productName: json['product_name'] as String?, + direction: json['direction'] as String, + quantity: (json['quantity'] as num).toDouble(), + qtyBefore: json['qty_before'] != null + ? (json['qty_before'] as num).toDouble() + : null, + qtyAfter: json['qty_after'] != null + ? (json['qty_after'] as num).toDouble() + : null, + refType: json['ref_type'] as String?, + refId: json['ref_id'] != null + ? (json['ref_id'] as num).toInt() + : null, + createdAt: json['created_at'] as String?, + ); +} diff --git a/client/lib/models/partner.dart b/client/lib/models/partner.dart new file mode 100644 index 0000000..b1c8c99 --- /dev/null +++ b/client/lib/models/partner.dart @@ -0,0 +1,46 @@ +class Partner { + final int id; + final String? code; + final String name; + final String type; // supplier | customer + final String? contact; + final String? phone; + final String? address; + final String? bankAccount; + final String? remark; + + const Partner({ + required this.id, + this.code, + required this.name, + required this.type, + this.contact, + this.phone, + this.address, + this.bankAccount, + this.remark, + }); + + factory Partner.fromJson(Map json) => Partner( + id: (json['id'] as num).toInt(), + code: json['code'] as String?, + name: json['name'] as String, + type: json['type'] as String, + contact: json['contact'] as String?, + phone: json['phone'] as String?, + address: json['address'] as String?, + bankAccount: json['bank_account'] as String?, + remark: json['remark'] as String?, + ); + + Map toJson() => { + 'name': name, + 'type': type, + if (code != null) 'code': code, + if (contact != null) 'contact': contact, + if (phone != null) 'phone': phone, + if (address != null) 'address': address, + if (bankAccount != null) 'bank_account': bankAccount, + if (remark != null) 'remark': remark, + }; +} diff --git a/client/lib/models/product.dart b/client/lib/models/product.dart new file mode 100644 index 0000000..e443e4b --- /dev/null +++ b/client/lib/models/product.dart @@ -0,0 +1,73 @@ +class Product { + final int id; + final String code; + final String? barcode; + final String name; + final String? series; + final String? spec; + final String unit; + final int? categoryId; + final String? brand; + final double? purchasePrice; + final double? salePrice; + final int? minStock; + final String? remark; + final Map? customFields; + + const Product({ + required this.id, + required this.code, + this.barcode, + required this.name, + this.series, + this.spec, + required this.unit, + this.categoryId, + this.brand, + this.purchasePrice, + this.salePrice, + this.minStock, + this.remark, + this.customFields, + }); + + factory Product.fromJson(Map json) => Product( + id: (json['id'] as num).toInt(), + code: json['code'] as String? ?? '', + barcode: json['barcode'] as String?, + name: json['name'] as String, + series: json['series'] as String?, + spec: json['spec'] as String?, + unit: json['unit'] as String? ?? '个', + categoryId: json['category_id'] != null + ? (json['category_id'] as num).toInt() + : null, + brand: json['brand'] as String?, + purchasePrice: json['purchase_price'] != null + ? (json['purchase_price'] as num).toDouble() + : null, + salePrice: json['sale_price'] != null + ? (json['sale_price'] as num).toDouble() + : null, + minStock: json['min_stock'] != null + ? (json['min_stock'] as num).toInt() + : null, + remark: json['remark'] as String?, + customFields: json['custom_fields'] as Map?, + ); + + Map toJson() => { + 'code': code, + if (barcode != null) 'barcode': barcode, + 'name': name, + if (series != null) 'series': series, + if (spec != null) 'spec': spec, + 'unit': unit, + if (categoryId != null) 'category_id': categoryId, + if (brand != null) 'brand': brand, + if (purchasePrice != null) 'purchase_price': purchasePrice, + if (salePrice != null) 'sale_price': salePrice, + if (minStock != null) 'min_stock': minStock, + if (remark != null) 'remark': remark, + }; +} diff --git a/client/lib/models/stock_in.dart b/client/lib/models/stock_in.dart new file mode 100644 index 0000000..d6e7079 --- /dev/null +++ b/client/lib/models/stock_in.dart @@ -0,0 +1,108 @@ +class StockInItem { + final int? orderId; + final int productId; + final double quantity; + final double unitPrice; + final double totalPrice; + final String? batchNo; + // Denormalized for display + final String? productName; + final String? productCode; + final String? productSpec; + final String? productUnit; + + const StockInItem({ + this.orderId, + required this.productId, + required this.quantity, + required this.unitPrice, + required this.totalPrice, + this.batchNo, + this.productName, + this.productCode, + this.productSpec, + this.productUnit, + }); + + factory StockInItem.fromJson(Map json) => StockInItem( + orderId: json['order_id'] != null + ? (json['order_id'] as num).toInt() + : null, + productId: (json['product_id'] as num).toInt(), + quantity: (json['quantity'] as num).toDouble(), + unitPrice: (json['unit_price'] as num).toDouble(), + totalPrice: (json['total_price'] as num).toDouble(), + batchNo: json['batch_no'] as String?, + productName: json['product_name'] as String?, + productCode: json['product_code'] as String?, + productSpec: json['product_spec'] as String?, + productUnit: json['product_unit'] as String?, + ); + + Map toJson() => { + 'product_id': productId, + 'quantity': quantity, + 'unit_price': unitPrice, + 'total_price': totalPrice, + if (batchNo != null) 'batch_no': batchNo, + }; +} + +class StockInOrder { + final int id; + final String orderNo; + final String? type; + final int warehouseId; + final String? warehouseName; + final int? partnerId; + final String? partnerName; + final int? operatorId; + final String status; // draft | pending | approved | rejected + final String? orderDate; + final double? totalAmount; + final String? remark; + final List items; + + const StockInOrder({ + required this.id, + required this.orderNo, + this.type, + required this.warehouseId, + this.warehouseName, + this.partnerId, + this.partnerName, + this.operatorId, + required this.status, + this.orderDate, + this.totalAmount, + this.remark, + this.items = const [], + }); + + factory StockInOrder.fromJson(Map json) => StockInOrder( + id: (json['id'] as num).toInt(), + orderNo: json['order_no'] as String, + type: json['type'] as String?, + warehouseId: (json['warehouse_id'] as num).toInt(), + warehouseName: json['warehouse_name'] as String?, + partnerId: json['partner_id'] != null + ? (json['partner_id'] as num).toInt() + : null, + partnerName: json['partner_name'] as String?, + operatorId: json['operator_id'] != null + ? (json['operator_id'] as num).toInt() + : null, + status: json['status'] as String, + orderDate: json['order_date'] as String?, + totalAmount: json['total_amount'] != null + ? (json['total_amount'] as num).toDouble() + : null, + remark: json['remark'] as String?, + items: json['items'] != null + ? (json['items'] as List) + .map((e) => + StockInItem.fromJson(e as Map)) + .toList() + : [], + ); +} diff --git a/client/lib/models/stock_out.dart b/client/lib/models/stock_out.dart new file mode 100644 index 0000000..333e722 --- /dev/null +++ b/client/lib/models/stock_out.dart @@ -0,0 +1,96 @@ +class StockOutItem { + final int? orderId; + final int productId; + final double quantity; + final double unitPrice; + final double totalPrice; + final String? productName; + final String? productCode; + final String? productSpec; + final String? productUnit; + + const StockOutItem({ + this.orderId, + required this.productId, + required this.quantity, + required this.unitPrice, + required this.totalPrice, + this.productName, + this.productCode, + this.productSpec, + this.productUnit, + }); + + factory StockOutItem.fromJson(Map json) => StockOutItem( + orderId: json['order_id'] != null + ? (json['order_id'] as num).toInt() + : null, + productId: (json['product_id'] as num).toInt(), + quantity: (json['quantity'] as num).toDouble(), + unitPrice: (json['unit_price'] as num).toDouble(), + totalPrice: (json['total_price'] as num).toDouble(), + productName: json['product_name'] as String?, + productCode: json['product_code'] as String?, + productSpec: json['product_spec'] as String?, + productUnit: json['product_unit'] as String?, + ); +} + +class StockOutOrder { + final int id; + final String orderNo; + final String? type; + final int warehouseId; + final String? warehouseName; + final int? partnerId; + final String? partnerName; + final int? operatorId; + final String status; // draft | pending | approved | rejected + final String? orderDate; + final double? totalAmount; + final String? remark; + final List items; + + const StockOutOrder({ + required this.id, + required this.orderNo, + this.type, + required this.warehouseId, + this.warehouseName, + this.partnerId, + this.partnerName, + this.operatorId, + required this.status, + this.orderDate, + this.totalAmount, + this.remark, + this.items = const [], + }); + + factory StockOutOrder.fromJson(Map json) => StockOutOrder( + id: (json['id'] as num).toInt(), + orderNo: json['order_no'] as String, + type: json['type'] as String?, + warehouseId: (json['warehouse_id'] as num).toInt(), + warehouseName: json['warehouse_name'] as String?, + partnerId: json['partner_id'] != null + ? (json['partner_id'] as num).toInt() + : null, + partnerName: json['partner_name'] as String?, + operatorId: json['operator_id'] != null + ? (json['operator_id'] as num).toInt() + : null, + status: json['status'] as String, + orderDate: json['order_date'] as String?, + totalAmount: json['total_amount'] != null + ? (json['total_amount'] as num).toDouble() + : null, + remark: json['remark'] as String?, + items: json['items'] != null + ? (json['items'] as List) + .map((e) => + StockOutItem.fromJson(e as Map)) + .toList() + : [], + ); +} diff --git a/client/lib/models/warehouse.dart b/client/lib/models/warehouse.dart new file mode 100644 index 0000000..4f276b6 --- /dev/null +++ b/client/lib/models/warehouse.dart @@ -0,0 +1,26 @@ +class Warehouse { + final int id; + final String name; + final String? location; + final bool isDefault; + + const Warehouse({ + required this.id, + required this.name, + this.location, + required this.isDefault, + }); + + factory Warehouse.fromJson(Map json) => Warehouse( + id: (json['id'] as num).toInt(), + name: json['name'] as String, + location: json['location'] as String?, + isDefault: json['is_default'] as bool? ?? false, + ); + + Map toJson() => { + 'name': name, + if (location != null) 'location': location, + 'is_default': isDefault, + }; +} diff --git a/client/lib/providers/inventory_provider.dart b/client/lib/providers/inventory_provider.dart new file mode 100644 index 0000000..fbf34e7 --- /dev/null +++ b/client/lib/providers/inventory_provider.dart @@ -0,0 +1,96 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/api/api_client.dart'; +import '../core/auth/auth_state.dart'; +import '../core/models/page_result.dart'; +import '../models/inventory.dart'; +import '../repositories/inventory_repository.dart'; + +final inventoryRepositoryProvider = Provider((ref) { + return InventoryRepository(ref.watch(apiClientProvider)); +}); + +final inventoryListProvider = + AsyncNotifierProvider>( + InventoryListNotifier.new, +); + +class InventoryListNotifier extends AsyncNotifier> { + int _page = 1; + int? _warehouseId; + String _keyword = ''; + + @override + Future> build() { + ref.watch(authStateProvider.select((s) => s.user?.shopId)); + return _fetch(); + } + + Future> _fetch() { + return ref.read(inventoryRepositoryProvider).listInventory( + warehouseId: _warehouseId, + keyword: _keyword.isEmpty ? null : _keyword, + page: _page, + pageSize: 50, + ); + } + + void setPage(int page) { + _page = page; + reload(); + } + + void setWarehouseId(int? id) { + _warehouseId = id; + _page = 1; + reload(); + } + + void setKeyword(String keyword) { + _keyword = keyword; + _page = 1; + reload(); + } + + void reload() { + state = const AsyncValue.loading(); + _fetch().then( + (result) => state = AsyncValue.data(result), + onError: (e, st) => state = AsyncValue.error(e, st), + ); + } +} + +final inventoryLogProvider = + AsyncNotifierProvider>( + InventoryLogNotifier.new, +); + +class InventoryLogNotifier extends AsyncNotifier> { + int _page = 1; + + @override + Future> build() { + ref.watch(authStateProvider.select((s) => s.user?.shopId)); + return _fetch(); + } + + Future> _fetch() { + return ref.read(inventoryRepositoryProvider).listLogs( + page: _page, + pageSize: 50, + ); + } + + void setPage(int page) { + _page = page; + reload(); + } + + void reload() { + state = const AsyncValue.loading(); + _fetch().then( + (result) => state = AsyncValue.data(result), + onError: (e, st) => state = AsyncValue.error(e, st), + ); + } +} diff --git a/client/lib/providers/partner_provider.dart b/client/lib/providers/partner_provider.dart new file mode 100644 index 0000000..7bcc213 --- /dev/null +++ b/client/lib/providers/partner_provider.dart @@ -0,0 +1,78 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/api/api_client.dart'; +import '../core/auth/auth_state.dart'; +import '../core/models/page_result.dart'; +import '../models/partner.dart'; +import '../repositories/partner_repository.dart'; + +final partnerRepositoryProvider = Provider((ref) { + return PartnerRepository(ref.watch(apiClientProvider)); +}); + +// Supplier list provider for dropdowns +final supplierListProvider = + AsyncNotifierProvider>( + () => PartnerListNotifier(type: 'supplier'), +); + +// Customer list provider +final customerListProvider = + AsyncNotifierProvider>( + () => PartnerListNotifier(type: 'customer'), +); + +class PartnerListNotifier extends AsyncNotifier> { + final String? type; + int _page = 1; + String _keyword = ''; + + PartnerListNotifier({this.type}); + + @override + Future> build() { + ref.watch(authStateProvider.select((s) => s.user?.shopId)); + return _fetch(); + } + + Future> _fetch() { + return ref.read(partnerRepositoryProvider).list( + type: type, + keyword: _keyword.isEmpty ? null : _keyword, + page: _page, + ); + } + + void setPage(int page) { + _page = page; + reload(); + } + + void setKeyword(String keyword) { + _keyword = keyword; + _page = 1; + reload(); + } + + void reload() { + state = const AsyncValue.loading(); + _fetch().then( + (result) => state = AsyncValue.data(result), + onError: (e, st) => state = AsyncValue.error(e, st), + ); + } + + Future createPartner(Map data) async { + await ref.read(partnerRepositoryProvider).create(data); + reload(); + } + + Future updatePartner(int id, Map data) async { + await ref.read(partnerRepositoryProvider).update(id, data); + reload(); + } + + Future deletePartner(int id) async { + await ref.read(partnerRepositoryProvider).delete(id); + reload(); + } +} diff --git a/client/lib/providers/product_provider.dart b/client/lib/providers/product_provider.dart new file mode 100644 index 0000000..30a7164 --- /dev/null +++ b/client/lib/providers/product_provider.dart @@ -0,0 +1,80 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/api/api_client.dart'; +import '../core/auth/auth_state.dart'; +import '../core/models/page_result.dart'; +import '../models/product.dart'; +import '../repositories/product_repository.dart'; + +final productRepositoryProvider = Provider((ref) { + return ProductRepository(ref.watch(apiClientProvider)); +}); + +final productListProvider = + AsyncNotifierProvider>( + ProductListNotifier.new, +); + +class ProductListNotifier extends AsyncNotifier> { + int _page = 1; + String _keyword = ''; + int? _categoryId; + + @override + Future> build() { + ref.watch(authStateProvider.select((s) => s.user?.shopId)); + return _fetch(); + } + + Future> _fetch() { + final repo = ref.read(productRepositoryProvider); + return repo.list( + page: _page, + pageSize: 20, + keyword: _keyword.isEmpty ? null : _keyword, + categoryId: _categoryId, + ); + } + + void setPage(int page) { + _page = page; + reload(); + } + + void setKeyword(String keyword) { + _keyword = keyword; + _page = 1; + reload(); + } + + void setCategoryId(int? categoryId) { + _categoryId = categoryId; + _page = 1; + reload(); + } + + void reload() { + state = const AsyncValue.loading(); + _fetch().then( + (result) => state = AsyncValue.data(result), + onError: (e, st) => state = AsyncValue.error(e, st), + ); + } + + Future createProduct(Map data) async { + final repo = ref.read(productRepositoryProvider); + await repo.create(data); + reload(); + } + + Future updateProduct(int id, Map data) async { + final repo = ref.read(productRepositoryProvider); + await repo.update(id, data); + reload(); + } + + Future deleteProduct(int id) async { + final repo = ref.read(productRepositoryProvider); + await repo.delete(id); + reload(); + } +} diff --git a/client/lib/providers/stock_in_provider.dart b/client/lib/providers/stock_in_provider.dart new file mode 100644 index 0000000..f05874d --- /dev/null +++ b/client/lib/providers/stock_in_provider.dart @@ -0,0 +1,83 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/api/api_client.dart'; +import '../core/auth/auth_state.dart'; +import '../core/models/page_result.dart'; +import '../models/stock_in.dart'; +import '../repositories/stock_in_repository.dart'; + +final stockInRepositoryProvider = Provider((ref) { + return StockInRepository(ref.watch(apiClientProvider)); +}); + +final stockInListProvider = + AsyncNotifierProvider>( + StockInListNotifier.new, +); + +class StockInListNotifier extends AsyncNotifier> { + int _page = 1; + String _status = ''; + String? _startDate; + String? _endDate; + + @override + Future> build() { + ref.watch(authStateProvider.select((s) => s.user?.shopId)); + return _fetch(); + } + + Future> _fetch() { + return ref.read(stockInRepositoryProvider).list( + status: _status.isEmpty ? null : _status, + startDate: _startDate, + endDate: _endDate, + page: _page, + ); + } + + void setPage(int page) { + _page = page; + reload(); + } + + void setStatus(String status) { + _status = status; + _page = 1; + reload(); + } + + void setDateRange(String? startDate, String? endDate) { + _startDate = startDate; + _endDate = endDate; + _page = 1; + reload(); + } + + void reload() { + state = const AsyncValue.loading(); + _fetch().then( + (result) => state = AsyncValue.data(result), + onError: (e, st) => state = AsyncValue.error(e, st), + ); + } + + Future createOrder(Map data) async { + await ref.read(stockInRepositoryProvider).create(data); + reload(); + } + + Future submitOrder(int id) async { + await ref.read(stockInRepositoryProvider).submit(id); + reload(); + } + + Future approveOrder(int id) async { + await ref.read(stockInRepositoryProvider).approve(id); + reload(); + } + + Future rejectOrder(int id) async { + await ref.read(stockInRepositoryProvider).reject(id); + reload(); + } +} diff --git a/client/lib/providers/stock_out_provider.dart b/client/lib/providers/stock_out_provider.dart new file mode 100644 index 0000000..11b17c0 --- /dev/null +++ b/client/lib/providers/stock_out_provider.dart @@ -0,0 +1,83 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/api/api_client.dart'; +import '../core/auth/auth_state.dart'; +import '../core/models/page_result.dart'; +import '../models/stock_out.dart'; +import '../repositories/stock_out_repository.dart'; + +final stockOutRepositoryProvider = Provider((ref) { + return StockOutRepository(ref.watch(apiClientProvider)); +}); + +final stockOutListProvider = + AsyncNotifierProvider>( + StockOutListNotifier.new, +); + +class StockOutListNotifier extends AsyncNotifier> { + int _page = 1; + String _status = ''; + String? _startDate; + String? _endDate; + + @override + Future> build() { + ref.watch(authStateProvider.select((s) => s.user?.shopId)); + return _fetch(); + } + + Future> _fetch() { + return ref.read(stockOutRepositoryProvider).list( + status: _status.isEmpty ? null : _status, + startDate: _startDate, + endDate: _endDate, + page: _page, + ); + } + + void setPage(int page) { + _page = page; + reload(); + } + + void setStatus(String status) { + _status = status; + _page = 1; + reload(); + } + + void setDateRange(String? startDate, String? endDate) { + _startDate = startDate; + _endDate = endDate; + _page = 1; + reload(); + } + + void reload() { + state = const AsyncValue.loading(); + _fetch().then( + (result) => state = AsyncValue.data(result), + onError: (e, st) => state = AsyncValue.error(e, st), + ); + } + + Future createOrder(Map data) async { + await ref.read(stockOutRepositoryProvider).create(data); + reload(); + } + + Future submitOrder(int id) async { + await ref.read(stockOutRepositoryProvider).submit(id); + reload(); + } + + Future approveOrder(int id) async { + await ref.read(stockOutRepositoryProvider).approve(id); + reload(); + } + + Future rejectOrder(int id) async { + await ref.read(stockOutRepositoryProvider).reject(id); + reload(); + } +} diff --git a/client/lib/providers/warehouse_provider.dart b/client/lib/providers/warehouse_provider.dart new file mode 100644 index 0000000..7b126d3 --- /dev/null +++ b/client/lib/providers/warehouse_provider.dart @@ -0,0 +1,43 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/api/api_client.dart'; +import '../core/auth/auth_state.dart'; +import '../models/warehouse.dart'; +import '../repositories/warehouse_repository.dart'; + +final warehouseRepositoryProvider = Provider((ref) { + return WarehouseRepository(ref.watch(apiClientProvider)); +}); + +final warehouseListProvider = + AsyncNotifierProvider>( + WarehouseListNotifier.new, +); + +class WarehouseListNotifier extends AsyncNotifier> { + @override + Future> build() { + ref.watch(authStateProvider.select((s) => s.user?.shopId)); + return ref.read(warehouseRepositoryProvider).list(); + } + + Future reload() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard( + () => ref.read(warehouseRepositoryProvider).list()); + } + + Future createWarehouse(Map data) async { + await ref.read(warehouseRepositoryProvider).create(data); + await reload(); + } + + Future updateWarehouse(int id, Map data) async { + await ref.read(warehouseRepositoryProvider).update(id, data); + await reload(); + } + + Future deleteWarehouse(int id) async { + await ref.read(warehouseRepositoryProvider).delete(id); + await reload(); + } +} diff --git a/client/lib/repositories/auth_repository.dart b/client/lib/repositories/auth_repository.dart index bdc3008..9474e7a 100644 --- a/client/lib/repositories/auth_repository.dart +++ b/client/lib/repositories/auth_repository.dart @@ -11,16 +11,16 @@ class AuthException implements Exception { class AuthRepository { /// POST /api/v1/auth/login - /// Request: { hotel_code, username, password } + /// Request: { shop_code, username, password } /// Response: { data: { access_token, refresh_token, expires_in, user: { id, username, real_name, role } } } static Future login({ - required String hotelCode, + required String shopCode, required String username, required String password, }) async { try { final resp = await PublicApiClient.post('/auth/login', data: { - 'hotel_code': hotelCode, + 'shop_code': shopCode, 'username': username, 'password': password, }); @@ -33,8 +33,8 @@ class AuthRepository { refreshToken: data['refresh_token'] as String, username: user['username'] as String, realName: user['real_name'] as String? ?? username, - hotelNo: hotelCode, - hotelId: (user['id'] as num).toInt(), + shopNo: shopCode, + shopId: (data['shop_id'] as num).toInt(), ); } on DioException catch (e) { final msg = e.response?.data?['error'] as String?; diff --git a/client/lib/repositories/inventory_repository.dart b/client/lib/repositories/inventory_repository.dart new file mode 100644 index 0000000..e5a3bd0 --- /dev/null +++ b/client/lib/repositories/inventory_repository.dart @@ -0,0 +1,63 @@ +import 'package:dio/dio.dart'; +import '../core/api/api_client.dart'; +import '../core/exceptions.dart'; +import '../core/models/page_result.dart'; +import '../models/inventory.dart'; + +class InventoryRepository { + final ApiClient _client; + + const InventoryRepository(this._client); + + Future> listInventory({ + int? warehouseId, + String? keyword, + int page = 1, + int pageSize = 50, + }) async { + try { + final params = { + 'page': page, + 'page_size': pageSize, + if (warehouseId != null) 'warehouse_id': warehouseId, + if (keyword != null && keyword.isNotEmpty) 'keyword': keyword, + }; + final resp = await _client.get('/inventory', params: params); + return PageResult.fromJson( + resp.data as Map, + Inventory.fromJson, + ); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '获取库存失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future> listLogs({ + int? warehouseId, + int? productId, + int page = 1, + int pageSize = 50, + }) async { + try { + final params = { + 'page': page, + 'page_size': pageSize, + if (warehouseId != null) 'warehouse_id': warehouseId, + if (productId != null) 'product_id': productId, + }; + final resp = await _client.get('/inventory/logs', params: params); + return PageResult.fromJson( + resp.data as Map, + InventoryLog.fromJson, + ); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '获取流水失败', + statusCode: e.response?.statusCode, + ); + } + } +} diff --git a/client/lib/repositories/partner_repository.dart b/client/lib/repositories/partner_repository.dart new file mode 100644 index 0000000..647776f --- /dev/null +++ b/client/lib/repositories/partner_repository.dart @@ -0,0 +1,74 @@ +import 'package:dio/dio.dart'; +import '../core/api/api_client.dart'; +import '../core/exceptions.dart'; +import '../core/models/page_result.dart'; +import '../models/partner.dart'; + +class PartnerRepository { + final ApiClient _client; + + const PartnerRepository(this._client); + + Future> list({ + String? type, + String? keyword, + int page = 1, + int pageSize = 20, + }) async { + try { + final params = { + 'page': page, + 'page_size': pageSize, + if (type != null && type.isNotEmpty) 'type': type, + if (keyword != null && keyword.isNotEmpty) 'keyword': keyword, + }; + final resp = await _client.get('/partners', params: params); + return PageResult.fromJson( + resp.data as Map, + Partner.fromJson, + ); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '获取往来单位列表失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future create(Map data) async { + try { + final resp = await _client.post('/partners', data: data); + return Partner.fromJson( + (resp.data as Map)['data'] as Map); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '创建往来单位失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future update(int id, Map data) async { + try { + final resp = await _client.put('/partners/$id', data: data); + return Partner.fromJson( + (resp.data as Map)['data'] as Map); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '更新往来单位失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future delete(int id) async { + try { + await _client.delete('/partners/$id'); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '删除往来单位失败', + statusCode: e.response?.statusCode, + ); + } + } +} diff --git a/client/lib/repositories/product_repository.dart b/client/lib/repositories/product_repository.dart new file mode 100644 index 0000000..6685fa3 --- /dev/null +++ b/client/lib/repositories/product_repository.dart @@ -0,0 +1,74 @@ +import 'package:dio/dio.dart'; +import '../core/api/api_client.dart'; +import '../core/exceptions.dart'; +import '../core/models/page_result.dart'; +import '../models/product.dart'; + +class ProductRepository { + final ApiClient _client; + + const ProductRepository(this._client); + + Future> list({ + int page = 1, + int pageSize = 20, + String? keyword, + int? categoryId, + }) async { + try { + final params = { + 'page': page, + 'page_size': pageSize, + if (keyword != null && keyword.isNotEmpty) 'keyword': keyword, + if (categoryId != null) 'category_id': categoryId, + }; + final resp = await _client.get('/products', params: params); + return PageResult.fromJson( + resp.data as Map, + Product.fromJson, + ); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '获取商品列表失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future create(Map data) async { + try { + final resp = await _client.post('/products', data: data); + return Product.fromJson( + (resp.data as Map)['data'] as Map); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '创建商品失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future update(int id, Map data) async { + try { + final resp = await _client.put('/products/$id', data: data); + return Product.fromJson( + (resp.data as Map)['data'] as Map); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '更新商品失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future delete(int id) async { + try { + await _client.delete('/products/$id'); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '删除商品失败', + statusCode: e.response?.statusCode, + ); + } + } +} diff --git a/client/lib/repositories/stock_in_repository.dart b/client/lib/repositories/stock_in_repository.dart new file mode 100644 index 0000000..f2f6d79 --- /dev/null +++ b/client/lib/repositories/stock_in_repository.dart @@ -0,0 +1,98 @@ +import 'package:dio/dio.dart'; +import '../core/api/api_client.dart'; +import '../core/exceptions.dart'; +import '../core/models/page_result.dart'; +import '../models/stock_in.dart'; + +class StockInRepository { + final ApiClient _client; + + const StockInRepository(this._client); + + Future> list({ + String? status, + String? startDate, + String? endDate, + int page = 1, + int pageSize = 20, + }) async { + try { + final params = { + 'page': page, + 'page_size': pageSize, + if (status != null && status.isNotEmpty) 'status': status, + if (startDate != null) 'start_date': startDate, + if (endDate != null) 'end_date': endDate, + }; + final resp = await _client.get('/stock-in/orders', params: params); + return PageResult.fromJson( + resp.data as Map, + StockInOrder.fromJson, + ); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '获取入库单列表失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future get(int id) async { + try { + final resp = await _client.get('/stock-in/orders/$id'); + return StockInOrder.fromJson( + (resp.data as Map)['data'] as Map); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '获取入库单详情失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future create(Map data) async { + try { + final resp = await _client.post('/stock-in/orders', data: data); + return StockInOrder.fromJson( + (resp.data as Map)['data'] as Map); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '创建入库单失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future submit(int id) async { + try { + await _client.put('/stock-in/orders/$id/submit'); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '提交审核失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future approve(int id) async { + try { + await _client.put('/stock-in/orders/$id/approve'); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '审核通过失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future reject(int id) async { + try { + await _client.put('/stock-in/orders/$id/reject'); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '拒绝失败', + statusCode: e.response?.statusCode, + ); + } + } +} diff --git a/client/lib/repositories/stock_out_repository.dart b/client/lib/repositories/stock_out_repository.dart new file mode 100644 index 0000000..cc8e313 --- /dev/null +++ b/client/lib/repositories/stock_out_repository.dart @@ -0,0 +1,98 @@ +import 'package:dio/dio.dart'; +import '../core/api/api_client.dart'; +import '../core/exceptions.dart'; +import '../core/models/page_result.dart'; +import '../models/stock_out.dart'; + +class StockOutRepository { + final ApiClient _client; + + const StockOutRepository(this._client); + + Future> list({ + String? status, + String? startDate, + String? endDate, + int page = 1, + int pageSize = 20, + }) async { + try { + final params = { + 'page': page, + 'page_size': pageSize, + if (status != null && status.isNotEmpty) 'status': status, + if (startDate != null) 'start_date': startDate, + if (endDate != null) 'end_date': endDate, + }; + final resp = await _client.get('/stock-out/orders', params: params); + return PageResult.fromJson( + resp.data as Map, + StockOutOrder.fromJson, + ); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '获取出库单列表失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future get(int id) async { + try { + final resp = await _client.get('/stock-out/orders/$id'); + return StockOutOrder.fromJson( + (resp.data as Map)['data'] as Map); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '获取出库单详情失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future create(Map data) async { + try { + final resp = await _client.post('/stock-out/orders', data: data); + return StockOutOrder.fromJson( + (resp.data as Map)['data'] as Map); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '创建出库单失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future submit(int id) async { + try { + await _client.put('/stock-out/orders/$id/submit'); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '提交审核失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future approve(int id) async { + try { + await _client.put('/stock-out/orders/$id/approve'); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '审核通过失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future reject(int id) async { + try { + await _client.put('/stock-out/orders/$id/reject'); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '拒绝失败', + statusCode: e.response?.statusCode, + ); + } + } +} diff --git a/client/lib/repositories/warehouse_repository.dart b/client/lib/repositories/warehouse_repository.dart new file mode 100644 index 0000000..0f0ab57 --- /dev/null +++ b/client/lib/repositories/warehouse_repository.dart @@ -0,0 +1,63 @@ +import 'package:dio/dio.dart'; +import '../core/api/api_client.dart'; +import '../core/exceptions.dart'; +import '../models/warehouse.dart'; + +class WarehouseRepository { + final ApiClient _client; + + const WarehouseRepository(this._client); + + Future> list() async { + try { + final resp = await _client.get('/warehouses'); + final body = resp.data as Map; + final raw = body['data'] as List; + return raw + .map((e) => Warehouse.fromJson(e as Map)) + .toList(); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '获取仓库列表失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future create(Map data) async { + try { + final resp = await _client.post('/warehouses', data: data); + return Warehouse.fromJson( + (resp.data as Map)['data'] as Map); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '创建仓库失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future update(int id, Map data) async { + try { + final resp = await _client.put('/warehouses/$id', data: data); + return Warehouse.fromJson( + (resp.data as Map)['data'] as Map); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '更新仓库失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future delete(int id) async { + try { + await _client.delete('/warehouses/$id'); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '删除仓库失败', + statusCode: e.response?.statusCode, + ); + } + } +} diff --git a/client/lib/screens/auth/login_screen.dart b/client/lib/screens/auth/login_screen.dart index dcbb054..2c9ca48 100644 --- a/client/lib/screens/auth/login_screen.dart +++ b/client/lib/screens/auth/login_screen.dart @@ -15,7 +15,7 @@ class LoginScreen extends ConsumerStatefulWidget { class _LoginScreenState extends ConsumerState { final _formKey = GlobalKey(); - final _hotelCodeCtrl = TextEditingController(); + final _shopCodeCtrl = TextEditingController(); final _usernameCtrl = TextEditingController(); final _passwordCtrl = TextEditingController(); final _hotelCodeFocus = FocusNode(); @@ -42,7 +42,7 @@ class _LoginScreenState extends ConsumerState { _loadHistory(); _hotelCodeFocus.addListener(() { if (_hotelCodeFocus.hasFocus && _hotelCodeHistory.isNotEmpty) { - _openDropdown(_hotelLayerLink, _hotelCodeHistory, _hotelCodeCtrl, + _openDropdown(_hotelLayerLink, _hotelCodeHistory, _shopCodeCtrl, isHotel: true); } else if (!_hotelCodeFocus.hasFocus) { _closeHotel(); @@ -68,7 +68,7 @@ class _LoginScreenState extends ConsumerState { }); // If a field is already focused, open its dropdown now that history loaded if (_hotelCodeFocus.hasFocus && hotels.isNotEmpty) { - _openDropdown(_hotelLayerLink, hotels, _hotelCodeCtrl, isHotel: true); + _openDropdown(_hotelLayerLink, hotels, _shopCodeCtrl, isHotel: true); } if (_usernameFocus.hasFocus && users.isNotEmpty) { _openDropdown(_usernameLayerLink, users, _usernameCtrl, isHotel: false); @@ -174,7 +174,7 @@ class _LoginScreenState extends ConsumerState { void dispose() { _hotelEntry?.remove(); _usernameEntry?.remove(); - _hotelCodeCtrl.dispose(); + _shopCodeCtrl.dispose(); _usernameCtrl.dispose(); _passwordCtrl.dispose(); _hotelCodeFocus.dispose(); @@ -189,17 +189,21 @@ class _LoginScreenState extends ConsumerState { _errorMessage = null; }); try { + debugPrint('[Login] calling AuthRepository.login...'); final user = await AuthRepository.login( - hotelCode: _hotelCodeCtrl.text.trim(), + shopCode: _shopCodeCtrl.text.trim(), username: _usernameCtrl.text.trim(), password: _passwordCtrl.text, ); + debugPrint('[Login] API success, username=${user.username}'); await LoginHistoryStorage.record( - _hotelCodeCtrl.text.trim(), + _shopCodeCtrl.text.trim(), _usernameCtrl.text.trim(), ); await ref.read(authStateProvider.notifier).login(user); + debugPrint('[Login] notifier.login done, mounted=$mounted, going to /stock-in'); if (mounted) context.go('/stock-in'); + debugPrint('[Login] context.go called'); } on AuthException catch (e) { setState(() => _errorMessage = e.message); } catch (e) { @@ -283,11 +287,11 @@ class _LoginScreenState extends ConsumerState { ), const SizedBox(height: 32), - // Hotel code field + // Shop code field CompositedTransformTarget( link: _hotelLayerLink, child: TextFormField( - controller: _hotelCodeCtrl, + controller: _shopCodeCtrl, focusNode: _hotelCodeFocus, decoration: InputDecoration( labelText: '门店编号', diff --git a/client/lib/screens/inventory/inventory_list_screen.dart b/client/lib/screens/inventory/inventory_list_screen.dart index e42fa78..e49f0ad 100644 --- a/client/lib/screens/inventory/inventory_list_screen.dart +++ b/client/lib/screens/inventory/inventory_list_screen.dart @@ -1,9 +1,13 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import '../../widgets/page_scaffold.dart'; -import '../../widgets/data_table_card.dart'; import '../../core/theme/app_theme.dart'; +import '../../models/inventory.dart'; +import '../../providers/inventory_provider.dart'; +import '../../providers/warehouse_provider.dart'; +import '../../widgets/data_table_card.dart'; +import '../../widgets/page_scaffold.dart'; class InventoryListScreen extends ConsumerStatefulWidget { const InventoryListScreen({super.key}); @@ -14,256 +18,24 @@ class InventoryListScreen extends ConsumerStatefulWidget { } class _InventoryListScreenState extends ConsumerState { - int _page = 1; final _searchCtrl = TextEditingController(); - String _categoryFilter = '全部'; - String _warehouseFilter = '全部'; - - final List> _mockInventory = [ - { - 'code': 'SP001', - 'name': '茅台酒(飞天)53度500ml', - 'category': '白酒', - 'brand': '茅台', - 'spec': '500ml/瓶', - 'unit': '瓶', - 'warehouse': '主仓库', - 'qty': 286, - 'available': 280, - 'reserved': 6, - 'cost': 2100.00, - 'price': 2600.00, - 'totalValue': '600600.00', - 'minQty': 50, - 'status': '正常', - }, - { - 'code': 'SP002', - 'name': '五粮液(普五)52度500ml', - 'category': '白酒', - 'brand': '五粮液', - 'spec': '500ml/瓶', - 'unit': '瓶', - 'warehouse': '主仓库', - 'qty': 152, - 'available': 148, - 'reserved': 4, - 'cost': 850.00, - 'price': 1050.00, - 'totalValue': '129200.00', - 'minQty': 30, - 'status': '正常', - }, - { - 'code': 'SP003', - 'name': '洋河梦之蓝M6+ 45度500ml', - 'category': '白酒', - 'brand': '洋河', - 'spec': '500ml/瓶', - 'unit': '瓶', - 'warehouse': '主仓库', - 'qty': 88, - 'available': 85, - 'reserved': 3, - 'cost': 480.00, - 'price': 598.00, - 'totalValue': '42240.00', - 'minQty': 20, - 'status': '正常', - }, - { - 'code': 'SP004', - 'name': '剑南春(水晶剑)52度500ml', - 'category': '白酒', - 'brand': '剑南春', - 'spec': '500ml/瓶', - 'unit': '瓶', - 'warehouse': '副仓库', - 'qty': 12, - 'available': 12, - 'reserved': 0, - 'cost': 288.00, - 'price': 368.00, - 'totalValue': '3456.00', - 'minQty': 20, - 'status': '库存不足', - }, - { - 'code': 'SP005', - 'name': '泸州老窖(国窖1573)52度500ml', - 'category': '白酒', - 'brand': '泸州老窖', - 'spec': '500ml/瓶', - 'unit': '瓶', - 'warehouse': '主仓库', - 'qty': 68, - 'available': 65, - 'reserved': 3, - 'cost': 680.00, - 'price': 860.00, - 'totalValue': '46240.00', - 'minQty': 20, - 'status': '正常', - }, - { - 'code': 'SP006', - 'name': '汾酒(青花30)53度500ml', - 'category': '白酒', - 'brand': '汾酒', - 'spec': '500ml/瓶', - 'unit': '瓶', - 'warehouse': '主仓库', - 'qty': 45, - 'available': 45, - 'reserved': 0, - 'cost': 320.00, - 'price': 418.00, - 'totalValue': '14400.00', - 'minQty': 15, - 'status': '正常', - }, - { - 'code': 'SP007', - 'name': '拉菲古堡正牌红葡萄酒2018', - 'category': '葡萄酒', - 'brand': '拉菲', - 'spec': '750ml/瓶', - 'unit': '瓶', - 'warehouse': '副仓库', - 'qty': 24, - 'available': 24, - 'reserved': 0, - 'cost': 5200.00, - 'price': 6800.00, - 'totalValue': '124800.00', - 'minQty': 6, - 'status': '正常', - }, - { - 'code': 'SP008', - 'name': '人头马XO特优香槟干邑700ml', - 'category': '洋酒', - 'brand': '人头马', - 'spec': '700ml/瓶', - 'unit': '瓶', - 'warehouse': '副仓库', - 'qty': 18, - 'available': 16, - 'reserved': 2, - 'cost': 1680.00, - 'price': 2180.00, - 'totalValue': '30240.00', - 'minQty': 6, - 'status': '正常', - }, - { - 'code': 'SP009', - 'name': '百威啤酒330ml', - 'category': '啤酒', - 'brand': '百威', - 'spec': '330ml×24罐', - 'unit': '箱', - 'warehouse': '主仓库', - 'qty': 8, - 'available': 6, - 'reserved': 2, - 'cost': 58.00, - 'price': 88.00, - 'totalValue': '464.00', - 'minQty': 20, - 'status': '库存不足', - }, - { - 'code': 'SP010', - 'name': '青岛啤酒(经典)500ml', - 'category': '啤酒', - 'brand': '青岛', - 'spec': '500ml×12瓶', - 'unit': '箱', - 'warehouse': '主仓库', - 'qty': 35, - 'available': 35, - 'reserved': 0, - 'cost': 42.00, - 'price': 68.00, - 'totalValue': '1470.00', - 'minQty': 20, - 'status': '正常', - }, - { - 'code': 'SP011', - 'name': '芝华士12年苏格兰威士忌700ml', - 'category': '洋酒', - 'brand': '芝华士', - 'spec': '700ml/瓶', - 'unit': '瓶', - 'warehouse': '副仓库', - 'qty': 30, - 'available': 28, - 'reserved': 2, - 'cost': 288.00, - 'price': 398.00, - 'totalValue': '8640.00', - 'minQty': 10, - 'status': '正常', - }, - { - 'code': 'SP012', - 'name': '郎酒红花郎15年53度500ml', - 'category': '白酒', - 'brand': '郎酒', - 'spec': '500ml/瓶', - 'unit': '瓶', - 'warehouse': '主仓库', - 'qty': 0, - 'available': 0, - 'reserved': 0, - 'cost': 620.00, - 'price': 798.00, - 'totalValue': '0.00', - 'minQty': 10, - 'status': '缺货', - }, - ]; + Timer? _debounce; + int? _warehouseFilter; @override void dispose() { _searchCtrl.dispose(); + _debounce?.cancel(); super.dispose(); } - List> get _filtered { - return _mockInventory.where((item) { - if (_categoryFilter != '全部' && - item['category'] != _categoryFilter) return false; - if (_warehouseFilter != '全部' && - item['warehouse'] != _warehouseFilter) return false; - final q = _searchCtrl.text.toLowerCase(); - if (q.isNotEmpty) { - final name = (item['name'] as String).toLowerCase(); - final code = (item['code'] as String).toLowerCase(); - final brand = (item['brand'] as String).toLowerCase(); - if (!name.contains(q) && !code.contains(q) && !brand.contains(q)) { - return false; - } - } - return true; - }).toList(); - } - - // Summary stats - double get _totalInventoryValue { - return _mockInventory.fold(0, (sum, item) { - return sum + (double.tryParse(item['totalValue'] as String) ?? 0); + void _onSearchChanged(String value) { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 300), () { + ref.read(inventoryListProvider.notifier).setKeyword(value); }); } - int get _lowStockCount { - return _mockInventory.where((item) { - return (item['qty'] as int) < (item['minQty'] as int); - }).length; - } - @override Widget build(BuildContext context) { return PageScaffold( @@ -271,281 +43,429 @@ class _InventoryListScreenState extends ConsumerState { tabs: const [ Tab(text: '库存查询'), Tab(text: '库存预警'), - Tab(text: '库存盘点'), + Tab(text: '流水记录'), ], tabViews: [ - _buildInventoryList(), - _buildWarningList(), - _buildCheckTab(), + _buildInventoryTab(), + _buildWarningTab(), + _buildLogTab(), ], ); } - Widget _buildInventoryList() { - final items = _filtered; + Widget _buildInventoryTab() { + final asyncInventory = ref.watch(inventoryListProvider); + final asyncWarehouses = ref.watch(warehouseListProvider); - return Column( - children: [ - // Summary cards - Container( - color: AppTheme.background, - padding: const EdgeInsets.all(12), - child: Row( + return asyncInventory.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('加载失败:$e', + style: const TextStyle(color: AppTheme.danger)), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () => + ref.read(inventoryListProvider.notifier).reload(), + child: const Text('重试'), + ), + ], + ), + ), + data: (result) { + final items = result.data; + final warningCount = + items.where((i) => i.minStock != null && i.quantity < i.minStock!).length; + final emptyCount = items.where((i) => i.quantity == 0).length; + + return Column( + children: [ + // Summary cards + Container( + color: AppTheme.background, + padding: const EdgeInsets.all(12), + child: Row( + children: [ + _SummaryCard( + title: '商品总数', + value: '${items.length}', + unit: '种', + icon: Icons.inventory_2, + color: AppTheme.primary), + const SizedBox(width: 12), + _SummaryCard( + title: '库存预警', + value: '$warningCount', + unit: '种', + icon: Icons.warning_amber, + color: AppTheme.accent), + const SizedBox(width: 12), + _SummaryCard( + title: '缺货商品', + value: '$emptyCount', + unit: '种', + icon: Icons.remove_shopping_cart, + color: AppTheme.danger), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: DataTableCard( + totalCount: result.total, + page: result.page, + onPageChanged: (p) => + ref.read(inventoryListProvider.notifier).setPage(p), + toolbar: Row( + children: [ + OutlinedButton.icon( + onPressed: () => context.go('/inventory/check'), + icon: const Icon(Icons.fact_check, size: 16), + label: const Text('发起盘点'), + ), + const Spacer(), + // Warehouse filter + asyncWarehouses.when( + loading: () => const SizedBox(), + error: (_, __) => const SizedBox(), + data: (warehouses) => Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + border: Border.all(color: AppTheme.border), + borderRadius: BorderRadius.circular(4), + color: AppTheme.surface, + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _warehouseFilter, + hint: const Text('全部仓库', + style: TextStyle(fontSize: 13)), + items: [ + const DropdownMenuItem( + value: null, + child: Text('全部仓库', + style: TextStyle(fontSize: 13))), + ...warehouses.map((w) => + DropdownMenuItem( + value: w.id, + child: Text(w.name, + style: const TextStyle( + fontSize: 13)))), + ], + onChanged: (v) { + setState(() => _warehouseFilter = v); + ref + .read(inventoryListProvider.notifier) + .setWarehouseId(v); + }, + style: const TextStyle( + fontSize: 13, + color: AppTheme.textPrimary), + ), + ), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 200, + child: TextField( + controller: _searchCtrl, + decoration: const InputDecoration( + hintText: '搜索商品名/编码', + prefixIcon: Icon(Icons.search, size: 16), + hintStyle: TextStyle(fontSize: 13), + ), + onChanged: _onSearchChanged, + ), + ), + ], + ), + columns: const [ + DataColumn(label: Text('商品编码')), + DataColumn(label: Text('商品名称')), + DataColumn(label: Text('品牌')), + DataColumn(label: Text('规格')), + DataColumn(label: Text('仓库')), + DataColumn(label: Text('库存'), numeric: true), + DataColumn(label: Text('安全库存'), numeric: true), + DataColumn(label: Text('状态')), + ], + rows: items.isEmpty + ? [ + const DataRow(cells: [ + DataCell(SizedBox()), + DataCell(Text('暂无库存数据', + style: TextStyle( + color: AppTheme.textSecondary))), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + ]) + ] + : items + .map((item) => DataRow( + color: WidgetStateProperty.resolveWith( + (states) { + if (item.quantity == 0) { + return AppTheme.danger.withOpacity(0.04); + } + if (item.minStock != null && + item.quantity < item.minStock!) { + return AppTheme.accent.withOpacity(0.04); + } + return null; + }), + cells: [ + DataCell(Text( + item.productCode ?? '-', + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: AppTheme.textSecondary))), + DataCell(SizedBox( + width: 180, + child: Text( + item.productName ?? '-', + overflow: TextOverflow.ellipsis), + )), + DataCell( + Text(item.productBrand ?? '-')), + DataCell( + Text(item.productSpec ?? '-')), + DataCell( + Text(item.warehouseName ?? '-')), + DataCell(Text( + item.quantity.toStringAsFixed(0), + style: TextStyle( + fontWeight: FontWeight.w600, + color: item.quantity == 0 + ? AppTheme.danger + : (item.minStock != null && + item.quantity < + item.minStock!) + ? AppTheme.accent + : AppTheme.textPrimary), + )), + DataCell(Text(item.minStock != null + ? '${item.minStock}' + : '-')), + DataCell(_InventoryStatusBadge(item)), + ], + )) + .toList(), + ), + ), + ], + ); + }, + ); + } + + Widget _buildWarningTab() { + final asyncInventory = ref.watch(inventoryListProvider); + return asyncInventory.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('加载失败:$e', + style: const TextStyle(color: AppTheme.danger)), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () => + ref.read(inventoryListProvider.notifier).reload(), + child: const Text('重试'), + ), + ], + ), + ), + data: (result) { + final warnings = result.data + .where((item) => + item.minStock != null && item.quantity < item.minStock!) + .toList(); + return DataTableCard( + totalCount: warnings.length, + page: 1, + toolbar: Row( children: [ - _SummaryCard( - title: '商品总数', - value: '${_mockInventory.length}', - unit: '种', - icon: Icons.inventory_2, - color: AppTheme.primary), - const SizedBox(width: 12), - _SummaryCard( - title: '库存总价值', - value: '¥${(_totalInventoryValue / 10000).toStringAsFixed(1)}万', - unit: '', - icon: Icons.monetization_on, - color: AppTheme.success), - const SizedBox(width: 12), - _SummaryCard( - title: '库存预警', - value: '$_lowStockCount', - unit: '种', - icon: Icons.warning_amber, - color: AppTheme.accent), - const SizedBox(width: 12), - _SummaryCard( - title: '缺货商品', - value: - '${_mockInventory.where((i) => (i['qty'] as int) == 0).length}', - unit: '种', - icon: Icons.remove_shopping_cart, - color: AppTheme.danger), + const Icon(Icons.warning_amber, + color: AppTheme.accent, size: 18), + const SizedBox(width: 8), + Text( + '共 ${warnings.length} 个商品库存低于安全库存', + style: const TextStyle( + fontSize: 13, color: AppTheme.accent), + ), ], ), - ), - const Divider(height: 1), - Expanded( - child: DataTableCard( - totalCount: items.length, - page: _page, - onPageChanged: (p) => setState(() => _page = p), - toolbar: Row( - children: [ - OutlinedButton.icon( - onPressed: () => context.go('/inventory/check'), - icon: const Icon(Icons.fact_check, size: 16), - label: const Text('发起盘点'), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.file_download_outlined, size: 16), - label: const Text('导出'), - ), - const Spacer(), - _DropdownFilter( - value: _categoryFilter, - items: ['全部', '白酒', '葡萄酒', '洋酒', '啤酒'], - onChanged: (v) => setState(() => _categoryFilter = v!), - hint: '商品分类'), - const SizedBox(width: 8), - _DropdownFilter( - value: _warehouseFilter, - items: ['全部', '主仓库', '副仓库'], - onChanged: (v) => - setState(() => _warehouseFilter = v!), - hint: '仓库'), - const SizedBox(width: 8), - SizedBox( - width: 180, - child: TextField( - controller: _searchCtrl, - decoration: const InputDecoration( - hintText: '搜索商品名/编码/品牌', - prefixIcon: Icon(Icons.search, size: 16), - hintStyle: TextStyle(fontSize: 13), - ), - onChanged: (_) => setState(() => _page = 1), - ), - ), - ], - ), - columns: const [ - DataColumn(label: Text('商品编码')), - DataColumn(label: Text('商品名称')), - DataColumn(label: Text('分类')), - DataColumn(label: Text('品牌')), - DataColumn(label: Text('仓库')), - DataColumn(label: Text('库存'), numeric: true), - DataColumn(label: Text('可用'), numeric: true), - DataColumn(label: Text('预留'), numeric: true), - DataColumn(label: Text('成本价'), numeric: true), - DataColumn(label: Text('库存价值'), numeric: true), - DataColumn(label: Text('状态')), - ], - rows: items - .map((item) => DataRow( - color: WidgetStateProperty.resolveWith((states) { - if ((item['qty'] as int) == 0) { - return AppTheme.danger.withOpacity(0.04); - } - if ((item['qty'] as int) < (item['minQty'] as int)) { - return AppTheme.accent.withOpacity(0.04); - } - return null; - }), - cells: [ - DataCell(Text(item['code'] as String, + columns: const [ + DataColumn(label: Text('商品编码')), + DataColumn(label: Text('商品名称')), + DataColumn(label: Text('仓库')), + DataColumn(label: Text('当前库存'), numeric: true), + DataColumn(label: Text('安全库存'), numeric: true), + DataColumn(label: Text('缺口'), numeric: true), + DataColumn(label: Text('状态')), + ], + rows: warnings.isEmpty + ? [ + const DataRow(cells: [ + DataCell(SizedBox()), + DataCell(Text('暂无预警商品', + style: TextStyle( + color: AppTheme.textSecondary))), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + ]) + ] + : warnings + .map((item) => DataRow( + color: WidgetStateProperty.all( + item.quantity == 0 + ? AppTheme.danger.withOpacity(0.05) + : AppTheme.accent.withOpacity(0.04)), + cells: [ + DataCell(Text(item.productCode ?? '-', + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12))), + DataCell(SizedBox( + width: 180, + child: Text(item.productName ?? '-', + overflow: TextOverflow.ellipsis), + )), + DataCell( + Text(item.warehouseName ?? '-')), + DataCell(Text( + item.quantity.toStringAsFixed(0), + style: TextStyle( + fontWeight: FontWeight.w700, + color: item.quantity == 0 + ? AppTheme.danger + : AppTheme.accent), + )), + DataCell(Text('${item.minStock}')), + DataCell(Text( + '${item.minStock! - item.quantity.toInt()}', + style: const TextStyle( + color: AppTheme.danger, + fontWeight: FontWeight.w600), + )), + DataCell(_InventoryStatusBadge(item)), + ], + )) + .toList(), + ); + }, + ); + } + + Widget _buildLogTab() { + final asyncLogs = ref.watch(inventoryLogProvider); + return asyncLogs.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('加载失败:$e', + style: const TextStyle(color: AppTheme.danger)), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () => + ref.read(inventoryLogProvider.notifier).reload(), + child: const Text('重试'), + ), + ], + ), + ), + data: (result) { + final logs = result.data; + return DataTableCard( + totalCount: result.total, + page: result.page, + onPageChanged: (p) => + ref.read(inventoryLogProvider.notifier).setPage(p), + toolbar: const Row( + children: [ + Icon(Icons.history, color: AppTheme.primary, size: 18), + SizedBox(width: 8), + Text('库存流水记录', + style: TextStyle( + fontSize: 14, fontWeight: FontWeight.w500)), + ], + ), + columns: const [ + DataColumn(label: Text('商品名称')), + DataColumn(label: Text('仓库')), + DataColumn(label: Text('方向')), + DataColumn(label: Text('数量'), numeric: true), + DataColumn(label: Text('变前'), numeric: true), + DataColumn(label: Text('变后'), numeric: true), + DataColumn(label: Text('来源')), + DataColumn(label: Text('时间')), + ], + rows: logs.isEmpty + ? [ + const DataRow(cells: [ + DataCell(SizedBox()), + DataCell(Text('暂无流水记录', + style: TextStyle( + color: AppTheme.textSecondary))), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + ]) + ] + : logs + .map((log) => DataRow(cells: [ + DataCell(SizedBox( + width: 160, + child: Text(log.productName ?? '-', + overflow: TextOverflow.ellipsis), + )), + DataCell(Text(log.warehouseName ?? '-')), + DataCell(_DirectionBadge(log.direction)), + DataCell(Text( + log.quantity.toStringAsFixed(0), + style: TextStyle( + fontWeight: FontWeight.w600, + color: log.direction == 'in' + ? AppTheme.success + : AppTheme.danger), + )), + DataCell(Text( + log.qtyBefore?.toStringAsFixed(0) ?? '-')), + DataCell(Text( + log.qtyAfter?.toStringAsFixed(0) ?? '-')), + DataCell(Text(log.refType ?? '-', style: const TextStyle( - fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))), - DataCell(SizedBox( - width: 200, - child: Text(item['name'] as String, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 13)), - )), - DataCell(Text(item['category'] as String)), - DataCell(Text(item['brand'] as String)), - DataCell(Text(item['warehouse'] as String)), - DataCell(Text('${item['qty']}', - style: TextStyle( - fontWeight: FontWeight.w600, - color: (item['qty'] as int) == 0 - ? AppTheme.danger - : (item['qty'] as int) < - (item['minQty'] as int) - ? AppTheme.accent - : AppTheme.textPrimary))), - DataCell(Text('${item['available']}')), - DataCell(Text('${item['reserved']}', - style: TextStyle( - color: (item['reserved'] as int) > 0 - ? AppTheme.accent - : AppTheme.textSecondary))), DataCell(Text( - '¥${(item['cost'] as double).toStringAsFixed(2)}')), - DataCell(Text('¥${item['totalValue']}', - style: const TextStyle( - fontWeight: FontWeight.w500))), - DataCell(_InventoryStatusBadge( - item['status'] as String)), - ], - )) - .toList(), - ), - ), - ], - ); - } - - Widget _buildWarningList() { - final warnings = _mockInventory - .where((item) => (item['qty'] as int) < (item['minQty'] as int)) - .toList(); - return DataTableCard( - totalCount: warnings.length, - page: 1, - toolbar: Row( - children: [ - const Icon(Icons.warning_amber, color: AppTheme.accent, size: 18), - const SizedBox(width: 8), - Text( - '共 ${warnings.length} 个商品库存低于安全库存', - style: const TextStyle( - fontSize: 13, color: AppTheme.accent), - ), - const Spacer(), - ElevatedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.mail_outline, size: 16), - label: const Text('发送预警通知'), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.shopping_cart_checkout, size: 16), - label: const Text('一键补货申请'), - ), - ], - ), - columns: const [ - DataColumn(label: Text('商品编码')), - DataColumn(label: Text('商品名称')), - DataColumn(label: Text('分类')), - DataColumn(label: Text('仓库')), - DataColumn(label: Text('当前库存'), numeric: true), - DataColumn(label: Text('安全库存'), numeric: true), - DataColumn(label: Text('缺口'), numeric: true), - DataColumn(label: Text('状态')), - DataColumn(label: Text('操作')), - ], - rows: warnings - .map((item) => DataRow( - color: WidgetStateProperty.all( - (item['qty'] as int) == 0 - ? AppTheme.danger.withOpacity(0.05) - : AppTheme.accent.withOpacity(0.04)), - cells: [ - DataCell(Text(item['code'] as String, - style: const TextStyle( - fontFamily: 'monospace', fontSize: 12))), - DataCell(SizedBox( - width: 180, - child: Text(item['name'] as String, - overflow: TextOverflow.ellipsis), - )), - DataCell(Text(item['category'] as String)), - DataCell(Text(item['warehouse'] as String)), - DataCell(Text('${item['qty']}', - style: TextStyle( - fontWeight: FontWeight.w700, - color: (item['qty'] as int) == 0 - ? AppTheme.danger - : AppTheme.accent))), - DataCell(Text('${item['minQty']}')), - DataCell(Text( - '${(item['minQty'] as int) - (item['qty'] as int)}', - style: const TextStyle( - color: AppTheme.danger, - fontWeight: FontWeight.w600))), - DataCell(_InventoryStatusBadge(item['status'] as String)), - DataCell( - ElevatedButton( - onPressed: () {}, - style: ElevatedButton.styleFrom( - minimumSize: const Size(0, 28)), - child: const Text('申请补货', - style: TextStyle(fontSize: 12)), - ), - ), - ], - )) - .toList(), - ); - } - - Widget _buildCheckTab() { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.fact_check_outlined, - size: 64, color: AppTheme.textSecondary), - const SizedBox(height: 16), - const Text('点击下方按钮发起新的库存盘点', - style: TextStyle(fontSize: 15, color: AppTheme.textSecondary)), - const SizedBox(height: 24), - ElevatedButton.icon( - onPressed: () => context.go('/inventory/check'), - icon: const Icon(Icons.add, size: 20), - label: const Text('新建盘点单', style: TextStyle(fontSize: 15)), - style: ElevatedButton.styleFrom( - minimumSize: const Size(160, 44)), - ), - ], - ), + log.createdAt?.substring(0, 19) ?? '-', + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary), + )), + ])) + .toList(), + ); + }, ); } } @@ -624,34 +544,31 @@ class _SummaryCard extends StatelessWidget { } class _InventoryStatusBadge extends StatelessWidget { - final String status; - const _InventoryStatusBadge(this.status); + final Inventory item; + const _InventoryStatusBadge(this.item); @override Widget build(BuildContext context) { + final String status; final Color bg; final Color fg; - switch (status) { - case '正常': - bg = const Color(0xFFE8F5E9); - fg = AppTheme.success; - break; - case '库存不足': - bg = const Color(0xFFFFF3E0); - fg = AppTheme.accent; - break; - case '缺货': - bg = const Color(0xFFFFEBEE); - fg = AppTheme.danger; - break; - default: - bg = const Color(0xFFF5F5F5); - fg = AppTheme.textSecondary; + if (item.quantity == 0) { + status = '缺货'; + bg = const Color(0xFFFFEBEE); + fg = AppTheme.danger; + } else if (item.minStock != null && item.quantity < item.minStock!) { + status = '库存不足'; + bg = const Color(0xFFFFF3E0); + fg = AppTheme.accent; + } else { + status = '正常'; + bg = const Color(0xFFE8F5E9); + fg = AppTheme.success; } return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: bg, borderRadius: BorderRadius.circular(3)), + decoration: + BoxDecoration(color: bg, borderRadius: BorderRadius.circular(3)), child: Text(status, style: TextStyle( color: fg, fontSize: 12, fontWeight: FontWeight.w500)), @@ -659,41 +576,27 @@ class _InventoryStatusBadge extends StatelessWidget { } } -class _DropdownFilter extends StatelessWidget { - final String value; - final List items; - final ValueChanged onChanged; - final String hint; - - const _DropdownFilter({ - required this.value, - required this.items, - required this.onChanged, - required this.hint, - }); +class _DirectionBadge extends StatelessWidget { + final String direction; + const _DirectionBadge(this.direction); @override Widget build(BuildContext context) { + final isIn = direction == 'in'; return Container( - height: 36, - padding: const EdgeInsets.symmetric(horizontal: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( - border: Border.all(color: AppTheme.border), - borderRadius: BorderRadius.circular(4), - color: AppTheme.surface, + color: isIn + ? const Color(0xFFE8F5E9) + : const Color(0xFFFFEBEE), + borderRadius: BorderRadius.circular(3), ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: value, - items: items - .map((s) => DropdownMenuItem( - value: s, - child: Text(s, style: const TextStyle(fontSize: 13)))) - .toList(), - onChanged: onChanged, - style: const TextStyle( - fontSize: 13, color: AppTheme.textPrimary), - ), + child: Text( + isIn ? '入库' : '出库', + style: TextStyle( + color: isIn ? AppTheme.success : AppTheme.danger, + fontSize: 12, + fontWeight: FontWeight.w500), ), ); } diff --git a/client/lib/screens/partners/partners_screen.dart b/client/lib/screens/partners/partners_screen.dart index 499145e..766ad09 100644 --- a/client/lib/screens/partners/partners_screen.dart +++ b/client/lib/screens/partners/partners_screen.dart @@ -1,9 +1,12 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../widgets/page_scaffold.dart'; -import '../../widgets/data_table_card.dart'; +import '../../core/models/page_result.dart'; import '../../core/theme/app_theme.dart'; -import '../../widgets/form_dialog.dart'; +import '../../models/partner.dart'; +import '../../providers/partner_provider.dart'; +import '../../widgets/data_table_card.dart'; +import '../../widgets/page_scaffold.dart'; class PartnersScreen extends ConsumerStatefulWidget { const PartnersScreen({super.key}); @@ -13,147 +16,17 @@ class PartnersScreen extends ConsumerStatefulWidget { } class _PartnersScreenState extends ConsumerState { - int _suppliersPage = 1; - int _customersPage = 1; - final _searchCtrl = TextEditingController(); - - final List> _suppliers = [ - { - 'code': 'GYS001', - 'name': '贵州茅台酒股份有限公司', - 'type': '白酒厂商', - 'contact': '王建国', - 'phone': '0851-22886688', - 'address': '贵州省仁怀市茅台镇', - 'balance': '-86000.00', - 'status': '合作中', - 'creditDays': 30, - }, - { - 'code': 'GYS002', - 'name': '四川五粮液股份有限公司', - 'type': '白酒厂商', - 'contact': '李明华', - 'phone': '0831-12345678', - 'address': '四川省宜宾市翠屏区', - 'balance': '-42500.00', - 'status': '合作中', - 'creditDays': 30, - }, - { - 'code': 'GYS003', - 'name': '江苏洋河酒厂股份有限公司', - 'type': '白酒厂商', - 'contact': '张秀英', - 'phone': '0527-83999999', - 'address': '江苏省宿迁市宿城区', - 'balance': '0.00', - 'status': '合作中', - 'creditDays': 45, - }, - { - 'code': 'GYS004', - 'name': '剑南春(集团)有限责任公司', - 'type': '白酒厂商', - 'contact': '陈志强', - 'phone': '0838-88888888', - 'address': '四川省德阳市绵竹市', - 'balance': '-28600.00', - 'status': '合作中', - 'creditDays': 30, - }, - { - 'code': 'GYS005', - 'name': '泸州老窖股份有限公司', - 'type': '白酒厂商', - 'contact': '赵丽红', - 'phone': '0830-12233456', - 'address': '四川省泸州市江阳区', - 'balance': '-15200.00', - 'status': '合作中', - 'creditDays': 30, - }, - { - 'code': 'GYS006', - 'name': '法国拉菲集团中国总代理', - 'type': '葡萄酒进口商', - 'contact': 'Pierre Liu', - 'phone': '021-61234567', - 'address': '上海市黄浦区外滩18号', - 'balance': '-124800.00', - 'status': '合作中', - 'creditDays': 60, - }, - { - 'code': 'GYS007', - 'name': '人头马轩尼诗(中国)有限公司', - 'type': '洋酒进口商', - 'contact': '刘经理', - 'phone': '021-54321678', - 'address': '上海市浦东新区', - 'balance': '-30240.00', - 'status': '合作中', - 'creditDays': 60, - }, - { - 'code': 'GYS008', - 'name': '山西汾酒集团有限责任公司', - 'type': '白酒厂商', - 'contact': '周建明', - 'phone': '0357-33666888', - 'address': '山西省吕梁市汾阳市', - 'balance': '0.00', - 'status': '暂停合作', - 'creditDays': 30, - }, - ]; - - final List> _customers = [ - { - 'code': 'KH001', - 'name': '北京国贸大酒店', - 'type': '五星级酒店', - 'contact': '采购部', - 'phone': '010-65051234', - 'address': '北京市朝阳区建国门外大街1号', - 'balance': '45000.00', - 'status': '合作中', - }, - { - 'code': 'KH002', - 'name': '上海外滩华尔道夫酒店', - 'type': '五星级酒店', - 'contact': '采购经理', - 'phone': '021-63228888', - 'address': '上海市黄浦区中山东一路2号', - 'balance': '0.00', - 'status': '合作中', - }, - { - 'code': 'KH003', - 'name': '广州白云国际会议中心', - 'type': '会议中心', - 'contact': '餐饮总监', - 'phone': '020-86001234', - 'address': '广州市白云区云城东路1号', - 'balance': '28600.00', - 'status': '合作中', - }, - { - 'code': 'KH004', - 'name': '深圳湾万象城购物中心', - 'type': '商超零售', - 'contact': '王采购', - 'phone': '0755-82345678', - 'address': '深圳市南山区望海路购物公园', - 'balance': '12000.00', - 'status': '合作中', - }, - ]; + final _supplierSearchCtrl = TextEditingController(); + final _customerSearchCtrl = TextEditingController(); + Timer? _supplierDebounce; + Timer? _customerDebounce; @override void dispose() { - _searchCtrl.dispose(); + _supplierSearchCtrl.dispose(); + _customerSearchCtrl.dispose(); + _supplierDebounce?.cancel(); + _customerDebounce?.cancel(); super.dispose(); } @@ -166,352 +39,436 @@ class _PartnersScreenState extends ConsumerState { Tab(text: '客户'), ], tabViews: [ - _buildSupplierList(), - _buildCustomerList(), + _buildSupplierTab(), + _buildCustomerTab(), ], ); } - Widget _buildSupplierList() { - final q = _searchCtrl.text.toLowerCase(); - final filtered = _suppliers.where((s) { - if (q.isEmpty) return true; - return (s['name'] as String).toLowerCase().contains(q) || - (s['code'] as String).toLowerCase().contains(q); - }).toList(); + Widget _buildSupplierTab() { + final asyncPartners = ref.watch(supplierListProvider); + return asyncPartners.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('加载失败:$e', + style: const TextStyle(color: AppTheme.danger)), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () => + ref.read(supplierListProvider.notifier).reload(), + child: const Text('重试'), + ), + ], + ), + ), + data: (result) => _buildPartnerList( + result, + isSupplier: true, + searchCtrl: _supplierSearchCtrl, + onSearchChanged: (v) { + _supplierDebounce?.cancel(); + _supplierDebounce = Timer(const Duration(milliseconds: 300), () { + ref.read(supplierListProvider.notifier).setKeyword(v); + }); + }, + onPageChanged: (p) => + ref.read(supplierListProvider.notifier).setPage(p), + onAdd: () => _showPartnerDialog(context, isSupplier: true), + onEdit: (p) => + _showPartnerDialog(context, isSupplier: true, partner: p), + onDelete: (p) => _confirmDelete(context, p, isSupplier: true), + ), + ); + } + Widget _buildCustomerTab() { + final asyncPartners = ref.watch(customerListProvider); + return asyncPartners.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('加载失败:$e', + style: const TextStyle(color: AppTheme.danger)), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () => + ref.read(customerListProvider.notifier).reload(), + child: const Text('重试'), + ), + ], + ), + ), + data: (result) => _buildPartnerList( + result, + isSupplier: false, + searchCtrl: _customerSearchCtrl, + onSearchChanged: (v) { + _customerDebounce?.cancel(); + _customerDebounce = Timer(const Duration(milliseconds: 300), () { + ref.read(customerListProvider.notifier).setKeyword(v); + }); + }, + onPageChanged: (p) => + ref.read(customerListProvider.notifier).setPage(p), + onAdd: () => _showPartnerDialog(context, isSupplier: false), + onEdit: (p) => + _showPartnerDialog(context, isSupplier: false, partner: p), + onDelete: (p) => _confirmDelete(context, p, isSupplier: false), + ), + ); + } + + Widget _buildPartnerList( + PageResult result, { + required bool isSupplier, + required TextEditingController searchCtrl, + required void Function(String) onSearchChanged, + required void Function(int) onPageChanged, + required VoidCallback onAdd, + required void Function(Partner) onEdit, + required void Function(Partner) onDelete, + }) { + final partners = result.data; return DataTableCard( - totalCount: filtered.length, - page: _suppliersPage, - onPageChanged: (p) => setState(() => _suppliersPage = p), + totalCount: result.total, + page: result.page, + onPageChanged: onPageChanged, toolbar: Row( children: [ ElevatedButton.icon( - onPressed: () => _showAddPartnerDialog(context, isSupplier: true), + onPressed: onAdd, icon: const Icon(Icons.add, size: 16), - label: const Text('新增供应商'), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.file_download_outlined, size: 16), - label: const Text('导出'), + label: Text(isSupplier ? '新建' : '新建'), ), const Spacer(), SizedBox( width: 200, child: TextField( - controller: _searchCtrl, - decoration: const InputDecoration( - hintText: '搜索供应商名称/编码', - prefixIcon: Icon(Icons.search, size: 16), - hintStyle: TextStyle(fontSize: 13), + controller: searchCtrl, + decoration: InputDecoration( + hintText: isSupplier ? '搜索供应商名称/编码' : '搜索客户名称/编码', + prefixIcon: const Icon(Icons.search, size: 16), + hintStyle: const TextStyle(fontSize: 13), ), - onChanged: (_) => setState(() {}), + onChanged: onSearchChanged, ), ), ], ), - columns: const [ - DataColumn(label: Text('供应商编码')), - DataColumn(label: Text('供应商名称')), - DataColumn(label: Text('类型')), - DataColumn(label: Text('联系人')), - DataColumn(label: Text('联系电话')), - DataColumn(label: Text('账期(天)'), numeric: true), - DataColumn(label: Text('应付余额'), numeric: true), - DataColumn(label: Text('状态')), - DataColumn(label: Text('操作')), + columns: [ + const DataColumn(label: Text('编码')), + const DataColumn(label: Text('名称')), + const DataColumn(label: Text('联系人')), + const DataColumn(label: Text('联系电话')), + const DataColumn(label: Text('地址')), + const DataColumn(label: Text('操作')), ], - rows: filtered - .map((s) => DataRow(cells: [ - DataCell(Text(s['code'] as String, - style: const TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: AppTheme.textSecondary))), - DataCell(SizedBox( - width: 180, - child: Text(s['name'] as String, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.w500)), - )), - DataCell(Text(s['type'] as String)), - DataCell(Text(s['contact'] as String)), - DataCell(Text(s['phone'] as String)), - DataCell(Text('${s['creditDays']}')), - DataCell(Text( - '¥${s['balance']}', - style: TextStyle( - color: (s['balance'] as String).startsWith('-') - ? AppTheme.danger - : AppTheme.textPrimary, - fontWeight: FontWeight.w500, - ), - )), - DataCell(_StatusChip(s['status'] as String)), - DataCell(Row( - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - onPressed: () {}, - child: const Text('查看', - style: TextStyle(fontSize: 12))), - TextButton( - onPressed: () {}, - child: const Text('编辑', - style: TextStyle(fontSize: 12))), - TextButton( - onPressed: () {}, - child: const Text('对账', - style: TextStyle(fontSize: 12))), - ], - )), - ])) - .toList(), + rows: partners.isEmpty + ? [ + DataRow(cells: [ + const DataCell(SizedBox()), + DataCell(Text(isSupplier ? '暂无供应商' : '暂无客户', + style: + const TextStyle(color: AppTheme.textSecondary))), + const DataCell(SizedBox()), + const DataCell(SizedBox()), + const DataCell(SizedBox()), + const DataCell(SizedBox()), + ]) + ] + : partners + .map((p) => DataRow( + cells: [ + DataCell(Text(p.code ?? '-', + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: AppTheme.textSecondary))), + DataCell(SizedBox( + width: 180, + child: Text(p.name, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w500)), + )), + DataCell(Text(p.contact ?? '-')), + DataCell(Text(p.phone ?? '-')), + DataCell(SizedBox( + width: 160, + child: Text(p.address ?? '-', + overflow: TextOverflow.ellipsis), + )), + DataCell(Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + key: Key('btn_edit_${p.id}'), + onPressed: () => onEdit(p), + child: const Text('编辑', + style: TextStyle(fontSize: 12)), + ), + TextButton( + key: Key('btn_delete_${p.id}'), + onPressed: () => onDelete(p), + child: const Text('删除', + style: TextStyle( + fontSize: 12, + color: AppTheme.danger)), + ), + ], + )), + ], + )) + .toList(), ); } - Widget _buildCustomerList() { - return DataTableCard( - totalCount: _customers.length, - page: _customersPage, - onPageChanged: (p) => setState(() => _customersPage = p), - toolbar: Row( - children: [ - ElevatedButton.icon( - onPressed: () => - _showAddPartnerDialog(context, isSupplier: false), - icon: const Icon(Icons.add, size: 16), - label: const Text('新增客户'), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.file_download_outlined, size: 16), - label: const Text('导出'), - ), - const Spacer(), - SizedBox( - width: 200, - child: TextField( - decoration: const InputDecoration( - hintText: '搜索客户名称/编码', - prefixIcon: Icon(Icons.search, size: 16), - hintStyle: TextStyle(fontSize: 13), - ), - ), - ), - ], - ), - columns: const [ - DataColumn(label: Text('客户编码')), - DataColumn(label: Text('客户名称')), - DataColumn(label: Text('类型')), - DataColumn(label: Text('联系人')), - DataColumn(label: Text('联系电话')), - DataColumn(label: Text('应收余额'), numeric: true), - DataColumn(label: Text('状态')), - DataColumn(label: Text('操作')), - ], - rows: _customers - .map((c) => DataRow(cells: [ - DataCell(Text(c['code'] as String, - style: const TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: AppTheme.textSecondary))), - DataCell(SizedBox( - width: 180, - child: Text(c['name'] as String, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.w500)), - )), - DataCell(Text(c['type'] as String)), - DataCell(Text(c['contact'] as String)), - DataCell(Text(c['phone'] as String)), - DataCell(Text( - '¥${c['balance']}', - style: const TextStyle(fontWeight: FontWeight.w500), - )), - DataCell(_StatusChip(c['status'] as String)), - DataCell(Row( - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - onPressed: () {}, - child: const Text('查看', - style: TextStyle(fontSize: 12))), - TextButton( - onPressed: () {}, - child: const Text('编辑', - style: TextStyle(fontSize: 12))), - ], - )), - ])) - .toList(), - ); - } - - void _showAddPartnerDialog(BuildContext context, {required bool isSupplier}) { - final nameCtrl = TextEditingController(); - final contactCtrl = TextEditingController(); - final phoneCtrl = TextEditingController(); - final addressCtrl = TextEditingController(); - + void _showPartnerDialog(BuildContext context, + {required bool isSupplier, Partner? partner}) { showDialog( context: context, - builder: (ctx) => FormDialog( - title: isSupplier ? '新增供应商' : '新增客户', - width: 520, - onConfirm: () { - Navigator.of(ctx).pop(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(isSupplier ? '供应商添加成功' : '客户添加成功'), - backgroundColor: AppTheme.success, - ), - ); + builder: (ctx) => _PartnerFormDialog( + isSupplier: isSupplier, + partner: partner, + onSaved: () { + if (isSupplier) { + ref.read(supplierListProvider.notifier).reload(); + } else { + ref.read(customerListProvider.notifier).reload(); + } }, - content: Column( - children: [ - _DialogField( - label: isSupplier ? '供应商名称' : '客户名称', - required: true, - child: TextFormField( - controller: nameCtrl, - decoration: InputDecoration( - hintText: isSupplier ? '请输入供应商全称' : '请输入客户名称', - ), + ), + ); + } + + Future _confirmDelete(BuildContext context, Partner partner, + {required bool isSupplier}) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('确认删除'), + content: Text('确认删除「${partner.name}」?此操作不可恢复。'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('取消'), + ), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.danger, + foregroundColor: Colors.white), + child: const Text('删除'), + ), + ], + ), + ); + if (confirmed == true && mounted) { + try { + if (isSupplier) { + await ref + .read(supplierListProvider.notifier) + .deletePartner(partner.id); + } else { + await ref + .read(customerListProvider.notifier) + .deletePartner(partner.id); + } + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('删除成功'), + backgroundColor: AppTheme.success)); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('删除失败:$e'), + backgroundColor: AppTheme.danger)); + } + } + } + } +} + +class _PartnerFormDialog extends ConsumerStatefulWidget { + final bool isSupplier; + final Partner? partner; + final VoidCallback onSaved; + + const _PartnerFormDialog({ + required this.isSupplier, + this.partner, + required this.onSaved, + }); + + @override + ConsumerState<_PartnerFormDialog> createState() => + _PartnerFormDialogState(); +} + +class _PartnerFormDialogState extends ConsumerState<_PartnerFormDialog> { + final _formKey = GlobalKey(); + late final TextEditingController _nameCtrl; + late final TextEditingController _codeCtrl; + late final TextEditingController _contactCtrl; + late final TextEditingController _phoneCtrl; + late final TextEditingController _addressCtrl; + late final TextEditingController _remarkCtrl; + bool _saving = false; + + @override + void initState() { + super.initState(); + final p = widget.partner; + _nameCtrl = TextEditingController(text: p?.name ?? ''); + _codeCtrl = TextEditingController(text: p?.code ?? ''); + _contactCtrl = TextEditingController(text: p?.contact ?? ''); + _phoneCtrl = TextEditingController(text: p?.phone ?? ''); + _addressCtrl = TextEditingController(text: p?.address ?? ''); + _remarkCtrl = TextEditingController(text: p?.remark ?? ''); + } + + @override + void dispose() { + _nameCtrl.dispose(); + _codeCtrl.dispose(); + _contactCtrl.dispose(); + _phoneCtrl.dispose(); + _addressCtrl.dispose(); + _remarkCtrl.dispose(); + super.dispose(); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _saving = true); + final data = { + 'name': _nameCtrl.text.trim(), + 'type': widget.isSupplier ? 'supplier' : 'customer', + if (_codeCtrl.text.trim().isNotEmpty) 'code': _codeCtrl.text.trim(), + if (_contactCtrl.text.trim().isNotEmpty) + 'contact': _contactCtrl.text.trim(), + if (_phoneCtrl.text.trim().isNotEmpty) 'phone': _phoneCtrl.text.trim(), + if (_addressCtrl.text.trim().isNotEmpty) + 'address': _addressCtrl.text.trim(), + if (_remarkCtrl.text.trim().isNotEmpty) 'remark': _remarkCtrl.text.trim(), + }; + try { + final notifier = widget.isSupplier + ? ref.read(supplierListProvider.notifier) + : ref.read(customerListProvider.notifier); + if (widget.partner != null) { + await notifier.updatePartner(widget.partner!.id, data); + } else { + await notifier.createPartner(data); + } + if (mounted) { + Navigator.of(context).pop(); + widget.onSaved(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(widget.partner != null ? '更新成功' : '创建成功'), + backgroundColor: AppTheme.success, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('保存失败:$e'), + backgroundColor: AppTheme.danger), + ); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final isEdit = widget.partner != null; + final typeName = widget.isSupplier ? '供应商' : '客户'; + return Dialog( + child: Container( + width: 520, + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(isEdit ? '编辑$typeName' : '新建$typeName', + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.w600)), + const SizedBox(height: 20), + TextFormField( + controller: _nameCtrl, + decoration: InputDecoration(labelText: '$typeName名称'), + validator: (v) => + (v == null || v.isEmpty) ? '不能为空' : null, ), - ), - const SizedBox(height: 12), - Row( - children: [ + const SizedBox(height: 12), + Row(children: [ Expanded( - child: _DialogField( - label: '联系人', - child: TextFormField( - controller: contactCtrl, - decoration: const InputDecoration(hintText: '联系人姓名'), - ), + child: TextFormField( + controller: _codeCtrl, + decoration: const InputDecoration(labelText: '编码'), ), ), const SizedBox(width: 12), Expanded( - child: _DialogField( - label: '联系电话', - child: TextFormField( - controller: phoneCtrl, - decoration: const InputDecoration(hintText: '手机/座机'), - ), + child: TextFormField( + controller: _contactCtrl, + decoration: const InputDecoration(labelText: '联系人'), ), ), - ], - ), - const SizedBox(height: 12), - _DialogField( - label: '地址', - child: TextFormField( - controller: addressCtrl, - decoration: const InputDecoration(hintText: '详细地址'), - ), - ), - if (isSupplier) ...[ + ]), const SizedBox(height: 12), + TextFormField( + controller: _phoneCtrl, + decoration: const InputDecoration(labelText: '联系电话'), + ), + const SizedBox(height: 12), + TextFormField( + controller: _addressCtrl, + decoration: const InputDecoration(labelText: '地址'), + ), + const SizedBox(height: 12), + TextFormField( + controller: _remarkCtrl, + decoration: const InputDecoration(labelText: '备注'), + maxLines: 2, + ), + const SizedBox(height: 24), Row( + mainAxisAlignment: MainAxisAlignment.end, children: [ - Expanded( - child: _DialogField( - label: '账期(天)', - child: TextFormField( - initialValue: '30', - keyboardType: TextInputType.number, - decoration: const InputDecoration(hintText: '结款账期'), - ), - ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消'), ), - const SizedBox(width: 12), - Expanded( - child: _DialogField( - label: '供应商类型', - child: DropdownButtonFormField( - value: '白酒厂商', - items: ['白酒厂商', '葡萄酒进口商', '洋酒进口商', '啤酒厂商', '其他'] - .map((s) => DropdownMenuItem( - value: s, - child: Text(s, - style: const TextStyle(fontSize: 13)))) - .toList(), - onChanged: (_) {}, - decoration: const InputDecoration(), - ), - ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Text('保存'), ), ], ), ], - ], - ), - ), - ); - } -} - -class _DialogField extends StatelessWidget { - final String label; - final Widget child; - final bool required; - - const _DialogField({ - required this.label, - required this.child, - this.required = false, - }); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (required) - const Text('* ', - style: TextStyle(color: AppTheme.danger, fontSize: 13)), - Text(label, - style: const TextStyle( - fontSize: 13, color: AppTheme.textSecondary)), - ], - ), - const SizedBox(height: 6), - child, - ], - ); - } -} - -class _StatusChip extends StatelessWidget { - final String status; - const _StatusChip(this.status); - - @override - Widget build(BuildContext context) { - final bool isActive = status == '合作中'; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: isActive - ? const Color(0xFFE8F5E9) - : const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(3), - ), - child: Text( - status, - style: TextStyle( - color: isActive ? AppTheme.success : AppTheme.textSecondary, - fontSize: 12, - fontWeight: FontWeight.w500, + ), ), ), ); diff --git a/client/lib/screens/products/products_screen.dart b/client/lib/screens/products/products_screen.dart index 66687d8..5d1fc19 100644 --- a/client/lib/screens/products/products_screen.dart +++ b/client/lib/screens/products/products_screen.dart @@ -1,9 +1,11 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../widgets/page_scaffold.dart'; -import '../../widgets/data_table_card.dart'; import '../../core/theme/app_theme.dart'; -import '../../widgets/form_dialog.dart'; +import '../../models/product.dart'; +import '../../providers/product_provider.dart'; +import '../../widgets/data_table_card.dart'; +import '../../widgets/page_scaffold.dart'; class ProductsScreen extends ConsumerStatefulWidget { const ProductsScreen({super.key}); @@ -13,247 +15,77 @@ class ProductsScreen extends ConsumerStatefulWidget { } class _ProductsScreenState extends ConsumerState { - int _productsPage = 1; - int _categoriesPage = 1; - int _warehousesPage = 1; final _searchCtrl = TextEditingController(); - String _categoryFilter = '全部'; - - final List> _products = [ - { - 'code': 'SP001', - 'name': '茅台酒(飞天)53度500ml', - 'category': '白酒', - 'brand': '茅台', - 'spec': '500ml/瓶', - 'unit': '瓶', - 'barcode': '6901735001534', - 'costPrice': '2100.00', - 'salePrice': '2600.00', - 'minStock': 50, - 'maxStock': 500, - 'status': '启用', - }, - { - 'code': 'SP002', - 'name': '五粮液(普五)52度500ml', - 'category': '白酒', - 'brand': '五粮液', - 'spec': '500ml/瓶', - 'unit': '瓶', - 'barcode': '6901234567890', - 'costPrice': '850.00', - 'salePrice': '1050.00', - 'minStock': 30, - 'maxStock': 300, - 'status': '启用', - }, - { - 'code': 'SP003', - 'name': '洋河梦之蓝M6+ 45度500ml', - 'category': '白酒', - 'brand': '洋河', - 'spec': '500ml/瓶', - 'unit': '瓶', - 'barcode': '6902701234567', - 'costPrice': '480.00', - 'salePrice': '598.00', - 'minStock': 20, - 'maxStock': 200, - 'status': '启用', - }, - { - 'code': 'SP004', - 'name': '剑南春(水晶剑)52度500ml', - 'category': '白酒', - 'brand': '剑南春', - 'spec': '500ml/瓶', - 'unit': '瓶', - 'barcode': '6921234567890', - 'costPrice': '288.00', - 'salePrice': '368.00', - 'minStock': 20, - 'maxStock': 200, - 'status': '启用', - }, - { - 'code': 'SP005', - 'name': '泸州老窖(国窖1573)52度500ml', - 'category': '白酒', - 'brand': '泸州老窖', - 'spec': '500ml/瓶', - 'unit': '瓶', - 'barcode': '6910123456789', - 'costPrice': '680.00', - 'salePrice': '860.00', - 'minStock': 20, - 'maxStock': 200, - 'status': '启用', - }, - { - 'code': 'SP006', - 'name': '汾酒(青花30)53度500ml', - 'category': '白酒', - 'brand': '汾酒', - 'spec': '500ml/瓶', - 'unit': '瓶', - 'barcode': '6936789012345', - 'costPrice': '320.00', - 'salePrice': '418.00', - 'minStock': 15, - 'maxStock': 150, - 'status': '启用', - }, - { - 'code': 'SP007', - 'name': '拉菲古堡正牌红葡萄酒2018', - 'category': '葡萄酒', - 'brand': '拉菲', - 'spec': '750ml/瓶', - 'unit': '瓶', - 'barcode': '3760040234567', - 'costPrice': '5200.00', - 'salePrice': '6800.00', - 'minStock': 6, - 'maxStock': 60, - 'status': '启用', - }, - { - 'code': 'SP008', - 'name': '人头马XO特优香槟干邑700ml', - 'category': '洋酒', - 'brand': '人头马', - 'spec': '700ml/瓶', - 'unit': '瓶', - 'barcode': '5010677012345', - 'costPrice': '1680.00', - 'salePrice': '2180.00', - 'minStock': 6, - 'maxStock': 60, - 'status': '启用', - }, - { - 'code': 'SP009', - 'name': '百威啤酒330ml×24罐', - 'category': '啤酒', - 'brand': '百威', - 'spec': '330ml×24罐/箱', - 'unit': '箱', - 'barcode': '6901234000123', - 'costPrice': '58.00', - 'salePrice': '88.00', - 'minStock': 20, - 'maxStock': 200, - 'status': '启用', - }, - { - 'code': 'SP010', - 'name': '青岛啤酒(经典)500ml×12瓶', - 'category': '啤酒', - 'brand': '青岛', - 'spec': '500ml×12瓶/箱', - 'unit': '箱', - 'barcode': '6901234000456', - 'costPrice': '42.00', - 'salePrice': '68.00', - 'minStock': 20, - 'maxStock': 200, - 'status': '启用', - }, - { - 'code': 'SP011', - 'name': '芝华士12年苏格兰威士忌700ml', - 'category': '洋酒', - 'brand': '芝华士', - 'spec': '700ml/瓶', - 'unit': '瓶', - 'barcode': '5000299606230', - 'costPrice': '288.00', - 'salePrice': '398.00', - 'minStock': 10, - 'maxStock': 100, - 'status': '启用', - }, - { - 'code': 'SP012', - 'name': '郎酒红花郎15年53度500ml', - 'category': '白酒', - 'brand': '郎酒', - 'spec': '500ml/瓶', - 'unit': '瓶', - 'barcode': '6914987012345', - 'costPrice': '620.00', - 'salePrice': '798.00', - 'minStock': 10, - 'maxStock': 100, - 'status': '禁用', - }, - ]; - - final List> _categories = [ - {'code': 'BJ', 'name': '白酒', 'parent': '酒类', 'count': 7, 'status': '启用'}, - {'code': 'PTJ', 'name': '葡萄酒', 'parent': '酒类', 'count': 1, 'status': '启用'}, - {'code': 'YJ', 'name': '洋酒', 'parent': '酒类', 'count': 2, 'status': '启用'}, - {'code': 'PJ', 'name': '啤酒', 'parent': '酒类', 'count': 2, 'status': '启用'}, - {'code': 'HJ', 'name': '黄酒', 'parent': '酒类', 'count': 0, 'status': '启用'}, - {'code': 'MLJ', 'name': '米露酒', 'parent': '酒类', 'count': 0, 'status': '启用'}, - ]; - - final List> _warehouses = [ - { - 'code': 'CK001', - 'name': '主仓库', - 'type': '常规仓', - 'location': 'B1层西区', - 'capacity': 1000, - 'used': 720, - 'manager': '仓库主任', - 'status': '启用', - }, - { - 'code': 'CK002', - 'name': '副仓库', - 'type': '常规仓', - 'location': 'B1层东区', - 'capacity': 500, - 'used': 150, - 'manager': '仓库副主任', - 'status': '启用', - }, - { - 'code': 'CK003', - 'name': '保税仓库', - 'type': '保税仓', - 'location': 'B2层', - 'capacity': 200, - 'used': 0, - 'manager': '待指派', - 'status': '禁用', - }, - ]; + Timer? _debounce; @override void dispose() { _searchCtrl.dispose(); + _debounce?.cancel(); super.dispose(); } - List> get _filteredProducts { - return _products.where((p) { - if (_categoryFilter != '全部' && p['category'] != _categoryFilter) { - return false; - } - final q = _searchCtrl.text.toLowerCase(); - if (q.isNotEmpty) { - final name = (p['name'] as String).toLowerCase(); - final code = (p['code'] as String).toLowerCase(); - final brand = (p['brand'] as String).toLowerCase(); - if (!name.contains(q) && !code.contains(q) && !brand.contains(q)) { - return false; + void _onSearchChanged(String value) { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 300), () { + ref.read(productListProvider.notifier).setKeyword(value); + }); + } + + void _showProductDialog(BuildContext context, {Product? product}) { + showDialog( + context: context, + builder: (ctx) => _ProductFormDialog( + product: product, + onSaved: () { + ref.read(productListProvider.notifier).reload(); + }, + ), + ); + } + + Future _confirmDelete(BuildContext context, Product product) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('确认删除'), + content: Text('确认删除商品「${product.name}」?此操作不可恢复。'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('取消'), + ), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.danger, + foregroundColor: Colors.white), + child: const Text('删除'), + ), + ], + ), + ); + if (confirmed == true && mounted) { + try { + await ref + .read(productListProvider.notifier) + .deleteProduct(product.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('删除成功'), backgroundColor: AppTheme.success), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('删除失败:$e'), + backgroundColor: AppTheme.danger), + ); } } - return true; - }).toList(); + } } @override @@ -262,52 +94,58 @@ class _ProductsScreenState extends ConsumerState { title: '基础数据', tabs: const [ Tab(text: '商品档案'), - Tab(text: '商品分类'), - Tab(text: '仓库管理'), ], tabViews: [ - _buildProductList(), - _buildCategoryList(), - _buildWarehouseList(), + _buildProductTab(), ], ); } - Widget _buildProductList() { - final products = _filteredProducts; + Widget _buildProductTab() { + final asyncProducts = ref.watch(productListProvider); + return asyncProducts.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('加载失败:$e', + style: const TextStyle(color: AppTheme.danger)), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () => + ref.read(productListProvider.notifier).reload(), + child: const Text('重试'), + ), + ], + ), + ), + data: (result) { + if (result.data.isEmpty) { + return _buildProductList([], result.total, result.page); + } + return _buildProductList(result.data, result.total, result.page); + }, + ); + } + + Widget _buildProductList( + List products, int totalCount, int page) { return DataTableCard( - totalCount: products.length, - page: _productsPage, - onPageChanged: (p) => setState(() => _productsPage = p), + totalCount: totalCount, + page: page, + onPageChanged: (p) => + ref.read(productListProvider.notifier).setPage(p), toolbar: Row( children: [ ElevatedButton.icon( onPressed: () => _showProductDialog(context), icon: const Icon(Icons.add, size: 16), - label: const Text('新增商品'), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.file_upload_outlined, size: 16), - label: const Text('导入'), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.file_download_outlined, size: 16), - label: const Text('导出'), + label: const Text('新建'), ), const Spacer(), - _DropdownFilter( - value: _categoryFilter, - items: ['全部', '白酒', '葡萄酒', '洋酒', '啤酒', '黄酒'], - onChanged: (v) => setState(() => _categoryFilter = v!), - hint: '分类', - ), - const SizedBox(width: 8), SizedBox( - width: 200, + width: 220, child: TextField( controller: _searchCtrl, decoration: const InputDecoration( @@ -315,7 +153,7 @@ class _ProductsScreenState extends ConsumerState { prefixIcon: Icon(Icons.search, size: 16), hintStyle: TextStyle(fontSize: 13), ), - onChanged: (_) => setState(() {}), + onChanged: _onSearchChanged, ), ), ], @@ -323,434 +161,325 @@ class _ProductsScreenState extends ConsumerState { columns: const [ DataColumn(label: Text('商品编码')), DataColumn(label: Text('商品名称')), - DataColumn(label: Text('分类')), DataColumn(label: Text('品牌')), DataColumn(label: Text('规格')), DataColumn(label: Text('单位')), - DataColumn(label: Text('成本价'), numeric: true), - DataColumn(label: Text('销售价'), numeric: true), + DataColumn(label: Text('进价'), numeric: true), + DataColumn(label: Text('售价'), numeric: true), DataColumn(label: Text('安全库存'), numeric: true), - DataColumn(label: Text('状态')), DataColumn(label: Text('操作')), ], - rows: products - .map((p) => DataRow( - color: WidgetStateProperty.resolveWith((_) => - p['status'] == '禁用' - ? AppTheme.textSecondary.withOpacity(0.04) - : null), - cells: [ - DataCell(Text(p['code'] as String, - style: const TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: AppTheme.textSecondary))), - DataCell(SizedBox( - width: 180, - child: Text(p['name'] as String, - overflow: TextOverflow.ellipsis), - )), - DataCell(Text(p['category'] as String)), - DataCell(Text(p['brand'] as String)), - DataCell(Text(p['spec'] as String)), - DataCell(Text(p['unit'] as String)), - DataCell(Text('¥${p['costPrice']}')), - DataCell(Text('¥${p['salePrice']}', - style: const TextStyle(color: AppTheme.primary))), - DataCell(Text('${p['minStock']}')), - DataCell(_StatusBadge(p['status'] as String)), - DataCell(Row( - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - onPressed: () {}, - child: const Text('编辑', - style: TextStyle(fontSize: 12))), - TextButton( - onPressed: () => setState(() { - p['status'] = p['status'] == '启用' ? '禁用' : '启用'; - }), - child: Text( - p['status'] == '启用' ? '禁用' : '启用', - style: TextStyle( - fontSize: 12, - color: p['status'] == '启用' - ? AppTheme.danger - : AppTheme.success), - )), + rows: products.isEmpty + ? [ + const DataRow(cells: [ + DataCell(SizedBox()), + DataCell(Text('暂无商品', + style: TextStyle(color: AppTheme.textSecondary))), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + ]) + ] + : products + .map((p) => DataRow( + cells: [ + DataCell(Text(p.code, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: AppTheme.textSecondary))), + DataCell(SizedBox( + width: 180, + child: Text(p.name, + overflow: TextOverflow.ellipsis), + )), + DataCell(Text(p.brand ?? '-')), + DataCell(Text(p.spec ?? '-')), + DataCell(Text(p.unit)), + DataCell(Text(p.purchasePrice != null + ? '¥${p.purchasePrice!.toStringAsFixed(2)}' + : '-')), + DataCell(Text( + p.salePrice != null + ? '¥${p.salePrice!.toStringAsFixed(2)}' + : '-', + style: + const TextStyle(color: AppTheme.primary), + )), + DataCell(Text( + p.minStock != null ? '${p.minStock}' : '-')), + DataCell(Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + key: Key('btn_edit_${p.id}'), + onPressed: () => + _showProductDialog(context, product: p), + child: const Text('编辑', + style: TextStyle(fontSize: 12)), + ), + TextButton( + key: Key('btn_delete_${p.id}'), + onPressed: () => + _confirmDelete(context, p), + child: const Text('删除', + style: TextStyle( + fontSize: 12, + color: AppTheme.danger)), + ), + ], + )), ], - )), - ], - )) - .toList(), + )) + .toList(), ); } +} - Widget _buildCategoryList() { - return DataTableCard( - totalCount: _categories.length, - page: _categoriesPage, - onPageChanged: (p) => setState(() => _categoriesPage = p), - toolbar: Row( - children: [ - ElevatedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.add, size: 16), - label: const Text('新增分类'), - ), - ], - ), - columns: const [ - DataColumn(label: Text('分类编码')), - DataColumn(label: Text('分类名称')), - DataColumn(label: Text('上级分类')), - DataColumn(label: Text('商品数量'), numeric: true), - DataColumn(label: Text('状态')), - DataColumn(label: Text('操作')), - ], - rows: _categories - .map((c) => DataRow(cells: [ - DataCell(Text(c['code'] as String, - style: const TextStyle(fontFamily: 'monospace', fontSize: 12))), - DataCell(Text(c['name'] as String, - style: const TextStyle(fontWeight: FontWeight.w500))), - DataCell(Text(c['parent'] as String)), - DataCell(Text('${c['count']}')), - DataCell(_StatusBadge(c['status'] as String)), - DataCell(Row( - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - onPressed: () {}, - child: const Text('编辑', - style: TextStyle(fontSize: 12))), - if ((c['count'] as int) == 0) - TextButton( - onPressed: () {}, - child: const Text('删除', - style: TextStyle( - fontSize: 12, color: AppTheme.danger))), - ], - )), - ])) - .toList(), - ); +class _ProductFormDialog extends ConsumerStatefulWidget { + final Product? product; + final VoidCallback onSaved; + + const _ProductFormDialog({this.product, required this.onSaved}); + + @override + ConsumerState<_ProductFormDialog> createState() => + _ProductFormDialogState(); +} + +class _ProductFormDialogState extends ConsumerState<_ProductFormDialog> { + final _formKey = GlobalKey(); + late final TextEditingController _nameCtrl; + late final TextEditingController _codeCtrl; + late final TextEditingController _barcodeCtrl; + late final TextEditingController _brandCtrl; + late final TextEditingController _specCtrl; + late final TextEditingController _purchasePriceCtrl; + late final TextEditingController _salePriceCtrl; + late final TextEditingController _minStockCtrl; + late final TextEditingController _remarkCtrl; + String _unit = '瓶'; + bool _saving = false; + + @override + void initState() { + super.initState(); + final p = widget.product; + _nameCtrl = TextEditingController(text: p?.name ?? ''); + _codeCtrl = TextEditingController(text: p?.code ?? ''); + _barcodeCtrl = TextEditingController(text: p?.barcode ?? ''); + _brandCtrl = TextEditingController(text: p?.brand ?? ''); + _specCtrl = TextEditingController(text: p?.spec ?? ''); + _purchasePriceCtrl = TextEditingController( + text: p?.purchasePrice?.toStringAsFixed(2) ?? ''); + _salePriceCtrl = + TextEditingController(text: p?.salePrice?.toStringAsFixed(2) ?? ''); + _minStockCtrl = + TextEditingController(text: p?.minStock?.toString() ?? ''); + _remarkCtrl = TextEditingController(text: p?.remark ?? ''); + _unit = p?.unit ?? '瓶'; } - Widget _buildWarehouseList() { - return DataTableCard( - totalCount: _warehouses.length, - page: _warehousesPage, - onPageChanged: (p) => setState(() => _warehousesPage = p), - toolbar: Row( - children: [ - ElevatedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.add, size: 16), - label: const Text('新增仓库'), + @override + void dispose() { + _nameCtrl.dispose(); + _codeCtrl.dispose(); + _barcodeCtrl.dispose(); + _brandCtrl.dispose(); + _specCtrl.dispose(); + _purchasePriceCtrl.dispose(); + _salePriceCtrl.dispose(); + _minStockCtrl.dispose(); + _remarkCtrl.dispose(); + super.dispose(); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _saving = true); + final data = { + 'name': _nameCtrl.text.trim(), + 'code': _codeCtrl.text.trim(), + if (_barcodeCtrl.text.trim().isNotEmpty) + 'barcode': _barcodeCtrl.text.trim(), + if (_brandCtrl.text.trim().isNotEmpty) 'brand': _brandCtrl.text.trim(), + if (_specCtrl.text.trim().isNotEmpty) 'spec': _specCtrl.text.trim(), + 'unit': _unit, + if (_purchasePriceCtrl.text.trim().isNotEmpty) + 'purchase_price': double.tryParse(_purchasePriceCtrl.text.trim()), + if (_salePriceCtrl.text.trim().isNotEmpty) + 'sale_price': double.tryParse(_salePriceCtrl.text.trim()), + if (_minStockCtrl.text.trim().isNotEmpty) + 'min_stock': int.tryParse(_minStockCtrl.text.trim()), + if (_remarkCtrl.text.trim().isNotEmpty) + 'remark': _remarkCtrl.text.trim(), + }; + try { + final notifier = ref.read(productListProvider.notifier); + if (widget.product != null) { + await notifier.updateProduct(widget.product!.id, data); + } else { + await notifier.createProduct(data); + } + if (mounted) { + Navigator.of(context).pop(); + widget.onSaved(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text(widget.product != null ? '商品更新成功' : '商品创建成功'), + backgroundColor: AppTheme.success, ), - ], - ), - columns: const [ - DataColumn(label: Text('仓库编码')), - DataColumn(label: Text('仓库名称')), - DataColumn(label: Text('仓库类型')), - DataColumn(label: Text('位置')), - DataColumn(label: Text('总容量'), numeric: true), - DataColumn(label: Text('已用容量'), numeric: true), - DataColumn(label: Text('使用率'), numeric: true), - DataColumn(label: Text('负责人')), - DataColumn(label: Text('状态')), - DataColumn(label: Text('操作')), - ], - rows: _warehouses - .map((w) { - final usageRate = - ((w['used'] as int) / (w['capacity'] as int) * 100) - .toStringAsFixed(1); - return DataRow(cells: [ - DataCell(Text(w['code'] as String, - style: const TextStyle(fontFamily: 'monospace', fontSize: 12))), - DataCell(Text(w['name'] as String, - style: const TextStyle(fontWeight: FontWeight.w500))), - DataCell(Text(w['type'] as String)), - DataCell(Text(w['location'] as String)), - DataCell(Text('${w['capacity']}')), - DataCell(Text('${w['used']}')), - DataCell(Stack( - alignment: Alignment.centerLeft, - children: [ - SizedBox( - width: 60, - child: LinearProgressIndicator( - value: (w['used'] as int) / (w['capacity'] as int), - backgroundColor: AppTheme.border, - color: (w['used'] as int) / (w['capacity'] as int) > 0.8 - ? AppTheme.danger - : AppTheme.primary, - minHeight: 6, - borderRadius: BorderRadius.circular(3), - ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('保存失败:$e'), + backgroundColor: AppTheme.danger), + ); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final isEdit = widget.product != null; + return Dialog( + child: Container( + width: 600, + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(isEdit ? '编辑商品' : '新建商品', + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.w600)), + const SizedBox(height: 20), + Row(children: [ + Expanded( + child: TextFormField( + controller: _nameCtrl, + decoration: const InputDecoration(labelText: '商品名称'), + validator: (v) => + (v == null || v.isEmpty) ? '不能为空' : null, ), - Padding( - padding: const EdgeInsets.only(top: 12), - child: Text('$usageRate%', - style: const TextStyle(fontSize: 11)), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: _codeCtrl, + decoration: const InputDecoration(labelText: '商品编码'), + validator: (v) => + (v == null || v.isEmpty) ? '不能为空' : null, ), - ], - )), - DataCell(Text(w['manager'] as String)), - DataCell(_StatusBadge(w['status'] as String)), - DataCell(Row( - mainAxisSize: MainAxisSize.min, + ), + ]), + const SizedBox(height: 12), + Row(children: [ + Expanded( + child: TextFormField( + controller: _brandCtrl, + decoration: const InputDecoration(labelText: '品牌'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: _specCtrl, + decoration: const InputDecoration(labelText: '规格'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: DropdownButtonFormField( + value: _unit, + decoration: const InputDecoration(labelText: '单位'), + items: ['瓶', '箱', '件', '桶', '支', '个'] + .map((u) => DropdownMenuItem( + value: u, + child: Text(u, + style: const TextStyle(fontSize: 13)))) + .toList(), + onChanged: (v) => setState(() => _unit = v!), + ), + ), + ]), + const SizedBox(height: 12), + Row(children: [ + Expanded( + child: TextFormField( + controller: _purchasePriceCtrl, + decoration: const InputDecoration( + labelText: '进货价', prefixText: '¥'), + keyboardType: const TextInputType.numberWithOptions( + decimal: true), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: _salePriceCtrl, + decoration: const InputDecoration( + labelText: '销售价', prefixText: '¥'), + keyboardType: const TextInputType.numberWithOptions( + decimal: true), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: _minStockCtrl, + decoration: + const InputDecoration(labelText: '安全库存'), + keyboardType: TextInputType.number, + ), + ), + ]), + const SizedBox(height: 12), + TextFormField( + controller: _barcodeCtrl, + decoration: const InputDecoration(labelText: '条形码'), + ), + const SizedBox(height: 12), + TextFormField( + controller: _remarkCtrl, + decoration: const InputDecoration(labelText: '备注'), + maxLines: 2, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( - onPressed: () {}, - child: const Text('编辑', - style: TextStyle(fontSize: 12))), + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Text('保存'), + ), ], - )), - ]); - }) - .toList(), - ); - } - - void _showProductDialog(BuildContext context) { - showDialog( - context: context, - builder: (ctx) => FormDialog( - title: '新增商品', - width: 600, - onConfirm: () { - Navigator.of(ctx).pop(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('商品添加成功'), - backgroundColor: AppTheme.success, - ), - ); - }, - content: Column( - children: [ - Row( - children: [ - Expanded( - child: _Field( - label: '商品名称', - required: true, - child: TextFormField( - decoration: - const InputDecoration(hintText: '请输入商品全称')), - ), - ), - const SizedBox(width: 12), - Expanded( - child: _Field( - label: '商品分类', - required: true, - child: DropdownButtonFormField( - value: '白酒', - items: ['白酒', '葡萄酒', '洋酒', '啤酒', '黄酒'] - .map((s) => DropdownMenuItem( - value: s, - child: Text(s, - style: const TextStyle(fontSize: 13)))) - .toList(), - onChanged: (_) {}, - decoration: const InputDecoration(), - ), - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: _Field( - label: '品牌', - child: TextFormField( - decoration: const InputDecoration(hintText: '品牌名称')), - ), - ), - const SizedBox(width: 12), - Expanded( - child: _Field( - label: '规格', - child: TextFormField( - decoration: - const InputDecoration(hintText: '如:500ml/瓶')), - ), - ), - const SizedBox(width: 12), - Expanded( - child: _Field( - label: '单位', - child: DropdownButtonFormField( - value: '瓶', - items: ['瓶', '箱', '件', '桶', '支'] - .map((s) => DropdownMenuItem( - value: s, - child: Text(s, - style: const TextStyle(fontSize: 13)))) - .toList(), - onChanged: (_) {}, - decoration: const InputDecoration(), - ), - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: _Field( - label: '成本价', - child: TextFormField( - decoration: const InputDecoration( - hintText: '0.00', prefixText: '¥'), - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: _Field( - label: '销售价', - child: TextFormField( - decoration: const InputDecoration( - hintText: '0.00', prefixText: '¥'), - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: _Field( - label: '安全库存', - child: TextFormField( - decoration: const InputDecoration(hintText: '最低库存量'), - keyboardType: TextInputType.number, - ), - ), - ), - ], - ), - const SizedBox(height: 12), - _Field( - label: '条形码', - fullWidth: true, - child: TextFormField( - decoration: const InputDecoration(hintText: '商品条形码(选填)')), - ), - ], - ), - ), - ); - } -} - -class _Field extends StatelessWidget { - final String label; - final Widget child; - final bool required; - final bool fullWidth; - - const _Field({ - required this.label, - required this.child, - this.required = false, - this.fullWidth = false, - }); - - @override - Widget build(BuildContext context) { - Widget content = Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - if (required) - const Text('* ', - style: TextStyle(color: AppTheme.danger, fontSize: 13)), - Text(label, - style: const TextStyle( - fontSize: 13, color: AppTheme.textSecondary)), - ]), - const SizedBox(height: 6), - child, - ], - ); - return fullWidth - ? SizedBox(width: double.infinity, child: content) - : content; - } -} - -class _StatusBadge extends StatelessWidget { - final String status; - const _StatusBadge(this.status); - - @override - Widget build(BuildContext context) { - final bool enabled = status == '启用'; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: enabled - ? const Color(0xFFE8F5E9) - : const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(3), - ), - child: Text( - status, - style: TextStyle( - color: enabled ? AppTheme.success : AppTheme.textSecondary, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ); - } -} - -class _DropdownFilter extends StatelessWidget { - final String value; - final List items; - final ValueChanged onChanged; - final String hint; - - const _DropdownFilter({ - required this.value, - required this.items, - required this.onChanged, - required this.hint, - }); - - @override - Widget build(BuildContext context) { - return Container( - height: 36, - padding: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - border: Border.all(color: AppTheme.border), - borderRadius: BorderRadius.circular(4), - color: AppTheme.surface, - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: value, - items: items - .map((s) => DropdownMenuItem( - value: s, - child: Text(s, style: const TextStyle(fontSize: 13)))) - .toList(), - onChanged: onChanged, - style: const TextStyle( - fontSize: 13, color: AppTheme.textPrimary), + ), + ], + ), ), ), ); diff --git a/client/lib/screens/settings/settings_screen.dart b/client/lib/screens/settings/settings_screen.dart index d6a102f..d4a31ae 100644 --- a/client/lib/screens/settings/settings_screen.dart +++ b/client/lib/screens/settings/settings_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/theme/app_theme.dart'; +import '../../models/warehouse.dart'; +import '../../providers/warehouse_provider.dart'; class SettingsScreen extends ConsumerStatefulWidget { const SettingsScreen({super.key}); @@ -144,12 +146,7 @@ class _SettingsScreenState extends ConsumerState { } Widget _buildWarehousesTab() { - final warehouses = [ - {'code': 'WH001', 'name': '主仓库', 'location': '一楼东侧', 'manager': '张三', 'capacity': 1000, 'used': 680, 'status': true}, - {'code': 'WH002', 'name': '副仓库', 'location': '二楼西侧', 'manager': '李四', 'capacity': 500, 'used': 210, 'status': true}, - {'code': 'WH003', 'name': '保税仓库', 'location': '地下一层', 'manager': '王五', 'capacity': 300, 'used': 0, 'status': false}, - ]; - + final asyncWarehouses = ref.watch(warehouseListProvider); return Column( children: [ Container( @@ -159,87 +156,144 @@ class _SettingsScreenState extends ConsumerState { child: Row( children: [ ElevatedButton.icon( - onPressed: () {}, + onPressed: () => _showWarehouseDialog(context), icon: const Icon(Icons.add, size: 16), - label: const Text('新增仓库'), + label: const Text('新建'), ), ], ), ), const Divider(height: 1), Expanded( - child: SingleChildScrollView( - child: DataTable( - headingRowColor: WidgetStateProperty.all(const Color(0xFFF0F4FF)), - columns: const [ - DataColumn(label: Text('仓库编号')), - DataColumn(label: Text('仓库名称')), - DataColumn(label: Text('位置')), - DataColumn(label: Text('负责人')), - DataColumn(label: Text('使用情况')), - DataColumn(label: Text('状态')), - DataColumn(label: Text('操作')), - ], - rows: warehouses - .map((w) => DataRow(cells: [ - DataCell(Text(w['code'] as String, - style: const TextStyle( - fontFamily: 'monospace', fontSize: 12))), - DataCell(Text(w['name'] as String, - style: const TextStyle(fontWeight: FontWeight.w500))), - DataCell(Text(w['location'] as String)), - DataCell(Text(w['manager'] as String)), - DataCell(SizedBox( - width: 140, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${w['used']}/${w['capacity']}', - style: const TextStyle( - fontSize: 12, - color: AppTheme.textSecondary), - ), - const SizedBox(height: 2), - LinearProgressIndicator( - value: (w['capacity'] as int) > 0 - ? (w['used'] as int) / (w['capacity'] as int) - : 0, - backgroundColor: AppTheme.border, - valueColor: AlwaysStoppedAnimation( - (w['used'] as int) / (w['capacity'] as int) > 0.8 - ? AppTheme.danger - : AppTheme.primary, - ), - ), - ], - ), - )), - DataCell(Switch( - value: w['status'] as bool, - onChanged: (_) {}, - materialTapTargetSize: - MaterialTapTargetSize.shrinkWrap, - )), - DataCell(Row( - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - onPressed: () {}, - child: const Text('编辑', - style: TextStyle(fontSize: 12))), - ], - )), - ])) - .toList(), + child: asyncWarehouses.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('加载失败:$e', + style: const TextStyle(color: AppTheme.danger)), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () => + ref.read(warehouseListProvider.notifier).reload(), + child: const Text('重试'), + ), + ], + ), ), + data: (warehouses) { + if (warehouses.isEmpty) { + return const Center( + child: Text('暂无仓库', + style: + TextStyle(color: AppTheme.textSecondary))); + } + return SingleChildScrollView( + child: DataTable( + headingRowColor: + WidgetStateProperty.all(const Color(0xFFF0F4FF)), + columns: const [ + DataColumn(label: Text('仓库名称')), + DataColumn(label: Text('位置')), + DataColumn(label: Text('默认仓库')), + DataColumn(label: Text('操作')), + ], + rows: warehouses + .map((w) => DataRow(cells: [ + DataCell(Text(w.name, + style: const TextStyle( + fontWeight: FontWeight.w500))), + DataCell(Text(w.location ?? '-')), + DataCell(w.isDefault + ? const Icon(Icons.check_circle, + color: AppTheme.success, size: 18) + : const SizedBox()), + DataCell(Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + key: Key('btn_edit_${w.id}'), + onPressed: () => + _showWarehouseDialog(context, + warehouse: w), + child: const Text('编辑', + style: TextStyle(fontSize: 12)), + ), + TextButton( + key: Key('btn_delete_${w.id}'), + onPressed: () => + _confirmDeleteWarehouse(context, w), + child: const Text('删除', + style: TextStyle( + fontSize: 12, + color: AppTheme.danger)), + ), + ], + )), + ])) + .toList(), + ), + ); + }, ), ), ], ); } + Future _confirmDeleteWarehouse( + BuildContext context, Warehouse w) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('确认删除'), + content: Text('确认删除仓库「${w.name}」?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('取消')), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.danger, + foregroundColor: Colors.white), + child: const Text('删除'), + ), + ], + ), + ); + if (confirmed == true && mounted) { + try { + await ref + .read(warehouseListProvider.notifier) + .deleteWarehouse(w.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('删除成功'), + backgroundColor: AppTheme.success)); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('删除失败:$e'), + backgroundColor: AppTheme.danger)); + } + } + } + } + + void _showWarehouseDialog(BuildContext context, {Warehouse? warehouse}) { + showDialog( + context: context, + builder: (ctx) => _WarehouseFormDialog( + warehouse: warehouse, + onSaved: () => + ref.read(warehouseListProvider.notifier).reload(), + ), + ); + } + Widget _buildNumberRulesTab() { final rules = [ {'type': '入库单', 'prefix': 'RK', 'format': 'RK{年}{月}{日}{序号4}', 'example': 'RK20260404001', 'currentNo': 4}, @@ -337,12 +391,6 @@ class _SettingsScreenState extends ConsumerState { _ParamRow(label: '入库单需要审核', value: '是', isSwitch: true), _ParamRow(label: '出库单需要审核', value: '是', isSwitch: true), _ParamRow(label: '允许超量出库', value: '否', isSwitch: false), - const SizedBox(height: 16), - const Text('库存预警设置', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.primaryDark)), - const Divider(height: 24), - _ParamRow(label: '启用库存预警', value: '是', isSwitch: true), - _ParamRow(label: '预警提醒方式', value: '系统内通知'), ], ), ), @@ -415,6 +463,133 @@ class _SettingsScreenState extends ConsumerState { } } +class _WarehouseFormDialog extends ConsumerStatefulWidget { + final Warehouse? warehouse; + final VoidCallback onSaved; + + const _WarehouseFormDialog({this.warehouse, required this.onSaved}); + + @override + ConsumerState<_WarehouseFormDialog> createState() => + _WarehouseFormDialogState(); +} + +class _WarehouseFormDialogState extends ConsumerState<_WarehouseFormDialog> { + final _formKey = GlobalKey(); + late final TextEditingController _nameCtrl; + late final TextEditingController _locationCtrl; + late bool _isDefault; + bool _saving = false; + + @override + void initState() { + super.initState(); + _nameCtrl = TextEditingController(text: widget.warehouse?.name ?? ''); + _locationCtrl = + TextEditingController(text: widget.warehouse?.location ?? ''); + _isDefault = widget.warehouse?.isDefault ?? false; + } + + @override + void dispose() { + _nameCtrl.dispose(); + _locationCtrl.dispose(); + super.dispose(); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _saving = true); + final data = { + 'name': _nameCtrl.text.trim(), + if (_locationCtrl.text.trim().isNotEmpty) + 'location': _locationCtrl.text.trim(), + 'is_default': _isDefault, + }; + try { + final notifier = ref.read(warehouseListProvider.notifier); + if (widget.warehouse != null) { + await notifier.updateWarehouse(widget.warehouse!.id, data); + } else { + await notifier.createWarehouse(data); + } + if (mounted) { + Navigator.of(context).pop(); + widget.onSaved(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + widget.warehouse != null ? '仓库更新成功' : '仓库创建成功'), + backgroundColor: AppTheme.success, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('保存失败:$e'), + backgroundColor: AppTheme.danger), + ); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.warehouse != null ? '编辑仓库' : '新建仓库'), + content: SizedBox( + width: 400, + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _nameCtrl, + decoration: const InputDecoration(labelText: '仓库名称'), + validator: (v) => + (v == null || v.isEmpty) ? '不能为空' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _locationCtrl, + decoration: const InputDecoration(labelText: '位置'), + ), + const SizedBox(height: 12), + CheckboxListTile( + title: const Text('设为默认仓库'), + value: _isDefault, + onChanged: (v) => setState(() => _isDefault = v ?? false), + contentPadding: EdgeInsets.zero, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + ElevatedButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Text('保存'), + ), + ], + ); + } +} + class _RoleBadge extends StatelessWidget { final String role; const _RoleBadge(this.role); diff --git a/client/lib/screens/shell/app_shell.dart b/client/lib/screens/shell/app_shell.dart index 623916c..48e5146 100644 --- a/client/lib/screens/shell/app_shell.dart +++ b/client/lib/screens/shell/app_shell.dart @@ -72,7 +72,7 @@ class _AppShellState extends ConsumerState { const Icon(Icons.business, color: Colors.white70, size: 14), const SizedBox(width: 4), - Text(user.hotelNo, + Text(user.shopNo, style: const TextStyle( color: Colors.white70, fontSize: 13)), const SizedBox(width: 20), @@ -86,6 +86,11 @@ class _AppShellState extends ConsumerState { PopupMenuButton( icon: const Icon(Icons.keyboard_arrow_down, color: Colors.white70), + offset: const Offset(0, 36), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6)), + color: Colors.white, + elevation: 8, onSelected: (v) { if (v == 'logout') { ref.read(authStateProvider.notifier).logout(); @@ -93,21 +98,24 @@ class _AppShellState extends ConsumerState { } }, itemBuilder: (context) => [ - const PopupMenuItem( - value: 'profile', - child: ListTile( - leading: Icon(Icons.manage_accounts, size: 18), - title: Text('个人设置'), - dense: true, - )), - const PopupMenuDivider(), - const PopupMenuItem( - value: 'logout', - child: ListTile( - leading: Icon(Icons.logout, size: 18), - title: Text('退出登录'), - dense: true, - )), + const PopupMenuItem( + value: 'profile', + padding: EdgeInsets.zero, + child: _HoverMenuItem( + icon: Icons.manage_accounts_outlined, + label: '个人设置', + ), + ), + const PopupMenuDivider(height: 1), + const PopupMenuItem( + value: 'logout', + padding: EdgeInsets.zero, + child: _HoverMenuItem( + icon: Icons.logout_outlined, + label: '退出登录', + danger: true, + ), + ), ], ), const SizedBox(width: 8), @@ -161,7 +169,7 @@ class _AppShellState extends ConsumerState { if (user != null) ...[ _StatusItem( icon: Icons.store, - text: '门店编号:${user.hotelNo}'), + text: '门店编号:${user.shopNo}'), const _StatusDivider(), _StatusItem( icon: Icons.person, @@ -334,3 +342,61 @@ class _ClockWidgetState extends State<_ClockWidget> { return _StatusItem(icon: Icons.access_time, text: '当前时间:$_time'); } } + +class _HoverMenuItem extends StatefulWidget { + final IconData icon; + final String label; + final bool danger; + + const _HoverMenuItem({ + required this.icon, + required this.label, + this.danger = false, + }); + + @override + State<_HoverMenuItem> createState() => _HoverMenuItemState(); +} + +class _HoverMenuItemState extends State<_HoverMenuItem> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final color = widget.danger + ? const Color(0xFFE53935) + : const Color(0xFF333333); + final hoverBg = widget.danger + ? const Color(0xFFFFF0F0) + : const Color(0xFFF0F4FF); + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + cursor: SystemMouseCursors.click, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + width: 152, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: _hovered ? hoverBg : Colors.transparent, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + children: [ + Icon(widget.icon, size: 16, color: color), + const SizedBox(width: 10), + Text( + widget.label, + style: TextStyle( + fontSize: 14, + color: color, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + ); + } +} diff --git a/client/lib/screens/stock_in/stock_in_form_screen.dart b/client/lib/screens/stock_in/stock_in_form_screen.dart index e8a982e..aeeb91c 100644 --- a/client/lib/screens/stock_in/stock_in_form_screen.dart +++ b/client/lib/screens/stock_in/stock_in_form_screen.dart @@ -3,6 +3,11 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/theme/app_theme.dart'; +import '../../models/product.dart'; +import '../../providers/partner_provider.dart'; +import '../../providers/product_provider.dart'; +import '../../providers/stock_in_provider.dart'; +import '../../providers/warehouse_provider.dart'; class StockInFormScreen extends ConsumerStatefulWidget { const StockInFormScreen({super.key}); @@ -13,37 +18,25 @@ class StockInFormScreen extends ConsumerStatefulWidget { class _StockInFormScreenState extends ConsumerState { final _formKey = GlobalKey(); - final _orderNoCtrl = TextEditingController(text: 'RK20260404004'); final _remarkCtrl = TextEditingController(); - String _supplier = '贵州茅台酒股份有限公司'; - String _warehouse = '主仓库'; + int? _warehouseId; + int? _partnerId; DateTime _orderDate = DateTime.now(); bool _submitting = false; - final List> _items = [ - { - 'name': '茅台酒(飞天)53度500ml', - 'spec': '500ml/瓶', - 'unit': '瓶', - 'qty': TextEditingController(text: '10'), - 'price': TextEditingController(text: '2600.00'), - }, - { - 'name': '五粮液(普五)52度500ml', - 'spec': '500ml/瓶', - 'unit': '瓶', - 'qty': TextEditingController(text: '20'), - 'price': TextEditingController(text: '1050.00'), - }, - ]; + final List<_ItemRow> _items = []; + + @override + void initState() { + super.initState(); + _items.add(_ItemRow()); + } @override void dispose() { - _orderNoCtrl.dispose(); _remarkCtrl.dispose(); for (final item in _items) { - (item['qty'] as TextEditingController).dispose(); - (item['price'] as TextEditingController).dispose(); + item.dispose(); } super.dispose(); } @@ -51,60 +44,94 @@ class _StockInFormScreenState extends ConsumerState { double get _totalAmount { double total = 0; for (final item in _items) { - final qty = double.tryParse( - (item['qty'] as TextEditingController).text) ?? - 0; - final price = double.tryParse( - (item['price'] as TextEditingController).text) ?? - 0; + final qty = double.tryParse(item.qtyCtrl.text) ?? 0; + final price = double.tryParse(item.priceCtrl.text) ?? 0; total += qty * price; } return total; } void _addItem() { - setState(() { - _items.add({ - 'name': '', - 'spec': '', - 'unit': '瓶', - 'qty': TextEditingController(), - 'price': TextEditingController(), - }); - }); + setState(() => _items.add(_ItemRow())); } void _removeItem(int index) { setState(() { - final item = _items.removeAt(index); - (item['qty'] as TextEditingController).dispose(); - (item['price'] as TextEditingController).dispose(); + _items[index].dispose(); + _items.removeAt(index); }); } Future _submit(bool asDraft) async { if (!asDraft && !_formKey.currentState!.validate()) return; - setState(() => _submitting = true); - await Future.delayed(const Duration(milliseconds: 600)); - if (mounted) { + if (_warehouseId == null) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(asDraft ? '已保存为草稿' : '入库单已提交审核'), - backgroundColor: AppTheme.success, - ), + const SnackBar(content: Text('请选择入库仓库'), backgroundColor: AppTheme.danger), ); - context.go('/stock-in'); + return; + } + if (_items.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('请添加商品明细'), backgroundColor: AppTheme.danger), + ); + return; + } + + setState(() => _submitting = true); + + final itemsData = _items.map((item) { + final qty = double.tryParse(item.qtyCtrl.text) ?? 0; + final price = double.tryParse(item.priceCtrl.text) ?? 0; + return { + 'product_id': item.productId ?? 0, + 'quantity': qty, + 'unit_price': price, + 'total_price': qty * price, + }; + }).toList(); + + final data = { + 'warehouse_id': _warehouseId, + if (_partnerId != null) 'partner_id': _partnerId, + 'order_date': + '${_orderDate.year}-${_orderDate.month.toString().padLeft(2, '0')}-${_orderDate.day.toString().padLeft(2, '0')}', + if (_remarkCtrl.text.trim().isNotEmpty) 'remark': _remarkCtrl.text.trim(), + 'items': itemsData, + 'status': asDraft ? 'draft' : 'pending', + }; + + try { + await ref.read(stockInListProvider.notifier).createOrder(data); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(asDraft ? '已保存为草稿' : '入库单已提交审核'), + backgroundColor: AppTheme.success, + ), + ); + context.go('/stock-in'); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('操作失败:$e'), backgroundColor: AppTheme.danger), + ); + } + } finally { + if (mounted) setState(() => _submitting = false); } - if (mounted) setState(() => _submitting = false); } @override Widget build(BuildContext context) { + final asyncWarehouses = ref.watch(warehouseListProvider); + final asyncSuppliers = ref.watch(supplierListProvider); + return Scaffold( backgroundColor: AppTheme.background, body: Column( children: [ - // Page header Container( height: 52, color: AppTheme.surface, @@ -155,7 +182,6 @@ class _StockInFormScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Basic info card Card( child: Padding( padding: const EdgeInsets.all(16), @@ -172,71 +198,70 @@ class _StockInFormScreenState extends ConsumerState { spacing: 16, runSpacing: 16, children: [ - _FormField( - label: '入库单号', - child: TextFormField( - controller: _orderNoCtrl, - readOnly: true, - style: const TextStyle( - fontFamily: 'monospace'), - decoration: const InputDecoration( - suffixIcon: Icon( - Icons.autorenew, - size: 16), - ), - ), - ), - _FormField( - label: '供应商', - required: true, - child: DropdownButtonFormField( - value: _supplier, - items: [ - '贵州茅台酒股份有限公司', - '四川五粮液股份有限公司', - '江苏洋河酒厂股份有限公司', - '剑南春(集团)有限责任公司', - '泸州老窖股份有限公司', - '山西汾酒股份有限公司', - ] - .map((s) => DropdownMenuItem( - value: s, - child: Text(s, - style: const TextStyle( - fontSize: 13)))) - .toList(), - onChanged: (v) => - setState(() => _supplier = v!), - validator: (v) => v == null || v.isEmpty - ? '请选择供应商' - : null, - decoration: const InputDecoration(), - ), - ), + // Warehouse dropdown _FormField( label: '入库仓库', required: true, - child: DropdownButtonFormField( - value: _warehouse, - items: ['主仓库', '副仓库', '保税仓库'] - .map((s) => DropdownMenuItem( - value: s, - child: Text(s, - style: const TextStyle( - fontSize: 13)))) - .toList(), - onChanged: (v) => - setState(() => _warehouse = v!), - decoration: const InputDecoration(), + child: asyncWarehouses.when( + loading: () => + const LinearProgressIndicator(), + error: (_, __) => const Text('加载失败'), + data: (warehouses) => + DropdownButtonFormField( + value: _warehouseId, + hint: const Text('请选择仓库', + style: + TextStyle(fontSize: 13)), + items: warehouses + .map((w) => DropdownMenuItem( + value: w.id, + child: Text(w.name, + style: const TextStyle( + fontSize: 13)))) + .toList(), + onChanged: (v) => setState( + () => _warehouseId = v), + validator: (v) => + v == null ? '不能为空' : null, + decoration: const InputDecoration(), + ), ), ), + // Partner dropdown + _FormField( + label: '供应商', + child: asyncSuppliers.when( + loading: () => + const LinearProgressIndicator(), + error: (_, __) => const Text('加载失败'), + data: (result) => + DropdownButtonFormField( + value: _partnerId, + hint: const Text('请选择供应商', + style: + TextStyle(fontSize: 13)), + items: result.data + .map((p) => DropdownMenuItem( + value: p.id, + child: Text(p.name, + style: const TextStyle( + fontSize: 13)))) + .toList(), + onChanged: (v) => setState( + () => _partnerId = v), + decoration: const InputDecoration(), + ), + ), + ), + // Date picker _FormField( label: '入库日期', required: true, child: InkWell( onTap: _pickDate, child: InputDecorator( - decoration: const InputDecoration(), + decoration: + const InputDecoration(), child: Row( children: [ Expanded( @@ -249,7 +274,8 @@ class _StockInFormScreenState extends ConsumerState { const Icon( Icons.calendar_today, size: 16, - color: AppTheme.textSecondary), + color: + AppTheme.textSecondary), ], ), ), @@ -274,7 +300,6 @@ class _StockInFormScreenState extends ConsumerState { ), ), const SizedBox(height: 12), - // Items card Card( child: Padding( padding: const EdgeInsets.all(16), @@ -299,17 +324,14 @@ class _StockInFormScreenState extends ConsumerState { ], ), const SizedBox(height: 12), - // Items table Table( columnWidths: const { 0: FixedColumnWidth(36), 1: FlexColumnWidth(3), - 2: FlexColumnWidth(2), - 3: FixedColumnWidth(60), + 2: FlexColumnWidth(1.5), + 3: FlexColumnWidth(1.5), 4: FlexColumnWidth(1.5), - 5: FlexColumnWidth(1.5), - 6: FlexColumnWidth(1.5), - 7: FixedColumnWidth(60), + 5: FixedColumnWidth(60), }, children: [ TableRow( @@ -317,23 +339,24 @@ class _StockInFormScreenState extends ConsumerState { color: Color(0xFFF0F4FF)), children: [ '序号', - '商品名称', - '规格', - '单位', + '商品', '数量', '单价', '金额', '操作', ] .map((h) => Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 10), + padding: + const EdgeInsets.symmetric( + horizontal: 8, + vertical: 10), child: Text(h, style: const TextStyle( fontSize: 13, - fontWeight: FontWeight.w600, - color: - AppTheme.primaryDark)), + fontWeight: + FontWeight.w600, + color: AppTheme + .primaryDark)), )) .toList(), ), @@ -343,7 +366,6 @@ class _StockInFormScreenState extends ConsumerState { ], ), const Divider(height: 1), - // Total Padding( padding: const EdgeInsets.only(top: 12), child: Row( @@ -379,10 +401,9 @@ class _StockInFormScreenState extends ConsumerState { TableRow _buildItemRow(int index) { final item = _items[index]; - final qtyCtrl = item['qty'] as TextEditingController; - final priceCtrl = item['price'] as TextEditingController; - final qty = double.tryParse(qtyCtrl.text) ?? 0; - final price = double.tryParse(priceCtrl.text) ?? 0; + final asyncProducts = ref.watch(productListProvider); + final qty = double.tryParse(item.qtyCtrl.text) ?? 0; + final price = double.tryParse(item.priceCtrl.text) ?? 0; final amount = qty * price; return TableRow( @@ -390,90 +411,89 @@ class _StockInFormScreenState extends ConsumerState { color: index.isEven ? Colors.white : const Color(0xFFFAFAFA), ), children: [ - // Index Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), child: Text('${index + 1}', style: const TextStyle( fontSize: 13, color: AppTheme.textSecondary)), ), - // Name + Padding( + padding: const EdgeInsets.all(4), + child: asyncProducts.when( + loading: () => const LinearProgressIndicator(), + error: (_, __) => const Text('加载失败'), + data: (result) => DropdownButtonFormField( + value: item.productId, + hint: const Text('选择商品', + style: TextStyle(fontSize: 13)), + items: result.data + .map((p) => DropdownMenuItem( + value: p.id, + child: Text('${p.name}(${p.spec ?? p.unit})', + style: const TextStyle(fontSize: 13)))) + .toList(), + onChanged: (v) { + setState(() { + item.productId = v; + // Auto-fill price if available + if (v != null) { + final found = result.data.firstWhere( + (p) => p.id == v, + orElse: () => Product( + id: 0, code: '', name: '', unit: '')); + if (found.purchasePrice != null) { + item.priceCtrl.text = + found.purchasePrice!.toStringAsFixed(2); + } + } + }); + }, + validator: (v) => v == null ? '不能为空' : null, + decoration: const InputDecoration(), + ), + ), + ), Padding( padding: const EdgeInsets.all(4), child: TextFormField( - initialValue: item['name'] as String, - decoration: - const InputDecoration(hintText: '商品名称'), - style: const TextStyle(fontSize: 13), - onChanged: (v) => item['name'] = v, - validator: (v) => - (v == null || v.isEmpty) ? '必填' : null, - ), - ), - // Spec - Padding( - padding: const EdgeInsets.all(4), - child: TextFormField( - initialValue: item['spec'] as String, - decoration: const InputDecoration(hintText: '规格'), - style: const TextStyle(fontSize: 13), - onChanged: (v) => item['spec'] = v, - ), - ), - // Unit - Padding( - padding: const EdgeInsets.all(4), - child: DropdownButtonFormField( - value: item['unit'] as String, - items: ['瓶', '箱', '件', '桶', '支'] - .map((u) => DropdownMenuItem( - value: u, - child: Text(u, style: const TextStyle(fontSize: 13)))) - .toList(), - onChanged: (v) => setState(() => item['unit'] = v!), - decoration: const InputDecoration(), - ), - ), - // Qty - Padding( - padding: const EdgeInsets.all(4), - child: TextFormField( - controller: qtyCtrl, + controller: item.qtyCtrl, decoration: const InputDecoration(hintText: '0'), style: const TextStyle(fontSize: 13), - keyboardType: const TextInputType.numberWithOptions(decimal: true), + keyboardType: + const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')) + FilteringTextInputFormatter.allow( + RegExp(r'^\d+\.?\d{0,2}')) ], onChanged: (_) => setState(() {}), validator: (v) { - if (v == null || v.isEmpty) return '必填'; + if (v == null || v.isEmpty) return '不能为空'; if ((double.tryParse(v) ?? 0) <= 0) return '>0'; return null; }, ), ), - // Price Padding( padding: const EdgeInsets.all(4), child: TextFormField( - controller: priceCtrl, + controller: item.priceCtrl, decoration: const InputDecoration( hintText: '0.00', prefixText: '¥'), style: const TextStyle(fontSize: 13), - keyboardType: const TextInputType.numberWithOptions(decimal: true), + keyboardType: + const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')) + FilteringTextInputFormatter.allow( + RegExp(r'^\d+\.?\d{0,2}')) ], onChanged: (_) => setState(() {}), validator: (v) { - if (v == null || v.isEmpty) return '必填'; + if (v == null || v.isEmpty) return '不能为空'; if ((double.tryParse(v) ?? 0) <= 0) return '>0'; return null; }, ), ), - // Amount Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), child: Text( @@ -482,13 +502,13 @@ class _StockInFormScreenState extends ConsumerState { fontSize: 13, fontWeight: FontWeight.w500), ), ), - // Actions Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), child: IconButton( icon: const Icon(Icons.delete_outline, size: 18, color: AppTheme.danger), - onPressed: _items.length > 1 ? () => _removeItem(index) : null, + onPressed: + _items.length > 1 ? () => _removeItem(index) : null, tooltip: '删除', padding: EdgeInsets.zero, constraints: @@ -510,6 +530,17 @@ class _StockInFormScreenState extends ConsumerState { } } +class _ItemRow { + int? productId; + final TextEditingController qtyCtrl = TextEditingController(); + final TextEditingController priceCtrl = TextEditingController(); + + void dispose() { + qtyCtrl.dispose(); + priceCtrl.dispose(); + } +} + class _FormField extends StatelessWidget { final String label; final Widget child; @@ -534,7 +565,8 @@ class _FormField extends StatelessWidget { children: [ if (required) const Text('*', - style: TextStyle(color: AppTheme.danger, fontSize: 13)), + style: + TextStyle(color: AppTheme.danger, fontSize: 13)), Text(label, style: const TextStyle( fontSize: 13, color: AppTheme.textSecondary)), diff --git a/client/lib/screens/stock_in/stock_in_list_screen.dart b/client/lib/screens/stock_in/stock_in_list_screen.dart index 14f3b21..24f45f2 100644 --- a/client/lib/screens/stock_in/stock_in_list_screen.dart +++ b/client/lib/screens/stock_in/stock_in_list_screen.dart @@ -1,144 +1,43 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import '../../widgets/page_scaffold.dart'; -import '../../widgets/data_table_card.dart'; -import '../../widgets/status_badge.dart'; import '../../core/theme/app_theme.dart'; +import '../../models/stock_in.dart'; +import '../../providers/stock_in_provider.dart'; +import '../../widgets/data_table_card.dart'; +import '../../widgets/page_scaffold.dart'; +import '../../widgets/status_badge.dart'; class StockInListScreen extends ConsumerStatefulWidget { const StockInListScreen({super.key}); @override - ConsumerState createState() => - _StockInListScreenState(); + ConsumerState createState() => _StockInListScreenState(); } class _StockInListScreenState extends ConsumerState { - int _page = 1; - final _searchCtrl = TextEditingController(); - String _statusFilter = '全部'; + String _statusFilter = ''; DateTimeRange? _dateRange; - final List> _mockOrders = [ - { - 'no': 'RK20260401001', - 'supplier': '贵州茅台酒股份有限公司', - 'warehouse': '主仓库', - 'amount': '85000.00', - 'status': OrderStatus.approved, - 'creator': '张三', - 'date': '2026-04-01', - }, - { - 'no': 'RK20260401002', - 'supplier': '四川五粮液股份有限公司', - 'warehouse': '副仓库', - 'amount': '42500.00', - 'status': OrderStatus.pending, - 'creator': '李四', - 'date': '2026-04-01', - }, - { - 'no': 'RK20260402001', - 'supplier': '江苏洋河酒厂股份有限公司', - 'warehouse': '主仓库', - 'amount': '36800.00', - 'status': OrderStatus.approved, - 'creator': '王五', - 'date': '2026-04-02', - }, - { - 'no': 'RK20260402002', - 'supplier': '剑南春(集团)有限责任公司', - 'warehouse': '主仓库', - 'amount': '28600.00', - 'status': OrderStatus.draft, - 'creator': '张三', - 'date': '2026-04-02', - }, - { - 'no': 'RK20260403001', - 'supplier': '泸州老窖股份有限公司', - 'warehouse': '副仓库', - 'amount': '55200.00', - 'status': OrderStatus.approved, - 'creator': '李四', - 'date': '2026-04-03', - }, - { - 'no': 'RK20260403002', - 'supplier': '古井贡酒股份有限公司', - 'warehouse': '主仓库', - 'amount': '19800.00', - 'status': OrderStatus.rejected, - 'creator': '王五', - 'date': '2026-04-03', - }, - { - 'no': 'RK20260403003', - 'supplier': '贵州茅台酒股份有限公司', - 'warehouse': '主仓库', - 'amount': '126000.00', - 'status': OrderStatus.pending, - 'creator': '张三', - 'date': '2026-04-03', - }, - { - 'no': 'RK20260404001', - 'supplier': '山西汾酒股份有限公司', - 'warehouse': '主仓库', - 'amount': '33600.00', - 'status': OrderStatus.approved, - 'creator': '李四', - 'date': '2026-04-04', - }, - { - 'no': 'RK20260404002', - 'supplier': '郎酒股份有限公司', - 'warehouse': '副仓库', - 'amount': '47300.00', - 'status': OrderStatus.draft, - 'creator': '王五', - 'date': '2026-04-04', - }, - { - 'no': 'RK20260404003', - 'supplier': '四川五粮液股份有限公司', - 'warehouse': '主仓库', - 'amount': '68500.00', - 'status': OrderStatus.pending, - 'creator': '张三', - 'date': '2026-04-04', - }, - ]; + String? get _startDate => _dateRange != null + ? '${_dateRange!.start.year}-${_dateRange!.start.month.toString().padLeft(2, '0')}-${_dateRange!.start.day.toString().padLeft(2, '0')}' + : null; - @override - void dispose() { - _searchCtrl.dispose(); - super.dispose(); - } + String? get _endDate => _dateRange != null + ? '${_dateRange!.end.year}-${_dateRange!.end.month.toString().padLeft(2, '0')}-${_dateRange!.end.day.toString().padLeft(2, '0')}' + : null; - List> _filteredOrders({OrderStatus? forceStatus}) { - return _mockOrders.where((o) { - if (forceStatus != null && o['status'] != forceStatus) return false; - if (_statusFilter != '全部' && forceStatus == null) { - final map = { - '草稿': OrderStatus.draft, - '待审核': OrderStatus.pending, - '已审核': OrderStatus.approved, - '已拒绝': OrderStatus.rejected, - }; - if (o['status'] != map[_statusFilter]) return false; - } - final q = _searchCtrl.text.toLowerCase(); - if (q.isNotEmpty) { - final no = (o['no'] as String).toLowerCase(); - final supplier = (o['supplier'] as String).toLowerCase(); - if (!no.contains(q) && !supplier.contains(q)) return false; - } - return true; - }).toList(); + OrderStatus _apiStatusToEnum(String status) { + switch (status) { + case 'pending': + return OrderStatus.pending; + case 'approved': + return OrderStatus.approved; + case 'rejected': + return OrderStatus.rejected; + default: + return OrderStatus.draft; + } } @override @@ -147,23 +46,62 @@ class _StockInListScreenState extends ConsumerState { title: '入库管理', tabs: const [ Tab(text: '入库单'), - Tab(text: '入库查询'), Tab(text: '入库审核'), ], tabViews: [ - _buildOrderList(), - _buildQueryView(), - _buildOrderList(forceStatus: OrderStatus.pending), + _buildListTab(filterStatus: null), + _buildListTab(filterStatus: 'pending'), ], ); } - Widget _buildOrderList({OrderStatus? forceStatus}) { - final orders = _filteredOrders(forceStatus: forceStatus); + Widget _buildListTab({String? filterStatus}) { + final asyncOrders = ref.watch(stockInListProvider); + return asyncOrders.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('加载失败:$e', + style: const TextStyle(color: AppTheme.danger)), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () => + ref.read(stockInListProvider.notifier).reload(), + child: const Text('重试'), + ), + ], + ), + ), + data: (result) { + // Client-side filter for review tab + final orders = filterStatus != null + ? result.data + .where((o) => o.status == filterStatus) + .toList() + : result.data; + return _buildOrderTable( + orders: orders, + totalCount: filterStatus != null ? orders.length : result.total, + page: result.page, + showStatusFilter: filterStatus == null, + ); + }, + ); + } + + Widget _buildOrderTable({ + required List orders, + required int totalCount, + required int page, + required bool showStatusFilter, + }) { return DataTableCard( - totalCount: orders.length, - page: _page, - onPageChanged: (p) => setState(() => _page = p), + totalCount: totalCount, + page: page, + onPageChanged: (p) => + ref.read(stockInListProvider.notifier).setPage(p), toolbar: Row( children: [ ElevatedButton.icon( @@ -171,58 +109,41 @@ class _StockInListScreenState extends ConsumerState { icon: const Icon(Icons.add, size: 16), label: const Text('新建入库单'), ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.file_upload_outlined, size: 16), - label: const Text('导入'), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.file_download_outlined, size: 16), - label: const Text('导出'), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.print_outlined, size: 16), - label: const Text('打印'), - ), const Spacer(), - if (forceStatus == null) ...[ + if (showStatusFilter) ...[ _StatusFilterDropdown( value: _statusFilter, - onChanged: (v) => setState(() { - _statusFilter = v!; - _page = 1; - }), + onChanged: (v) { + setState(() => _statusFilter = v ?? ''); + ref + .read(stockInListProvider.notifier) + .setStatus(v ?? ''); + }, ), const SizedBox(width: 8), ], - SizedBox( - width: 180, - child: TextField( - controller: _searchCtrl, - decoration: const InputDecoration( - hintText: '搜索单号/供应商', - prefixIcon: Icon(Icons.search, size: 16), - hintStyle: TextStyle(fontSize: 13), - ), - onChanged: (_) => setState(() => _page = 1), - ), - ), - const SizedBox(width: 8), OutlinedButton.icon( onPressed: _pickDateRange, icon: const Icon(Icons.date_range, size: 16), label: Text( _dateRange == null ? '选择日期' - : '${_dateRange!.start.toString().substring(0, 10)} ~ ${_dateRange!.end.toString().substring(0, 10)}', + : '$_startDate ~ $_endDate', style: const TextStyle(fontSize: 13), ), ), + if (_dateRange != null) ...[ + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.clear, size: 16), + onPressed: () { + setState(() => _dateRange = null); + ref + .read(stockInListProvider.notifier) + .setDateRange(null, null); + }, + ), + ], ], ), columns: const [ @@ -231,119 +152,75 @@ class _StockInListScreenState extends ConsumerState { DataColumn(label: Text('仓库')), DataColumn(label: Text('金额'), numeric: true), DataColumn(label: Text('状态')), - DataColumn(label: Text('录入人')), DataColumn(label: Text('日期')), DataColumn(label: Text('操作')), ], - rows: orders - .map((o) => DataRow(cells: [ - DataCell(Text(o['no'] as String, - style: const TextStyle( - color: AppTheme.primary, - fontFamily: 'monospace', - fontSize: 12))), - DataCell(Text(o['supplier'] as String)), - DataCell(Text(o['warehouse'] as String)), - DataCell(Text('¥${o['amount']}')), - DataCell(StatusBadge(o['status'] as OrderStatus)), - DataCell(Text(o['creator'] as String)), - DataCell(Text(o['date'] as String)), - DataCell(Row( - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - onPressed: () {}, - child: const Text('查看', - style: TextStyle(fontSize: 12))), - if ((o['status'] as OrderStatus) == - OrderStatus.pending) - TextButton( - onPressed: () {}, - child: const Text('审核', - style: TextStyle( - color: AppTheme.success, - fontSize: 12))), - if ((o['status'] as OrderStatus) == - OrderStatus.draft) - TextButton( - onPressed: () {}, - child: const Text('编辑', - style: TextStyle(fontSize: 12))), - if ((o['status'] as OrderStatus) == - OrderStatus.draft) - TextButton( - onPressed: () {}, - child: const Text('删除', - style: TextStyle( - color: AppTheme.danger, - fontSize: 12))), - ], - )), - ])) - .toList(), - ); - } - - Widget _buildQueryView() { - return Column( - children: [ - Container( - color: AppTheme.surface, - padding: const EdgeInsets.all(12), - child: Row( - children: [ - const Text('查询条件:', style: TextStyle(fontSize: 13)), - const SizedBox(width: 8), - SizedBox( - width: 200, - child: TextField( - controller: _searchCtrl, - decoration: const InputDecoration( - hintText: '单号/供应商', - prefixIcon: Icon(Icons.search, size: 16), - hintStyle: TextStyle(fontSize: 13), - ), - onChanged: (_) => setState(() => _page = 1), - ), - ), - const SizedBox(width: 8), - _StatusFilterDropdown( - value: _statusFilter, - onChanged: (v) => setState(() { - _statusFilter = v!; - _page = 1; - }), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: _pickDateRange, - icon: const Icon(Icons.date_range, size: 16), - label: Text( - _dateRange == null ? '选择日期范围' : '${_dateRange!.start.toString().substring(0, 10)} ~ ${_dateRange!.end.toString().substring(0, 10)}', - style: const TextStyle(fontSize: 13), - ), - ), - const SizedBox(width: 8), - ElevatedButton.icon( - onPressed: () => setState(() {}), - icon: const Icon(Icons.search, size: 16), - label: const Text('查询'), - ), - const SizedBox(width: 8), - OutlinedButton( - onPressed: () => setState(() { - _searchCtrl.clear(); - _statusFilter = '全部'; - _dateRange = null; - }), - child: const Text('重置'), - ), - ], - ), - ), - const Divider(height: 1), - Expanded(child: _buildOrderList()), - ], + rows: orders.isEmpty + ? [ + const DataRow(cells: [ + DataCell(SizedBox()), + DataCell(Text('暂无入库单', + style: TextStyle(color: AppTheme.textSecondary))), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + ]) + ] + : orders + .map((o) => DataRow( + cells: [ + DataCell(Text(o.orderNo, + style: const TextStyle( + color: AppTheme.primary, + fontFamily: 'monospace', + fontSize: 12))), + DataCell(Text(o.partnerName ?? '-')), + DataCell(Text(o.warehouseName ?? '-')), + DataCell(Text(o.totalAmount != null + ? '¥${o.totalAmount!.toStringAsFixed(2)}' + : '-')), + DataCell(StatusBadge( + _apiStatusToEnum(o.status))), + DataCell(Text(o.orderDate?.substring(0, 10) ?? '-')), + DataCell(Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (o.status == 'draft') + TextButton( + onPressed: () => + _confirmSubmit(context, o), + child: const Text('提交', + style: TextStyle( + fontSize: 12, + color: AppTheme.primary)), + ), + if (o.status == 'pending') ...[ + TextButton( + key: Key('btn_approve_${o.id}'), + onPressed: () => + _confirmApprove(context, o), + child: const Text('通过', + style: TextStyle( + fontSize: 12, + color: AppTheme.success)), + ), + TextButton( + key: Key('btn_reject_${o.id}'), + onPressed: () => + _confirmReject(context, o), + child: const Text('拒绝', + style: TextStyle( + fontSize: 12, + color: AppTheme.danger)), + ), + ], + ], + )), + ], + )) + .toList(), ); } @@ -354,7 +231,126 @@ class _StockInListScreenState extends ConsumerState { lastDate: DateTime(2030), initialDateRange: _dateRange, ); - if (range != null) setState(() => _dateRange = range); + if (range != null) { + setState(() => _dateRange = range); + ref + .read(stockInListProvider.notifier) + .setDateRange(_startDate, _endDate); + } + } + + Future _confirmSubmit(BuildContext context, StockInOrder o) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('提交审核'), + content: Text('确认提交入库单「${o.orderNo}」?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('取消')), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('提交')), + ], + ), + ); + if (confirmed == true && mounted) { + try { + await ref + .read(stockInListProvider.notifier) + .submitOrder(o.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('已提交审核'), + backgroundColor: AppTheme.success)); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('提交失败:$e'), + backgroundColor: AppTheme.danger)); + } + } + } + } + + Future _confirmApprove(BuildContext context, StockInOrder o) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('审核确认'), + content: Text('确认审核通过入库单「${o.orderNo}」?审核后将入库并增加库存。'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('取消')), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.success, + foregroundColor: Colors.white), + child: const Text('通过'), + ), + ], + ), + ); + if (confirmed == true && mounted) { + try { + await ref + .read(stockInListProvider.notifier) + .approveOrder(o.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('审核通过'), backgroundColor: AppTheme.success)); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('审核失败:$e'), + backgroundColor: AppTheme.danger)); + } + } + } + } + + Future _confirmReject(BuildContext context, StockInOrder o) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('拒绝确认'), + content: Text('确认拒绝入库单「${o.orderNo}」?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('取消')), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.danger, + foregroundColor: Colors.white), + child: const Text('拒绝'), + ), + ], + ), + ); + if (confirmed == true && mounted) { + try { + await ref + .read(stockInListProvider.notifier) + .rejectOrder(o.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('已拒绝'), backgroundColor: AppTheme.accent)); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('操作失败:$e'), + backgroundColor: AppTheme.danger)); + } + } + } } } @@ -379,15 +375,16 @@ class _StatusFilterDropdown extends StatelessWidget { ), child: DropdownButtonHideUnderline( child: DropdownButton( - value: value, - items: ['全部', '草稿', '待审核', '已审核', '已拒绝'] - .map((s) => DropdownMenuItem( - value: s, - child: Text(s, style: const TextStyle(fontSize: 13)))) - .toList(), + value: value.isEmpty ? '' : value, + items: [ + const DropdownMenuItem(value: '', child: Text('全部状态', style: TextStyle(fontSize: 13))), + const DropdownMenuItem(value: 'draft', child: Text('草稿', style: TextStyle(fontSize: 13))), + const DropdownMenuItem(value: 'pending', child: Text('待审核', style: TextStyle(fontSize: 13))), + const DropdownMenuItem(value: 'approved', child: Text('已审核', style: TextStyle(fontSize: 13))), + const DropdownMenuItem(value: 'rejected', child: Text('已拒绝', style: TextStyle(fontSize: 13))), + ], onChanged: onChanged, - style: const TextStyle( - fontSize: 13, color: AppTheme.textPrimary), + style: const TextStyle(fontSize: 13, color: AppTheme.textPrimary), ), ), ); diff --git a/client/lib/screens/stock_out/stock_out_list_screen.dart b/client/lib/screens/stock_out/stock_out_list_screen.dart index 3bcee6a..f5b56b4 100644 --- a/client/lib/screens/stock_out/stock_out_list_screen.dart +++ b/client/lib/screens/stock_out/stock_out_list_screen.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../widgets/page_scaffold.dart'; -import '../../widgets/data_table_card.dart'; -import '../../widgets/status_badge.dart'; import '../../core/theme/app_theme.dart'; +import '../../models/stock_out.dart'; +import '../../providers/stock_out_provider.dart'; +import '../../widgets/data_table_card.dart'; +import '../../widgets/page_scaffold.dart'; +import '../../widgets/status_badge.dart'; class StockOutListScreen extends ConsumerStatefulWidget { const StockOutListScreen({super.key}); @@ -14,128 +16,28 @@ class StockOutListScreen extends ConsumerStatefulWidget { } class _StockOutListScreenState extends ConsumerState { - int _page = 1; - final _searchCtrl = TextEditingController(); - String _statusFilter = '全部'; - String _typeFilter = '全部'; + String _statusFilter = ''; + DateTimeRange? _dateRange; - final List> _mockOrders = [ - { - 'no': 'CK20260401001', - 'department': '餐饮部', - 'purpose': '宴会用酒', - 'warehouse': '主仓库', - 'amount': '15600.00', - 'status': OrderStatus.approved, - 'creator': '李四', - 'date': '2026-04-01', - 'type': '领用', - }, - { - 'no': 'CK20260401002', - 'department': '客房部', - 'purpose': '客房迷你吧补货', - 'warehouse': '主仓库', - 'amount': '8200.00', - 'status': OrderStatus.approved, - 'creator': '王五', - 'date': '2026-04-01', - 'type': '领用', - }, - { - 'no': 'CK20260402001', - 'department': '行政部', - 'purpose': '接待用酒', - 'warehouse': '副仓库', - 'amount': '32000.00', - 'status': OrderStatus.pending, - 'creator': '张三', - 'date': '2026-04-02', - 'type': '调拨', - }, - { - 'no': 'CK20260402002', - 'department': '餐饮部', - 'purpose': '婚宴用酒', - 'warehouse': '主仓库', - 'amount': '68500.00', - 'status': OrderStatus.approved, - 'creator': '李四', - 'date': '2026-04-02', - 'type': '领用', - }, - { - 'no': 'CK20260403001', - 'department': '采购部', - 'purpose': '退货', - 'warehouse': '副仓库', - 'amount': '12000.00', - 'status': OrderStatus.pending, - 'creator': '王五', - 'date': '2026-04-03', - 'type': '退货', - }, - { - 'no': 'CK20260403002', - 'department': '餐饮部', - 'purpose': '散客用酒', - 'warehouse': '主仓库', - 'amount': '5400.00', - 'status': OrderStatus.draft, - 'creator': '张三', - 'date': '2026-04-03', - 'type': '领用', - }, - { - 'no': 'CK20260404001', - 'department': '客房部', - 'purpose': 'VIP客房补充', - 'warehouse': '主仓库', - 'amount': '18900.00', - 'status': OrderStatus.approved, - 'creator': '李四', - 'date': '2026-04-04', - 'type': '领用', - }, - { - 'no': 'CK20260404002', - 'department': '行政部', - 'purpose': '年度总结宴', - 'warehouse': '主仓库', - 'amount': '45000.00', - 'status': OrderStatus.pending, - 'creator': '王五', - 'date': '2026-04-04', - 'type': '领用', - }, - ]; + String? get _startDate => _dateRange != null + ? '${_dateRange!.start.year}-${_dateRange!.start.month.toString().padLeft(2, '0')}-${_dateRange!.start.day.toString().padLeft(2, '0')}' + : null; - @override - void dispose() { - _searchCtrl.dispose(); - super.dispose(); - } + String? get _endDate => _dateRange != null + ? '${_dateRange!.end.year}-${_dateRange!.end.month.toString().padLeft(2, '0')}-${_dateRange!.end.day.toString().padLeft(2, '0')}' + : null; - List> get _filtered { - return _mockOrders.where((o) { - if (_typeFilter != '全部' && o['type'] != _typeFilter) return false; - if (_statusFilter != '全部') { - final map = { - '草稿': OrderStatus.draft, - '待审核': OrderStatus.pending, - '已审核': OrderStatus.approved, - '已拒绝': OrderStatus.rejected, - }; - if (o['status'] != map[_statusFilter]) return false; - } - final q = _searchCtrl.text.toLowerCase(); - if (q.isNotEmpty) { - final no = (o['no'] as String).toLowerCase(); - final dept = (o['department'] as String).toLowerCase(); - if (!no.contains(q) && !dept.contains(q)) return false; - } - return true; - }).toList(); + OrderStatus _apiStatusToEnum(String status) { + switch (status) { + case 'pending': + return OrderStatus.pending; + case 'approved': + return OrderStatus.approved; + case 'rejected': + return OrderStatus.rejected; + default: + return OrderStatus.draft; + } } @override @@ -144,28 +46,61 @@ class _StockOutListScreenState extends ConsumerState { title: '出库管理', tabs: const [ Tab(text: '出库单'), - Tab(text: '出库查询'), Tab(text: '出库审核'), ], tabViews: [ - _buildList(), - _buildList(), - _buildList(forceStatus: OrderStatus.pending), + _buildListTab(filterStatus: null), + _buildListTab(filterStatus: 'pending'), ], ); } - Widget _buildList({OrderStatus? forceStatus}) { - final orders = forceStatus != null - ? _mockOrders - .where((o) => o['status'] == forceStatus) - .toList() - : _filtered; + Widget _buildListTab({String? filterStatus}) { + final asyncOrders = ref.watch(stockOutListProvider); + return asyncOrders.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('加载失败:$e', + style: const TextStyle(color: AppTheme.danger)), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () => + ref.read(stockOutListProvider.notifier).reload(), + child: const Text('重试'), + ), + ], + ), + ), + data: (result) { + final orders = filterStatus != null + ? result.data + .where((o) => o.status == filterStatus) + .toList() + : result.data; + return _buildOrderTable( + orders: orders, + totalCount: filterStatus != null ? orders.length : result.total, + page: result.page, + showStatusFilter: filterStatus == null, + ); + }, + ); + } + Widget _buildOrderTable({ + required List orders, + required int totalCount, + required int page, + required bool showStatusFilter, + }) { return DataTableCard( - totalCount: orders.length, - page: _page, - onPageChanged: (p) => setState(() => _page = p), + totalCount: totalCount, + page: page, + onPageChanged: (p) => + ref.read(stockOutListProvider.notifier).setPage(p), toolbar: Row( children: [ ElevatedButton.icon( @@ -173,110 +108,136 @@ class _StockOutListScreenState extends ConsumerState { icon: const Icon(Icons.add, size: 16), label: const Text('新建出库单'), ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.file_download_outlined, size: 16), - label: const Text('导出'), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.print_outlined, size: 16), - label: const Text('打印'), - ), const Spacer(), - // Type filter - _DropdownFilter( - value: _typeFilter, - items: ['全部', '领用', '调拨', '退货'], - onChanged: (v) => setState(() => _typeFilter = v!), - hint: '出库类型', - ), - const SizedBox(width: 8), - if (forceStatus == null) - _DropdownFilter( + if (showStatusFilter) ...[ + _StatusFilterDropdown( value: _statusFilter, - items: ['全部', '草稿', '待审核', '已审核', '已拒绝'], - onChanged: (v) => setState(() => _statusFilter = v!), - hint: '状态', + onChanged: (v) { + setState(() => _statusFilter = v ?? ''); + ref + .read(stockOutListProvider.notifier) + .setStatus(v ?? ''); + }, ), - if (forceStatus == null) const SizedBox(width: 8), - SizedBox( - width: 180, - child: TextField( - controller: _searchCtrl, - decoration: const InputDecoration( - hintText: '搜索单号/部门', - prefixIcon: Icon(Icons.search, size: 16), - hintStyle: TextStyle(fontSize: 13), - ), - onChanged: (_) => setState(() => _page = 1), + const SizedBox(width: 8), + ], + OutlinedButton.icon( + onPressed: _pickDateRange, + icon: const Icon(Icons.date_range, size: 16), + label: Text( + _dateRange == null + ? '选择日期' + : '$_startDate ~ $_endDate', + style: const TextStyle(fontSize: 13), ), ), + if (_dateRange != null) ...[ + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.clear, size: 16), + onPressed: () { + setState(() => _dateRange = null); + ref + .read(stockOutListProvider.notifier) + .setDateRange(null, null); + }, + ), + ], ], ), columns: const [ DataColumn(label: Text('出库单号')), - DataColumn(label: Text('出库类型')), - DataColumn(label: Text('领用部门')), - DataColumn(label: Text('用途')), + DataColumn(label: Text('客户/往来单位')), DataColumn(label: Text('仓库')), DataColumn(label: Text('金额'), numeric: true), DataColumn(label: Text('状态')), DataColumn(label: Text('日期')), DataColumn(label: Text('操作')), ], - rows: orders - .map((o) => DataRow(cells: [ - DataCell(Text(o['no'] as String, - style: const TextStyle( - color: AppTheme.primary, - fontFamily: 'monospace', - fontSize: 12))), - DataCell(_TypeBadge(o['type'] as String)), - DataCell(Text(o['department'] as String)), - DataCell(Text(o['purpose'] as String, - overflow: TextOverflow.ellipsis)), - DataCell(Text(o['warehouse'] as String)), - DataCell(Text('¥${o['amount']}')), - DataCell(StatusBadge(o['status'] as OrderStatus)), - DataCell(Text(o['date'] as String)), - DataCell(Row( - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - onPressed: () {}, - child: const Text('查看', - style: TextStyle(fontSize: 12))), - if ((o['status'] as OrderStatus) == - OrderStatus.pending) - TextButton( - onPressed: () {}, - child: const Text('审核', - style: TextStyle( - color: AppTheme.success, - fontSize: 12))), - if ((o['status'] as OrderStatus) == - OrderStatus.draft) ...[ - TextButton( - onPressed: () {}, - child: const Text('编辑', - style: TextStyle(fontSize: 12))), - TextButton( - onPressed: () {}, - child: const Text('删除', - style: TextStyle( - color: AppTheme.danger, - fontSize: 12))), + rows: orders.isEmpty + ? [ + const DataRow(cells: [ + DataCell(SizedBox()), + DataCell(Text('暂无出库单', + style: TextStyle(color: AppTheme.textSecondary))), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + ]) + ] + : orders + .map((o) => DataRow( + cells: [ + DataCell(Text(o.orderNo, + style: const TextStyle( + color: AppTheme.primary, + fontFamily: 'monospace', + fontSize: 12))), + DataCell(Text(o.partnerName ?? '-')), + DataCell(Text(o.warehouseName ?? '-')), + DataCell(Text(o.totalAmount != null + ? '¥${o.totalAmount!.toStringAsFixed(2)}' + : '-')), + DataCell(StatusBadge( + _apiStatusToEnum(o.status))), + DataCell(Text(o.orderDate?.substring(0, 10) ?? '-')), + DataCell(Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (o.status == 'draft') + TextButton( + onPressed: () => + _confirmSubmit(context, o), + child: const Text('提交', + style: TextStyle( + fontSize: 12, + color: AppTheme.primary)), + ), + if (o.status == 'pending') ...[ + TextButton( + key: Key('btn_approve_${o.id}'), + onPressed: () => + _confirmApprove(context, o), + child: const Text('通过', + style: TextStyle( + fontSize: 12, + color: AppTheme.success)), + ), + TextButton( + key: Key('btn_reject_${o.id}'), + onPressed: () => + _confirmReject(context, o), + child: const Text('拒绝', + style: TextStyle( + fontSize: 12, + color: AppTheme.danger)), + ), + ], + ], + )), ], - ], - )), - ])) - .toList(), + )) + .toList(), ); } + Future _pickDateRange() async { + final range = await showDateRangePicker( + context: context, + firstDate: DateTime(2020), + lastDate: DateTime(2030), + initialDateRange: _dateRange, + ); + if (range != null) { + setState(() => _dateRange = range); + ref + .read(stockOutListProvider.notifier) + .setDateRange(_startDate, _endDate); + } + } + void _showCreateDialog(BuildContext context) { showDialog( context: context, @@ -284,63 +245,144 @@ class _StockOutListScreenState extends ConsumerState { title: const Text('新建出库单'), content: const SizedBox( width: 400, - child: Text('出库单创建功能完整实现请参考入库单流程。'), + child: Text('出库单完整创建功能请参考入库单流程。'), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('关闭')), + child: const Text('取消')), ], ), ); } -} -class _TypeBadge extends StatelessWidget { - final String type; - const _TypeBadge(this.type); - - @override - Widget build(BuildContext context) { - final Color color; - switch (type) { - case '领用': - color = AppTheme.primary; - break; - case '调拨': - color = AppTheme.accent; - break; - case '退货': - color = AppTheme.danger; - break; - default: - color = AppTheme.textSecondary; - } - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: color.withOpacity(0.1), - borderRadius: BorderRadius.circular(3), - border: Border.all(color: color.withOpacity(0.3)), + Future _confirmSubmit( + BuildContext context, StockOutOrder o) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('提交审核'), + content: Text('确认提交出库单「${o.orderNo}」?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('取消')), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('提交')), + ], ), - child: Text(type, - style: TextStyle( - color: color, fontSize: 12, fontWeight: FontWeight.w500)), ); + if (confirmed == true && mounted) { + try { + await ref + .read(stockOutListProvider.notifier) + .submitOrder(o.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('已提交审核'), + backgroundColor: AppTheme.success)); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('提交失败:$e'), + backgroundColor: AppTheme.danger)); + } + } + } + } + + Future _confirmApprove( + BuildContext context, StockOutOrder o) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('审核确认'), + content: Text('确认审核通过出库单「${o.orderNo}」?审核后将出库并扣减库存。'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('取消')), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.success, + foregroundColor: Colors.white), + child: const Text('通过'), + ), + ], + ), + ); + if (confirmed == true && mounted) { + try { + await ref + .read(stockOutListProvider.notifier) + .approveOrder(o.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('审核通过'), backgroundColor: AppTheme.success)); + } + } catch (e) { + // Show inventory-shortage error clearly + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('审核失败:$e'), + backgroundColor: AppTheme.danger, + duration: const Duration(seconds: 5))); + } + } + } + } + + Future _confirmReject( + BuildContext context, StockOutOrder o) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('拒绝确认'), + content: Text('确认拒绝出库单「${o.orderNo}」?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('取消')), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.danger, + foregroundColor: Colors.white), + child: const Text('拒绝'), + ), + ], + ), + ); + if (confirmed == true && mounted) { + try { + await ref + .read(stockOutListProvider.notifier) + .rejectOrder(o.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('已拒绝'), backgroundColor: AppTheme.accent)); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('操作失败:$e'), + backgroundColor: AppTheme.danger)); + } + } + } } } -class _DropdownFilter extends StatelessWidget { +class _StatusFilterDropdown extends StatelessWidget { final String value; - final List items; final ValueChanged onChanged; - final String hint; - const _DropdownFilter({ + const _StatusFilterDropdown({ required this.value, - required this.items, required this.onChanged, - required this.hint, }); @override @@ -355,16 +397,16 @@ class _DropdownFilter extends StatelessWidget { ), child: DropdownButtonHideUnderline( child: DropdownButton( - value: value, - hint: Text(hint, style: const TextStyle(fontSize: 13)), - items: items - .map((s) => DropdownMenuItem( - value: s, - child: Text(s, style: const TextStyle(fontSize: 13)))) - .toList(), + value: value.isEmpty ? '' : value, + items: const [ + DropdownMenuItem(value: '', child: Text('全部状态', style: TextStyle(fontSize: 13))), + DropdownMenuItem(value: 'draft', child: Text('草稿', style: TextStyle(fontSize: 13))), + DropdownMenuItem(value: 'pending', child: Text('待审核', style: TextStyle(fontSize: 13))), + DropdownMenuItem(value: 'approved', child: Text('已审核', style: TextStyle(fontSize: 13))), + DropdownMenuItem(value: 'rejected', child: Text('已拒绝', style: TextStyle(fontSize: 13))), + ], onChanged: onChanged, - style: const TextStyle( - fontSize: 13, color: AppTheme.textPrimary), + style: const TextStyle(fontSize: 13, color: AppTheme.textPrimary), ), ), ); diff --git a/client/lib/widgets/data_table_card.dart b/client/lib/widgets/data_table_card.dart index 1b11ca3..e36ff53 100644 --- a/client/lib/widgets/data_table_card.dart +++ b/client/lib/widgets/data_table_card.dart @@ -29,20 +29,8 @@ class DataTableCard extends StatelessWidget { Container( height: 52, color: AppTheme.surface, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - ConstrainedBox( - constraints: BoxConstraints( - minWidth: (MediaQuery.of(context).size.width - 212 - 24).clamp(0.0, double.infinity), - ), - child: toolbar!, - ), - ], - ), - ), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: toolbar!, ), if (toolbar != null) const Divider(height: 1), Expanded( diff --git a/client/test/auth_repository_test.dart b/client/test/auth_repository_test.dart index 4ac22bd..77952be 100644 --- a/client/test/auth_repository_test.dart +++ b/client/test/auth_repository_test.dart @@ -32,7 +32,7 @@ void main() { } }), data: { - 'hotel_code': 'H001', + 'shop_code': 'S001', 'username': 'admin', 'password': 'password123', }, @@ -41,7 +41,7 @@ void main() { // We can't easily swap the internal Dio of PublicApiClient, // so test the parsing logic directly via a helper. final resp = await dio.post('/auth/login', data: { - 'hotel_code': 'H001', + 'shop_code': 'S001', 'username': 'admin', 'password': 'password123', }); @@ -60,7 +60,7 @@ void main() { '/auth/login', (server) => server.reply(401, {'error': 'invalid username or password'}), data: { - 'hotel_code': 'H001', + 'shop_code': 'S001', 'username': 'admin', 'password': 'wrong', }, @@ -68,7 +68,7 @@ void main() { try { await dio.post('/auth/login', data: { - 'hotel_code': 'H001', + 'shop_code': 'S001', 'username': 'admin', 'password': 'wrong', }); @@ -87,7 +87,7 @@ void main() { try { await badDio.post('/auth/login', data: { - 'hotel_code': 'H001', + 'shop_code': 'S001', 'username': 'admin', 'password': 'password123', }); diff --git a/client/test/auth_state_test.dart b/client/test/auth_state_test.dart index 771a337..f725ed8 100644 --- a/client/test/auth_state_test.dart +++ b/client/test/auth_state_test.dart @@ -32,8 +32,8 @@ void main() { 'refresh_token': 'test-refresh-token', 'username': 'admin', 'real_name': '管理员', - 'hotel_no': 'H001', - 'hotel_id': '1', + 'shop_no': 'S001', + 'shop_id': '1', }); await notifier.restore(); @@ -41,7 +41,7 @@ void main() { expect(notifier.state.initialized, true); expect(notifier.state.isLoggedIn, true); expect(notifier.state.user!.username, 'admin'); - expect(notifier.state.user!.hotelNo, 'H001'); + expect(notifier.state.user!.shopNo, 'S001'); expect(notifier.state.user!.accessToken, 'test-access-token'); }); @@ -51,8 +51,8 @@ void main() { refreshToken: 'rt', username: 'admin', realName: '管理员', - hotelNo: 'H001', - hotelId: 1, + shopNo: 'S001', + shopId: 1, ); await notifier.login(user); @@ -70,7 +70,7 @@ void main() { 'access_token': 'token', 'refresh_token': 'rt', 'username': 'admin', - 'login_history_hotels': ['H001'], + 'login_history_hotels': ['S001'], }); await notifier.restore(); expect(notifier.state.isLoggedIn, true); @@ -81,7 +81,7 @@ void main() { final prefs = await SharedPreferences.getInstance(); expect(prefs.getString('access_token'), null); // Login history must NOT be cleared on logout - expect(prefs.getStringList('login_history_hotels'), ['H001']); + expect(prefs.getStringList('login_history_hotels'), ['S001']); }); test('updateAccessToken() updates token without changing other fields', @@ -91,8 +91,8 @@ void main() { refreshToken: 'rt', username: 'admin', realName: '管理员', - hotelNo: 'H001', - hotelId: 1, + shopNo: 'S001', + shopId: 1, )); notifier.updateAccessToken('new-token'); diff --git a/client/test/inventory_repository_test.dart b/client/test/inventory_repository_test.dart new file mode 100644 index 0000000..bdc15b4 --- /dev/null +++ b/client/test/inventory_repository_test.dart @@ -0,0 +1,270 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; +import 'package:jiu_client/core/api/api_client.dart'; +import 'package:jiu_client/core/exceptions.dart'; +import 'package:jiu_client/repositories/inventory_repository.dart'; + +const _baseUrl = 'http://localhost:8080/api/v1'; + +class _TestApiClient extends ApiClient { + final Dio _testDio; + _TestApiClient(this._testDio) : super(token: 'test-token'); + + @override + Future get(String path, {Map? params}) => + _testDio.get(path, queryParameters: params); + + @override + Future post(String path, {dynamic data}) => + _testDio.post(path, data: data); + + @override + Future put(String path, {dynamic data}) => + _testDio.put(path, data: data); + + @override + Future delete(String path) => _testDio.delete(path); +} + +void main() { + late Dio dio; + late DioAdapter adapter; + late InventoryRepository repo; + + setUp(() { + dio = Dio(BaseOptions(baseUrl: _baseUrl)); + adapter = DioAdapter(dio: dio, matcher: const FullHttpRequestMatcher()); + repo = InventoryRepository(_TestApiClient(dio)); + }); + + // --------------------------------------------------------------------------- + // listInventory() + // --------------------------------------------------------------------------- + group('InventoryRepository.listInventory()', () { + test('returns PageResult with inventory items on 200', () async { + adapter.onGet( + '/inventory', + (server) => server.reply(200, { + 'data': [ + { + 'warehouse_id': 1, + 'warehouse_name': '主仓库', + 'product_id': 1, + 'product_name': '五粮液', + 'product_code': 'P001', + 'product_spec': '500ml', + 'product_unit': '瓶', + 'product_brand': '五粮液集团', + 'min_stock': 10, + 'quantity': 50.0, + } + ], + 'total': 1, + 'page': 1, + 'page_size': 50, + }), + queryParameters: {'page': 1, 'page_size': 50}, + ); + + final result = await repo.listInventory(); + + expect(result.total, 1); + expect(result.data.length, 1); + expect(result.data.first.productName, '五粮液'); + expect(result.data.first.quantity, 50.0); + expect(result.data.first.warehouseId, 1); + }); + + test('returns empty list without error', () async { + adapter.onGet( + '/inventory', + (server) => server.reply(200, { + 'data': [], + 'total': 0, + 'page': 1, + 'page_size': 50, + }), + queryParameters: {'page': 1, 'page_size': 50}, + ); + + final result = await repo.listInventory(); + expect(result.data, isEmpty); + expect(result.total, 0); + }); + + test('passes warehouseId and keyword params', () async { + adapter.onGet( + '/inventory', + (server) => server.reply(200, { + 'data': [], + 'total': 0, + 'page': 1, + 'page_size': 50, + }), + queryParameters: { + 'page': 1, + 'page_size': 50, + 'warehouse_id': 1, + 'keyword': '五粮液', + }, + ); + + final result = await repo.listInventory(warehouseId: 1, keyword: '五粮液'); + expect(result.data, isEmpty); + }); + + test('401 throws AppException', () async { + adapter.onGet( + '/inventory', + (server) => server.reply(401, {'error': 'unauthorized'}), + queryParameters: {'page': 1, 'page_size': 50}, + ); + + expect( + () => repo.listInventory(), + throwsA(predicate((e) => e.statusCode == 401)), + ); + }); + + test('400 throws AppException with server error message', () async { + adapter.onGet( + '/inventory', + (server) => server.reply(400, {'error': 'invalid warehouse_id'}), + queryParameters: {'page': 1, 'page_size': 50}, + ); + + expect( + () => repo.listInventory(), + throwsA( + predicate( + (e) => e.message == 'invalid warehouse_id' && e.statusCode == 400, + ), + ), + ); + }); + + test('network timeout throws AppException', () async { + final badDio = Dio(BaseOptions( + baseUrl: 'http://localhost:19999', + connectTimeout: const Duration(milliseconds: 100), + )); + final badRepo = InventoryRepository(_TestApiClient(badDio)); + + try { + await badRepo.listInventory(); + fail('Expected AppException'); + } on AppException catch (e) { + expect(e.message, isNotEmpty); + } + }); + }); + + // --------------------------------------------------------------------------- + // listLogs() + // --------------------------------------------------------------------------- + group('InventoryRepository.listLogs()', () { + test('returns PageResult with inventory logs on 200', () async { + adapter.onGet( + '/inventory/logs', + (server) => server.reply(200, { + 'data': [ + { + 'warehouse_id': 1, + 'warehouse_name': '主仓库', + 'product_id': 1, + 'product_name': '五粮液', + 'direction': 'in', + 'quantity': 20.0, + 'qty_before': 30.0, + 'qty_after': 50.0, + 'ref_type': 'stock_in', + 'ref_id': 5, + 'created_at': '2024-01-10T10:00:00Z', + } + ], + 'total': 1, + 'page': 1, + 'page_size': 50, + }), + queryParameters: {'page': 1, 'page_size': 50}, + ); + + final result = await repo.listLogs(); + + expect(result.total, 1); + expect(result.data.length, 1); + expect(result.data.first.direction, 'in'); + expect(result.data.first.quantity, 20.0); + expect(result.data.first.qtyBefore, 30.0); + expect(result.data.first.qtyAfter, 50.0); + }); + + test('returns empty list without error', () async { + adapter.onGet( + '/inventory/logs', + (server) => server.reply(200, { + 'data': [], + 'total': 0, + 'page': 1, + 'page_size': 50, + }), + queryParameters: {'page': 1, 'page_size': 50}, + ); + + final result = await repo.listLogs(); + expect(result.data, isEmpty); + }); + + test('passes warehouseId and productId params', () async { + adapter.onGet( + '/inventory/logs', + (server) => server.reply(200, { + 'data': [], + 'total': 0, + 'page': 1, + 'page_size': 50, + }), + queryParameters: { + 'page': 1, + 'page_size': 50, + 'warehouse_id': 1, + 'product_id': 1, + }, + ); + + final result = await repo.listLogs(warehouseId: 1, productId: 1); + expect(result.data, isEmpty); + }); + + test('401 throws AppException', () async { + adapter.onGet( + '/inventory/logs', + (server) => server.reply(401, {'error': 'unauthorized'}), + queryParameters: {'page': 1, 'page_size': 50}, + ); + + expect( + () => repo.listLogs(), + throwsA(predicate((e) => e.statusCode == 401)), + ); + }); + + test('400 throws AppException with server error message', () async { + adapter.onGet( + '/inventory/logs', + (server) => server.reply(400, {'error': 'invalid product_id'}), + queryParameters: {'page': 1, 'page_size': 50}, + ); + + expect( + () => repo.listLogs(), + throwsA( + predicate( + (e) => e.message == 'invalid product_id' && e.statusCode == 400, + ), + ), + ); + }); + }); +} diff --git a/client/test/partner_repository_test.dart b/client/test/partner_repository_test.dart new file mode 100644 index 0000000..fdb6a17 --- /dev/null +++ b/client/test/partner_repository_test.dart @@ -0,0 +1,278 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; +import 'package:jiu_client/core/api/api_client.dart'; +import 'package:jiu_client/core/exceptions.dart'; +import 'package:jiu_client/repositories/partner_repository.dart'; + +const _baseUrl = 'http://localhost:8080/api/v1'; + +class _TestApiClient extends ApiClient { + final Dio _testDio; + _TestApiClient(this._testDio) : super(token: 'test-token'); + + @override + Future get(String path, {Map? params}) => + _testDio.get(path, queryParameters: params); + + @override + Future post(String path, {dynamic data}) => + _testDio.post(path, data: data); + + @override + Future put(String path, {dynamic data}) => + _testDio.put(path, data: data); + + @override + Future delete(String path) => _testDio.delete(path); +} + +void main() { + late Dio dio; + late DioAdapter adapter; + late PartnerRepository repo; + + setUp(() { + dio = Dio(BaseOptions(baseUrl: _baseUrl)); + adapter = DioAdapter(dio: dio, matcher: const FullHttpRequestMatcher()); + repo = PartnerRepository(_TestApiClient(dio)); + }); + + // --------------------------------------------------------------------------- + // list() + // --------------------------------------------------------------------------- + group('PartnerRepository.list()', () { + test('returns PageResult with partners on 200', () async { + adapter.onGet( + '/partners', + (server) => server.reply(200, { + 'data': [ + { + 'id': 1, + 'code': 'S001', + 'name': '张家酒水供应商', + 'type': 'supplier', + 'contact': '张三', + 'phone': '13800138000', + 'address': '北京市朝阳区', + 'bank_account': null, + 'remark': null, + } + ], + 'total': 1, + 'page': 1, + 'page_size': 20, + }), + queryParameters: {'page': 1, 'page_size': 20, 'type': 'supplier'}, + ); + + final result = await repo.list(type: 'supplier'); + + expect(result.total, 1); + expect(result.data.length, 1); + expect(result.data.first.name, '张家酒水供应商'); + expect(result.data.first.type, 'supplier'); + }); + + test('returns empty list without error', () async { + adapter.onGet( + '/partners', + (server) => server.reply(200, { + 'data': [], + 'total': 0, + 'page': 1, + 'page_size': 20, + }), + queryParameters: {'page': 1, 'page_size': 20}, + ); + + final result = await repo.list(); + expect(result.data, isEmpty); + expect(result.total, 0); + }); + + test('passes keyword query param', () async { + adapter.onGet( + '/partners', + (server) => server.reply(200, { + 'data': [], + 'total': 0, + 'page': 1, + 'page_size': 20, + }), + queryParameters: { + 'page': 1, + 'page_size': 20, + 'keyword': '茅台', + }, + ); + + final result = await repo.list(keyword: '茅台'); + expect(result.data, isEmpty); + }); + + test('400 response throws AppException with server error message', () async { + adapter.onGet( + '/partners', + (server) => server.reply(400, {'error': 'invalid type'}), + queryParameters: {'page': 1, 'page_size': 20}, + ); + + expect( + () => repo.list(), + throwsA( + predicate( + (e) => e.message == 'invalid type' && e.statusCode == 400, + ), + ), + ); + }); + + test('401 response throws AppException with status 401', () async { + adapter.onGet( + '/partners', + (server) => server.reply(401, {'error': 'unauthorized'}), + queryParameters: {'page': 1, 'page_size': 20}, + ); + + expect( + () => repo.list(), + throwsA( + predicate((e) => e.statusCode == 401), + ), + ); + }); + + test('network timeout throws AppException with fallback message', () async { + final badDio = Dio(BaseOptions( + baseUrl: 'http://localhost:19999', + connectTimeout: const Duration(milliseconds: 100), + )); + final badRepo = PartnerRepository(_TestApiClient(badDio)); + + try { + await badRepo.list(); + fail('Expected AppException'); + } on AppException catch (e) { + expect(e.message, isNotEmpty); + } + }); + }); + + // --------------------------------------------------------------------------- + // create() + // --------------------------------------------------------------------------- + group('PartnerRepository.create()', () { + test('returns created Partner on 201', () async { + final payload = {'name': '李记酒行', 'type': 'supplier'}; + adapter.onPost( + '/partners', + (server) => server.reply(201, { + 'data': { + 'id': 2, + 'code': null, + 'name': '李记酒行', + 'type': 'supplier', + 'contact': null, + 'phone': null, + 'address': null, + 'bank_account': null, + 'remark': null, + } + }), + data: payload, + ); + + final partner = await repo.create(payload); + + expect(partner.id, 2); + expect(partner.name, '李记酒行'); + expect(partner.type, 'supplier'); + }); + + test('400 response throws AppException', () async { + adapter.onPost( + '/partners', + (server) => server.reply(400, {'error': 'name is required'}), + data: {'name': '', 'type': 'supplier'}, + ); + + expect( + () => repo.create({'name': '', 'type': 'supplier'}), + throwsA(isA()), + ); + }); + }); + + // --------------------------------------------------------------------------- + // update() + // --------------------------------------------------------------------------- + group('PartnerRepository.update()', () { + test('returns updated Partner on 200', () async { + final payload = {'name': '张家酒水(新)', 'type': 'supplier'}; + adapter.onPut( + '/partners/1', + (server) => server.reply(200, { + 'data': { + 'id': 1, + 'code': 'S001', + 'name': '张家酒水(新)', + 'type': 'supplier', + 'contact': null, + 'phone': null, + 'address': null, + 'bank_account': null, + 'remark': null, + } + }), + data: payload, + ); + + final partner = await repo.update(1, payload); + + expect(partner.id, 1); + expect(partner.name, '张家酒水(新)'); + }); + + test('404 throws AppException', () async { + adapter.onPut( + '/partners/999', + (server) => server.reply(404, {'error': 'partner not found'}), + data: {'name': 'X', 'type': 'supplier'}, + ); + + expect( + () => repo.update(999, {'name': 'X', 'type': 'supplier'}), + throwsA( + predicate((e) => e.statusCode == 404), + ), + ); + }); + }); + + // --------------------------------------------------------------------------- + // delete() + // --------------------------------------------------------------------------- + group('PartnerRepository.delete()', () { + test('completes without error on 200', () async { + adapter.onDelete( + '/partners/1', + (server) => server.reply(200, {'message': 'deleted'}), + ); + + await expectLater(repo.delete(1), completes); + }); + + test('404 response throws AppException', () async { + adapter.onDelete( + '/partners/999', + (server) => server.reply(404, {'error': 'not found'}), + ); + + expect( + () => repo.delete(999), + throwsA(isA()), + ); + }); + }); +} diff --git a/client/test/partners_screen_test.dart b/client/test/partners_screen_test.dart new file mode 100644 index 0000000..76d6c17 --- /dev/null +++ b/client/test/partners_screen_test.dart @@ -0,0 +1,335 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jiu_client/core/models/page_result.dart'; +import 'package:jiu_client/models/partner.dart'; +import 'package:jiu_client/providers/partner_provider.dart'; +import 'package:jiu_client/screens/partners/partners_screen.dart'; + +// --------------------------------------------------------------------------- +// Fake notifier +// --------------------------------------------------------------------------- + +class _FakePartnerNotifier extends PartnerListNotifier { + final AsyncValue> _fixed; + + _FakePartnerNotifier(String type, this._fixed) : super(type: type); + + @override + Future> build() async { + state = _fixed; + return state.value ?? + const PageResult(data: [], total: 0, page: 1, pageSize: 20); + } + + @override + void reload() {} + + @override + void setKeyword(String keyword) {} + + @override + void setPage(int page) {} + + @override + Future createPartner(Map data) async {} + + @override + Future updatePartner(int id, Map data) async {} + + @override + Future deletePartner(int id) async {} +} + +/// A notifier that stays permanently in loading state (build never completes). +class _FakeLoadingPartnerNotifier extends PartnerListNotifier { + _FakeLoadingPartnerNotifier(String type) : super(type: type); + + @override + Future> build() { + // Never completes — keeps the widget in CircularProgressIndicator state. + return Completer>().future; + } + + @override + void reload() {} + + @override + void setKeyword(String keyword) {} + + @override + void setPage(int page) {} + + @override + Future createPartner(Map data) async {} + + @override + Future updatePartner(int id, Map data) async {} + + @override + Future deletePartner(int id) async {} +} + +Widget _buildApp({ + AsyncValue> supplierState = + const AsyncValue.data(PageResult(data: [], total: 0, page: 1, pageSize: 20)), + AsyncValue> customerState = + const AsyncValue.data(PageResult(data: [], total: 0, page: 1, pageSize: 20)), +}) { + return ProviderScope( + overrides: [ + supplierListProvider.overrideWith( + () => _FakePartnerNotifier('supplier', supplierState), + ), + customerListProvider.overrideWith( + () => _FakePartnerNotifier('customer', customerState), + ), + ], + child: const MaterialApp(home: Scaffold(body: PartnersScreen())), + ); +} + +Widget _buildLoadingApp() { + return ProviderScope( + overrides: [ + supplierListProvider.overrideWith( + () => _FakeLoadingPartnerNotifier('supplier'), + ), + customerListProvider.overrideWith( + () => _FakeLoadingPartnerNotifier('customer'), + ), + ], + child: const MaterialApp(home: Scaffold(body: PartnersScreen())), + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +void main() { + group('PartnersScreen - Supplier tab', () { + testWidgets('shows CircularProgressIndicator while loading', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget(_buildLoadingApp()); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('shows error message on failure', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget( + _buildApp( + supplierState: AsyncValue.error('网络错误', StackTrace.empty), + ), + ); + await tester.pump(); + + expect(find.textContaining('加载失败'), findsOneWidget); + expect(find.text('重试'), findsOneWidget); + }); + + testWidgets('shows 暂无供应商 when supplier list is empty', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget(_buildApp()); + await tester.pump(); + + expect(find.text('暂无供应商'), findsOneWidget); + }); + + testWidgets('shows supplier list with name and contact', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + const partners = [ + Partner( + id: 1, + code: 'S001', + name: '张家酒水供应商', + type: 'supplier', + contact: '张三', + phone: '13800138000', + ), + ]; + + await tester.pumpWidget(_buildApp( + supplierState: AsyncValue.data( + const PageResult(data: partners, total: 1, page: 1, pageSize: 20), + ), + )); + await tester.pump(); + + expect(find.text('张家酒水供应商'), findsOneWidget); + expect(find.text('张三'), findsOneWidget); + }); + + testWidgets('shows 新建 button in toolbar', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget(_buildApp()); + await tester.pump(); + + expect(find.text('新建'), findsOneWidget); + }); + + testWidgets('tapping 新建 opens 新建供应商 dialog', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget(_buildApp()); + await tester.pump(); + + await tester.tap(find.text('新建')); + await tester.pumpAndSettle(); + + expect(find.text('新建供应商'), findsOneWidget); + expect(find.text('供应商名称'), findsAtLeastNWidgets(1)); + }); + + testWidgets('supplier form shows 不能为空 when saving with empty name', + (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget(_buildApp()); + await tester.pump(); + + await tester.tap(find.text('新建')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('保存')); + await tester.pump(); + + expect(find.text('不能为空'), findsAtLeastNWidgets(1)); + }); + + testWidgets('shows delete confirmation when delete button tapped', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + const partners = [ + Partner(id: 1, name: '张家酒水供应商', type: 'supplier'), + ]; + + await tester.pumpWidget(_buildApp( + supplierState: AsyncValue.data( + const PageResult(data: partners, total: 1, page: 1, pageSize: 20), + ), + )); + await tester.pump(); + + await tester.tap(find.byKey(const Key('btn_delete_1'))); + await tester.pumpAndSettle(); + + expect(find.text('确认删除'), findsOneWidget); + expect(find.textContaining('张家酒水供应商'), findsAtLeastNWidgets(1)); + expect(find.text('取消'), findsOneWidget); + // 删除 appears in both the list row action button and the dialog confirm button. + expect(find.text('删除'), findsAtLeastNWidgets(1)); + }); + + testWidgets('tapping edit button opens prefilled edit dialog', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + const partners = [ + Partner( + id: 1, + code: 'S001', + name: '张家酒水供应商', + type: 'supplier', + contact: '张三', + ), + ]; + + await tester.pumpWidget(_buildApp( + supplierState: AsyncValue.data( + const PageResult(data: partners, total: 1, page: 1, pageSize: 20), + ), + )); + await tester.pump(); + + await tester.tap(find.byKey(const Key('btn_edit_1'))); + await tester.pumpAndSettle(); + + expect(find.text('编辑供应商'), findsOneWidget); + expect(find.widgetWithText(TextFormField, '张家酒水供应商'), findsOneWidget); + }); + }); + + group('PartnersScreen - Customer tab', () { + testWidgets('shows 暂无客户 when customer list is empty', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget(_buildApp()); + await tester.pump(); + + // Switch to 客户 tab + await tester.tap(find.text('客户')); + await tester.pumpAndSettle(); + + expect(find.text('暂无客户'), findsOneWidget); + }); + + testWidgets('shows customer names in list', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + const customers = [ + Partner(id: 10, name: '王记饭店', type: 'customer', phone: '13900139000'), + ]; + + await tester.pumpWidget(_buildApp( + customerState: AsyncValue.data( + const PageResult(data: customers, total: 1, page: 1, pageSize: 20), + ), + )); + await tester.pump(); + + // Switch to customer tab + await tester.tap(find.text('客户')); + await tester.pumpAndSettle(); + + expect(find.text('王记饭店'), findsOneWidget); + }); + + testWidgets('opens 新建客户 dialog from customer tab', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget(_buildApp()); + await tester.pump(); + + await tester.tap(find.text('客户')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('新建')); + await tester.pumpAndSettle(); + + expect(find.text('新建客户'), findsOneWidget); + expect(find.text('客户名称'), findsAtLeastNWidgets(1)); + }); + }); +} diff --git a/client/test/product_repository_test.dart b/client/test/product_repository_test.dart new file mode 100644 index 0000000..247ff37 --- /dev/null +++ b/client/test/product_repository_test.dart @@ -0,0 +1,312 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; +import 'package:jiu_client/core/api/api_client.dart'; +import 'package:jiu_client/core/exceptions.dart'; +import 'package:jiu_client/repositories/product_repository.dart'; + +const _baseUrl = 'http://localhost:8080/api/v1'; + +ApiClient _buildClient(Dio dio) { + // We inject the Dio directly by creating ApiClient and swapping it out + // via a subclass-free approach: wrap with a test double. + return _TestApiClient(dio); +} + +/// A thin ApiClient subclass that delegates to an injected Dio. +class _TestApiClient extends ApiClient { + final Dio _testDio; + + _TestApiClient(this._testDio) : super(token: 'test-token'); + + @override + Future get(String path, {Map? params}) => + _testDio.get(path, queryParameters: params); + + @override + Future post(String path, {dynamic data}) => + _testDio.post(path, data: data); + + @override + Future put(String path, {dynamic data}) => + _testDio.put(path, data: data); + + @override + Future delete(String path) => _testDio.delete(path); +} + +void main() { + late Dio dio; + late DioAdapter adapter; + late ProductRepository repo; + + setUp(() { + dio = Dio(BaseOptions(baseUrl: _baseUrl)); + adapter = DioAdapter(dio: dio, matcher: const FullHttpRequestMatcher()); + repo = ProductRepository(_buildClient(dio)); + }); + + // --------------------------------------------------------------------------- + // list() + // --------------------------------------------------------------------------- + group('ProductRepository.list()', () { + test('returns PageResult with products on 200', () async { + adapter.onGet( + '/products', + (server) => server.reply(200, { + 'data': [ + { + 'id': 1, + 'code': 'P001', + 'name': '五粮液', + 'unit': '瓶', + 'barcode': null, + 'series': null, + 'spec': '500ml', + 'category_id': null, + 'brand': '五粮液集团', + 'purchase_price': 500.0, + 'sale_price': 680.0, + 'min_stock': 10, + 'remark': null, + 'custom_fields': null, + } + ], + 'total': 1, + 'page': 1, + 'page_size': 20, + }), + queryParameters: {'page': 1, 'page_size': 20}, + ); + + final result = await repo.list(); + + expect(result.total, 1); + expect(result.data.length, 1); + expect(result.data.first.name, '五粮液'); + expect(result.data.first.code, 'P001'); + expect(result.data.first.purchasePrice, 500.0); + }); + + test('returns empty list without error when data array is empty', () async { + adapter.onGet( + '/products', + (server) => server.reply(200, { + 'data': [], + 'total': 0, + 'page': 1, + 'page_size': 20, + }), + queryParameters: {'page': 1, 'page_size': 20}, + ); + + final result = await repo.list(); + + expect(result.data, isEmpty); + expect(result.total, 0); + }); + + test('passes keyword query param when provided', () async { + adapter.onGet( + '/products', + (server) => server.reply(200, { + 'data': [], + 'total': 0, + 'page': 1, + 'page_size': 20, + }), + queryParameters: { + 'page': 1, + 'page_size': 20, + 'keyword': '茅台', + }, + ); + + final result = await repo.list(keyword: '茅台'); + expect(result.data, isEmpty); + }); + + test('400 response throws AppException with server error message', () async { + adapter.onGet( + '/products', + (server) => server.reply(400, {'error': 'invalid page parameter'}), + queryParameters: {'page': 1, 'page_size': 20}, + ); + + expect( + () => repo.list(), + throwsA( + predicate( + (e) => + e is AppException && + e.message == 'invalid page parameter' && + e.statusCode == 400, + ), + ), + ); + }); + + test('401 response throws AppException with status 401', () async { + adapter.onGet( + '/products', + (server) => server.reply(401, {'error': 'unauthorized'}), + queryParameters: {'page': 1, 'page_size': 20}, + ); + + expect( + () => repo.list(), + throwsA( + predicate( + (e) => e is AppException && e.statusCode == 401, + ), + ), + ); + }); + + test('network timeout throws AppException with fallback message', () async { + final badDio = Dio(BaseOptions( + baseUrl: 'http://localhost:19999', + connectTimeout: const Duration(milliseconds: 100), + )); + final badRepo = ProductRepository(_buildClient(badDio)); + + try { + await badRepo.list(); + fail('Expected AppException'); + } on AppException catch (e) { + expect(e.message, isNotEmpty); + } + }); + }); + + // --------------------------------------------------------------------------- + // create() + // --------------------------------------------------------------------------- + group('ProductRepository.create()', () { + test('returns created Product on 201', () async { + final payload = {'name': '茅台', 'code': 'MT001', 'unit': '瓶'}; + adapter.onPost( + '/products', + (server) => server.reply(201, { + 'data': { + 'id': 2, + 'code': 'MT001', + 'name': '茅台', + 'unit': '瓶', + 'barcode': null, + 'series': null, + 'spec': null, + 'category_id': null, + 'brand': null, + 'purchase_price': null, + 'sale_price': null, + 'min_stock': null, + 'remark': null, + 'custom_fields': null, + } + }), + data: payload, + ); + + final product = await repo.create(payload); + + expect(product.id, 2); + expect(product.name, '茅台'); + expect(product.code, 'MT001'); + }); + + test('400 response throws AppException', () async { + adapter.onPost( + '/products', + (server) => server.reply(400, {'error': 'code already exists'}), + data: {'name': '茅台', 'code': 'MT001', 'unit': '瓶'}, + ); + + expect( + () => repo.create({'name': '茅台', 'code': 'MT001', 'unit': '瓶'}), + throwsA( + predicate( + (e) => e.message == 'code already exists' && e.statusCode == 400, + ), + ), + ); + }); + }); + + // --------------------------------------------------------------------------- + // update() + // --------------------------------------------------------------------------- + group('ProductRepository.update()', () { + test('returns updated Product on 200', () async { + final payload = {'name': '茅台(升级)', 'code': 'MT001', 'unit': '瓶'}; + adapter.onPut( + '/products/2', + (server) => server.reply(200, { + 'data': { + 'id': 2, + 'code': 'MT001', + 'name': '茅台(升级)', + 'unit': '瓶', + 'barcode': null, + 'series': null, + 'spec': null, + 'category_id': null, + 'brand': null, + 'purchase_price': null, + 'sale_price': null, + 'min_stock': null, + 'remark': null, + 'custom_fields': null, + } + }), + data: payload, + ); + + final product = await repo.update(2, payload); + + expect(product.id, 2); + expect(product.name, '茅台(升级)'); + }); + + test('404 response throws AppException with status 404', () async { + adapter.onPut( + '/products/999', + (server) => server.reply(404, {'error': 'product not found'}), + data: {'name': 'X', 'code': 'X', 'unit': '个'}, + ); + + expect( + () => repo.update(999, {'name': 'X', 'code': 'X', 'unit': '个'}), + throwsA( + predicate((e) => e.statusCode == 404), + ), + ); + }); + }); + + // --------------------------------------------------------------------------- + // delete() + // --------------------------------------------------------------------------- + group('ProductRepository.delete()', () { + test('completes without error on 200', () async { + adapter.onDelete( + '/products/1', + (server) => server.reply(200, {'message': 'deleted'}), + ); + + await expectLater(repo.delete(1), completes); + }); + + test('404 response throws AppException', () async { + adapter.onDelete( + '/products/999', + (server) => server.reply(404, {'error': 'product not found'}), + ); + + expect( + () => repo.delete(999), + throwsA(isA()), + ); + }); + }); +} diff --git a/client/test/products_screen_test.dart b/client/test/products_screen_test.dart new file mode 100644 index 0000000..8568418 --- /dev/null +++ b/client/test/products_screen_test.dart @@ -0,0 +1,312 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jiu_client/core/models/page_result.dart'; +import 'package:jiu_client/models/product.dart'; +import 'package:jiu_client/providers/product_provider.dart'; +import 'package:jiu_client/screens/products/products_screen.dart'; + +// --------------------------------------------------------------------------- +// Fake notifier helpers +// --------------------------------------------------------------------------- + +/// A notifier that immediately emits a fixed state. +class _FakeProductNotifier extends ProductListNotifier { + final AsyncValue> _fixed; + _FakeProductNotifier(this._fixed); + + @override + Future> build() async { + state = _fixed; + return state.value ?? const PageResult(data: [], total: 0, page: 1, pageSize: 20); + } + + @override + void reload() {} + + @override + void setKeyword(String keyword) {} + + @override + void setPage(int page) {} + + @override + Future createProduct(Map data) async {} + + @override + Future updateProduct(int id, Map data) async {} + + @override + Future deleteProduct(int id) async {} +} + +/// A notifier that stays permanently in loading state (build never completes). +class _FakeLoadingProductNotifier extends ProductListNotifier { + @override + Future> build() { + // Never completes — keeps the widget in CircularProgressIndicator state. + return Completer>().future; + } + + @override + void reload() {} + + @override + void setKeyword(String keyword) {} + + @override + void setPage(int page) {} + + @override + Future createProduct(Map data) async {} + + @override + Future updateProduct(int id, Map data) async {} + + @override + Future deleteProduct(int id) async {} +} + +/// Helper to build a testable app wrapping [ProductsScreen]. +Widget _buildApp(AsyncValue> state) { + return ProviderScope( + overrides: [ + productListProvider.overrideWith(() => _FakeProductNotifier(state)), + ], + child: const MaterialApp(home: Scaffold(body: ProductsScreen())), + ); +} + +Widget _buildLoadingApp() { + return ProviderScope( + overrides: [ + productListProvider.overrideWith(() => _FakeLoadingProductNotifier()), + ], + child: const MaterialApp(home: Scaffold(body: ProductsScreen())), + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +void main() { + testWidgets('shows CircularProgressIndicator while loading', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget(_buildLoadingApp()); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('shows error message on load failure', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget( + _buildApp(AsyncValue.error('网络连接失败', StackTrace.empty)), + ); + await tester.pump(); + + expect(find.textContaining('加载失败'), findsOneWidget); + expect(find.text('重试'), findsOneWidget); + }); + + testWidgets('shows "暂无商品" when list is empty', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + const emptyResult = PageResult( + data: [], + total: 0, + page: 1, + pageSize: 20, + ); + + await tester.pumpWidget(_buildApp(const AsyncValue.data(emptyResult))); + await tester.pump(); + + expect(find.text('暂无商品'), findsOneWidget); + }); + + testWidgets('shows product list with name, code and unit', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + const products = [ + Product(id: 1, code: 'P001', name: '五粮液', unit: '瓶'), + Product(id: 2, code: 'P002', name: '茅台', unit: '瓶'), + ]; + + final result = PageResult( + data: products, + total: 2, + page: 1, + pageSize: 20, + ); + + await tester.pumpWidget(_buildApp(AsyncValue.data(result))); + await tester.pump(); + + expect(find.text('五粮液'), findsOneWidget); + expect(find.text('茅台'), findsOneWidget); + expect(find.text('P001'), findsOneWidget); + expect(find.text('P002'), findsOneWidget); + }); + + testWidgets('shows 新建 button in toolbar', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + const emptyResult = PageResult( + data: [], + total: 0, + page: 1, + pageSize: 20, + ); + + await tester.pumpWidget(_buildApp(const AsyncValue.data(emptyResult))); + await tester.pump(); + + expect(find.text('新建'), findsOneWidget); + }); + + testWidgets('tapping 新建 opens product form dialog', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + const emptyResult = PageResult( + data: [], + total: 0, + page: 1, + pageSize: 20, + ); + + await tester.pumpWidget(_buildApp(const AsyncValue.data(emptyResult))); + await tester.pump(); + + await tester.tap(find.text('新建')); + await tester.pumpAndSettle(); + + // Dialog should appear with "新建商品" title + expect(find.text('新建商品'), findsOneWidget); + // Form should have 商品名称 and 商品编码 fields + expect(find.text('商品名称'), findsAtLeastNWidgets(1)); + expect(find.text('商品编码'), findsAtLeastNWidgets(1)); + }); + + testWidgets('product form shows "不能为空" when saving with empty name', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + const emptyResult = PageResult( + data: [], + total: 0, + page: 1, + pageSize: 20, + ); + + await tester.pumpWidget(_buildApp(const AsyncValue.data(emptyResult))); + await tester.pump(); + + await tester.tap(find.text('新建')); + await tester.pumpAndSettle(); + + // Tap save without filling required fields + await tester.tap(find.text('保存')); + await tester.pump(); + + expect(find.text('不能为空'), findsAtLeastNWidgets(1)); + }); + + testWidgets('shows delete confirmation dialog when delete button tapped', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + const products = [ + Product(id: 1, code: 'P001', name: '五粮液', unit: '瓶'), + ]; + + final result = PageResult( + data: products, + total: 1, + page: 1, + pageSize: 20, + ); + + await tester.pumpWidget(_buildApp(AsyncValue.data(result))); + await tester.pump(); + + await tester.tap(find.byKey(const Key('btn_delete_1'))); + await tester.pumpAndSettle(); + + expect(find.text('确认删除'), findsOneWidget); + expect(find.textContaining('五粮液'), findsAtLeastNWidgets(1)); + // Confirm dialog has 取消 and 删除 buttons — 删除 may appear in both the + // list row action button and the dialog, so use findsAtLeastNWidgets(1). + expect(find.text('取消'), findsOneWidget); + expect(find.text('删除'), findsAtLeastNWidgets(1)); + }); + + testWidgets('tapping edit button opens edit dialog with prefilled values', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + const products = [ + Product(id: 1, code: 'P001', name: '五粮液', unit: '瓶', brand: '五粮液集团'), + ]; + + final result = PageResult( + data: products, + total: 1, + page: 1, + pageSize: 20, + ); + + await tester.pumpWidget(_buildApp(AsyncValue.data(result))); + await tester.pump(); + + await tester.tap(find.byKey(const Key('btn_edit_1'))); + await tester.pumpAndSettle(); + + // Edit dialog title + expect(find.text('编辑商品'), findsOneWidget); + // Fields should be prefilled + expect(find.widgetWithText(TextFormField, '五粮液'), findsOneWidget); + expect(find.widgetWithText(TextFormField, 'P001'), findsOneWidget); + }); + + testWidgets('shows total count in pagination bar', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + const products = [ + Product(id: 1, code: 'P001', name: '五粮液', unit: '瓶'), + ]; + + final result = PageResult( + data: products, + total: 42, + page: 1, + pageSize: 20, + ); + + await tester.pumpWidget(_buildApp(AsyncValue.data(result))); + await tester.pump(); + + expect(find.textContaining('42'), findsAtLeastNWidgets(1)); + }); +} diff --git a/client/test/stock_in_repository_test.dart b/client/test/stock_in_repository_test.dart new file mode 100644 index 0000000..cf4aa0c --- /dev/null +++ b/client/test/stock_in_repository_test.dart @@ -0,0 +1,317 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; +import 'package:jiu_client/core/api/api_client.dart'; +import 'package:jiu_client/core/exceptions.dart'; +import 'package:jiu_client/repositories/stock_in_repository.dart'; + +const _baseUrl = 'http://localhost:8080/api/v1'; + +class _TestApiClient extends ApiClient { + final Dio _testDio; + _TestApiClient(this._testDio) : super(token: 'test-token'); + + @override + Future get(String path, {Map? params}) => + _testDio.get(path, queryParameters: params); + + @override + Future post(String path, {dynamic data}) => + _testDio.post(path, data: data); + + @override + Future put(String path, {dynamic data}) => + _testDio.put(path, data: data); + + @override + Future delete(String path) => _testDio.delete(path); +} + +/// Minimal stock-in order JSON fixture. +Map _orderJson({ + int id = 1, + String orderNo = 'SI2024010001', + String status = 'draft', +}) => + { + 'id': id, + 'order_no': orderNo, + 'type': 'purchase', + 'warehouse_id': 1, + 'warehouse_name': '主仓库', + 'partner_id': 1, + 'partner_name': '张家酒水', + 'operator_id': null, + 'status': status, + 'order_date': '2024-01-01', + 'total_amount': 1000.0, + 'remark': null, + 'items': [], + }; + +void main() { + late Dio dio; + late DioAdapter adapter; + late StockInRepository repo; + + setUp(() { + dio = Dio(BaseOptions(baseUrl: _baseUrl)); + adapter = DioAdapter(dio: dio, matcher: const FullHttpRequestMatcher()); + repo = StockInRepository(_TestApiClient(dio)); + }); + + // --------------------------------------------------------------------------- + // list() + // --------------------------------------------------------------------------- + group('StockInRepository.list()', () { + test('returns PageResult with orders on 200', () async { + adapter.onGet( + '/stock-in/orders', + (server) => server.reply(200, { + 'data': [_orderJson()], + 'total': 1, + 'page': 1, + 'page_size': 20, + }), + queryParameters: {'page': 1, 'page_size': 20}, + ); + + final result = await repo.list(); + + expect(result.total, 1); + expect(result.data.length, 1); + expect(result.data.first.orderNo, 'SI2024010001'); + expect(result.data.first.status, 'draft'); + }); + + test('returns empty list without error', () async { + adapter.onGet( + '/stock-in/orders', + (server) => server.reply(200, { + 'data': [], + 'total': 0, + 'page': 1, + 'page_size': 20, + }), + queryParameters: {'page': 1, 'page_size': 20}, + ); + + final result = await repo.list(); + expect(result.data, isEmpty); + }); + + test('passes status and date params when provided', () async { + adapter.onGet( + '/stock-in/orders', + (server) => server.reply(200, { + 'data': [], + 'total': 0, + 'page': 1, + 'page_size': 20, + }), + queryParameters: { + 'page': 1, + 'page_size': 20, + 'status': 'pending', + 'start_date': '2024-01-01', + 'end_date': '2024-01-31', + }, + ); + + final result = await repo.list( + status: 'pending', + startDate: '2024-01-01', + endDate: '2024-01-31', + ); + expect(result.data, isEmpty); + }); + + test('401 response throws AppException', () async { + adapter.onGet( + '/stock-in/orders', + (server) => server.reply(401, {'error': 'unauthorized'}), + queryParameters: {'page': 1, 'page_size': 20}, + ); + + expect( + () => repo.list(), + throwsA( + predicate((e) => e.statusCode == 401), + ), + ); + }); + + test('400 response throws AppException with error message', () async { + adapter.onGet( + '/stock-in/orders', + (server) => server.reply(400, {'error': 'invalid date format'}), + queryParameters: {'page': 1, 'page_size': 20}, + ); + + expect( + () => repo.list(), + throwsA( + predicate( + (e) => e.message == 'invalid date format' && e.statusCode == 400, + ), + ), + ); + }); + + test('network timeout throws AppException with fallback message', () async { + final badDio = Dio(BaseOptions( + baseUrl: 'http://localhost:19999', + connectTimeout: const Duration(milliseconds: 100), + )); + final badRepo = StockInRepository(_TestApiClient(badDio)); + + try { + await badRepo.list(); + fail('Expected AppException'); + } on AppException catch (e) { + expect(e.message, isNotEmpty); + } + }); + }); + + // --------------------------------------------------------------------------- + // get() + // --------------------------------------------------------------------------- + group('StockInRepository.get()', () { + test('returns order detail on 200', () async { + adapter.onGet( + '/stock-in/orders/1', + (server) => server.reply(200, {'data': _orderJson(id: 1)}), + ); + + final order = await repo.get(1); + + expect(order.id, 1); + expect(order.orderNo, 'SI2024010001'); + }); + + test('404 throws AppException with status 404', () async { + adapter.onGet( + '/stock-in/orders/999', + (server) => server.reply(404, {'error': 'order not found'}), + ); + + expect( + () => repo.get(999), + throwsA( + predicate((e) => e.statusCode == 404), + ), + ); + }); + }); + + // --------------------------------------------------------------------------- + // create() + // --------------------------------------------------------------------------- + group('StockInRepository.create()', () { + test('returns created order on 201', () async { + final payload = { + 'warehouse_id': 1, + 'partner_id': 1, + 'items': [ + {'product_id': 1, 'quantity': 10.0, 'unit_price': 100.0, 'total_price': 1000.0} + ] + }; + adapter.onPost( + '/stock-in/orders', + (server) => server.reply(201, {'data': _orderJson(id: 5)}), + data: payload, + ); + + final order = await repo.create(payload); + + expect(order.id, 5); + expect(order.warehouseId, 1); + }); + + test('400 response throws AppException', () async { + adapter.onPost( + '/stock-in/orders', + (server) => server.reply(400, {'error': 'warehouse_id is required'}), + data: {'items': []}, + ); + + expect( + () => repo.create({'items': []}), + throwsA(isA()), + ); + }); + }); + + // --------------------------------------------------------------------------- + // submit() / approve() / reject() + // --------------------------------------------------------------------------- + group('StockInRepository workflow actions', () { + test('submit completes without error on 200', () async { + adapter.onPut( + '/stock-in/orders/1/submit', + (server) => server.reply(200, {'message': 'submitted'}), + ); + + await expectLater(repo.submit(1), completes); + }); + + test('submit 400 throws AppException', () async { + adapter.onPut( + '/stock-in/orders/1/submit', + (server) => server.reply(400, {'error': 'already submitted'}), + ); + + expect( + () => repo.submit(1), + throwsA( + predicate( + (e) => e.message == 'already submitted' && e.statusCode == 400, + ), + ), + ); + }); + + test('approve completes without error on 200', () async { + adapter.onPut( + '/stock-in/orders/1/approve', + (server) => server.reply(200, {'message': 'approved'}), + ); + + await expectLater(repo.approve(1), completes); + }); + + test('approve 400 throws AppException', () async { + adapter.onPut( + '/stock-in/orders/1/approve', + (server) => server.reply(400, {'error': 'cannot approve draft order'}), + ); + + expect( + () => repo.approve(1), + throwsA(isA()), + ); + }); + + test('reject completes without error on 200', () async { + adapter.onPut( + '/stock-in/orders/1/reject', + (server) => server.reply(200, {'message': 'rejected'}), + ); + + await expectLater(repo.reject(1), completes); + }); + + test('reject 400 throws AppException', () async { + adapter.onPut( + '/stock-in/orders/1/reject', + (server) => server.reply(400, {'error': 'cannot reject approved order'}), + ); + + expect( + () => repo.reject(1), + throwsA(isA()), + ); + }); + }); +} diff --git a/client/test/stock_in_screen_test.dart b/client/test/stock_in_screen_test.dart new file mode 100644 index 0000000..9981068 --- /dev/null +++ b/client/test/stock_in_screen_test.dart @@ -0,0 +1,371 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:jiu_client/core/models/page_result.dart'; +import 'package:jiu_client/models/stock_in.dart'; +import 'package:jiu_client/providers/stock_in_provider.dart'; +import 'package:jiu_client/screens/stock_in/stock_in_list_screen.dart'; + +// --------------------------------------------------------------------------- +// Fake notifier +// --------------------------------------------------------------------------- + +class _FakeStockInNotifier extends StockInListNotifier { + final AsyncValue> _fixed; + + _FakeStockInNotifier(this._fixed); + + @override + Future> build() async { + state = _fixed; + return state.value ?? + const PageResult(data: [], total: 0, page: 1, pageSize: 20); + } + + @override + void reload() {} + + @override + void setPage(int page) {} + + @override + void setStatus(String status) {} + + @override + void setDateRange(String? startDate, String? endDate) {} + + @override + Future createOrder(Map data) async {} + + @override + Future submitOrder(int id) async {} + + @override + Future approveOrder(int id) async {} + + @override + Future rejectOrder(int id) async {} +} + +/// A notifier that stays permanently in loading state (build never completes). +class _FakeLoadingStockInNotifier extends StockInListNotifier { + @override + Future> build() { + // Never completes — keeps the widget in CircularProgressIndicator state. + return Completer>().future; + } + + @override + void reload() {} + + @override + void setPage(int page) {} + + @override + void setStatus(String status) {} + + @override + void setDateRange(String? startDate, String? endDate) {} + + @override + Future createOrder(Map data) async {} + + @override + Future submitOrder(int id) async {} + + @override + Future approveOrder(int id) async {} + + @override + Future rejectOrder(int id) async {} +} + +/// Build a testable app. GoRouter is needed because the screen uses +/// `context.go('/stock-in/new')`. +Widget _buildApp(AsyncValue> state) { + final router = GoRouter( + initialLocation: '/stock-in', + routes: [ + GoRoute( + path: '/stock-in', + builder: (_, __) => const Scaffold(body: StockInListScreen()), + ), + GoRoute( + path: '/stock-in/new', + builder: (_, __) => const Scaffold(body: Text('new order form')), + ), + ], + ); + + return ProviderScope( + overrides: [ + stockInListProvider.overrideWith(() => _FakeStockInNotifier(state)), + ], + child: MaterialApp.router(routerConfig: router), + ); +} + +Widget _buildLoadingApp() { + final router = GoRouter( + initialLocation: '/stock-in', + routes: [ + GoRoute( + path: '/stock-in', + builder: (_, __) => const Scaffold(body: StockInListScreen()), + ), + GoRoute( + path: '/stock-in/new', + builder: (_, __) => const Scaffold(body: Text('new order form')), + ), + ], + ); + + return ProviderScope( + overrides: [ + stockInListProvider.overrideWith(() => _FakeLoadingStockInNotifier()), + ], + child: MaterialApp.router(routerConfig: router), + ); +} + +StockInOrder _makeOrder({ + int id = 1, + String orderNo = 'SI2024010001', + String status = 'draft', + String? partnerName = '张家酒水', + String? warehouseName = '主仓库', +}) => + StockInOrder( + id: id, + orderNo: orderNo, + warehouseId: 1, + warehouseName: warehouseName, + partnerName: partnerName, + status: status, + totalAmount: 1000.0, + orderDate: '2024-01-10', + ); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +void main() { + group('StockInListScreen', () { + testWidgets('shows CircularProgressIndicator while loading', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget(_buildLoadingApp()); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('shows error message and retry button on failure', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget( + _buildApp(AsyncValue.error('连接超时', StackTrace.empty)), + ); + await tester.pump(); + + expect(find.textContaining('加载失败'), findsOneWidget); + expect(find.text('重试'), findsOneWidget); + }); + + testWidgets('shows 暂无入库单 when list is empty', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + const empty = + PageResult(data: [], total: 0, page: 1, pageSize: 20); + + await tester.pumpWidget(_buildApp(const AsyncValue.data(empty))); + await tester.pump(); + + expect(find.text('暂无入库单'), findsOneWidget); + }); + + testWidgets('shows order list with order number and supplier', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + final orders = [ + _makeOrder(id: 1, orderNo: 'SI2024010001', partnerName: '张家酒水'), + _makeOrder(id: 2, orderNo: 'SI2024010002', partnerName: '李记酒行'), + ]; + + final result = + PageResult(data: orders, total: 2, page: 1, pageSize: 20); + + await tester.pumpWidget(_buildApp(AsyncValue.data(result))); + await tester.pump(); + + expect(find.text('SI2024010001'), findsOneWidget); + expect(find.text('SI2024010002'), findsOneWidget); + expect(find.text('张家酒水'), findsOneWidget); + expect(find.text('李记酒行'), findsOneWidget); + }); + + testWidgets('shows 新建入库单 button in toolbar', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + const empty = + PageResult(data: [], total: 0, page: 1, pageSize: 20); + + await tester.pumpWidget(_buildApp(const AsyncValue.data(empty))); + await tester.pump(); + + expect(find.text('新建入库单'), findsOneWidget); + }); + + testWidgets('tapping 新建入库单 navigates to new order route', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + const empty = + PageResult(data: [], total: 0, page: 1, pageSize: 20); + + await tester.pumpWidget(_buildApp(const AsyncValue.data(empty))); + await tester.pump(); + + await tester.tap(find.text('新建入库单')); + await tester.pumpAndSettle(); + + expect(find.text('new order form'), findsOneWidget); + }); + + testWidgets('draft order shows 提交 button', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + final orders = [_makeOrder(id: 1, status: 'draft')]; + final result = + PageResult(data: orders, total: 1, page: 1, pageSize: 20); + + await tester.pumpWidget(_buildApp(AsyncValue.data(result))); + await tester.pump(); + + expect(find.text('提交'), findsOneWidget); + }); + + testWidgets('pending order shows 通过 and 拒绝 buttons', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + final orders = [_makeOrder(id: 1, status: 'pending')]; + final result = + PageResult(data: orders, total: 1, page: 1, pageSize: 20); + + await tester.pumpWidget(_buildApp(AsyncValue.data(result))); + await tester.pump(); + + expect(find.byKey(const Key('btn_approve_1')), findsOneWidget); + expect(find.byKey(const Key('btn_reject_1')), findsOneWidget); + }); + + testWidgets('approved order shows no action buttons', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + final orders = [_makeOrder(id: 1, status: 'approved')]; + final result = + PageResult(data: orders, total: 1, page: 1, pageSize: 20); + + await tester.pumpWidget(_buildApp(AsyncValue.data(result))); + await tester.pump(); + + expect(find.byKey(const Key('btn_approve_1')), findsNothing); + expect(find.byKey(const Key('btn_reject_1')), findsNothing); + expect(find.text('提交'), findsNothing); + }); + + testWidgets('tapping approve shows confirmation dialog', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + final orders = [ + _makeOrder(id: 1, status: 'pending', orderNo: 'SI2024010001') + ]; + final result = + PageResult(data: orders, total: 1, page: 1, pageSize: 20); + + await tester.pumpWidget(_buildApp(AsyncValue.data(result))); + await tester.pump(); + + await tester.tap(find.byKey(const Key('btn_approve_1'))); + await tester.pumpAndSettle(); + + expect(find.text('审核确认'), findsOneWidget); + expect(find.textContaining('SI2024010001'), findsAtLeastNWidgets(1)); + expect(find.text('通过'), findsAtLeastNWidgets(1)); + }); + + testWidgets('tapping reject shows confirmation dialog', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + final orders = [ + _makeOrder(id: 1, status: 'pending', orderNo: 'SI2024010001') + ]; + final result = + PageResult(data: orders, total: 1, page: 1, pageSize: 20); + + await tester.pumpWidget(_buildApp(AsyncValue.data(result))); + await tester.pump(); + + await tester.tap(find.byKey(const Key('btn_reject_1'))); + await tester.pumpAndSettle(); + + expect(find.text('拒绝确认'), findsOneWidget); + expect(find.textContaining('SI2024010001'), findsAtLeastNWidgets(1)); + expect(find.text('拒绝'), findsAtLeastNWidgets(1)); + }); + + testWidgets('shows status filter dropdown in first tab toolbar', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + const empty = + PageResult(data: [], total: 0, page: 1, pageSize: 20); + + await tester.pumpWidget(_buildApp(const AsyncValue.data(empty))); + await tester.pump(); + + // The dropdown shows 全部状态 by default + expect(find.text('全部状态'), findsOneWidget); + }); + + testWidgets('shows total record count in pagination bar', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + final orders = [_makeOrder(id: 1)]; + final result = + PageResult(data: orders, total: 35, page: 1, pageSize: 20); + + await tester.pumpWidget(_buildApp(AsyncValue.data(result))); + await tester.pump(); + + expect(find.textContaining('35'), findsAtLeastNWidgets(1)); + }); + }); +} diff --git a/client/test/stock_out_repository_test.dart b/client/test/stock_out_repository_test.dart new file mode 100644 index 0000000..f55b4f7 --- /dev/null +++ b/client/test/stock_out_repository_test.dart @@ -0,0 +1,315 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; +import 'package:jiu_client/core/api/api_client.dart'; +import 'package:jiu_client/core/exceptions.dart'; +import 'package:jiu_client/repositories/stock_out_repository.dart'; + +const _baseUrl = 'http://localhost:8080/api/v1'; + +class _TestApiClient extends ApiClient { + final Dio _testDio; + _TestApiClient(this._testDio) : super(token: 'test-token'); + + @override + Future get(String path, {Map? params}) => + _testDio.get(path, queryParameters: params); + + @override + Future post(String path, {dynamic data}) => + _testDio.post(path, data: data); + + @override + Future put(String path, {dynamic data}) => + _testDio.put(path, data: data); + + @override + Future delete(String path) => _testDio.delete(path); +} + +Map _orderJson({ + int id = 1, + String orderNo = 'SO2024010001', + String status = 'draft', +}) => + { + 'id': id, + 'order_no': orderNo, + 'type': 'sale', + 'warehouse_id': 1, + 'warehouse_name': '主仓库', + 'partner_id': 2, + 'partner_name': '王记饭店', + 'operator_id': null, + 'status': status, + 'order_date': '2024-01-05', + 'total_amount': 2000.0, + 'remark': null, + 'items': [], + }; + +void main() { + late Dio dio; + late DioAdapter adapter; + late StockOutRepository repo; + + setUp(() { + dio = Dio(BaseOptions(baseUrl: _baseUrl)); + adapter = DioAdapter(dio: dio, matcher: const FullHttpRequestMatcher()); + repo = StockOutRepository(_TestApiClient(dio)); + }); + + // --------------------------------------------------------------------------- + // list() + // --------------------------------------------------------------------------- + group('StockOutRepository.list()', () { + test('returns PageResult with orders on 200', () async { + adapter.onGet( + '/stock-out/orders', + (server) => server.reply(200, { + 'data': [_orderJson()], + 'total': 1, + 'page': 1, + 'page_size': 20, + }), + queryParameters: {'page': 1, 'page_size': 20}, + ); + + final result = await repo.list(); + + expect(result.total, 1); + expect(result.data.length, 1); + expect(result.data.first.orderNo, 'SO2024010001'); + expect(result.data.first.status, 'draft'); + }); + + test('returns empty list without error', () async { + adapter.onGet( + '/stock-out/orders', + (server) => server.reply(200, { + 'data': [], + 'total': 0, + 'page': 1, + 'page_size': 20, + }), + queryParameters: {'page': 1, 'page_size': 20}, + ); + + final result = await repo.list(); + expect(result.data, isEmpty); + }); + + test('passes status and date params when provided', () async { + adapter.onGet( + '/stock-out/orders', + (server) => server.reply(200, { + 'data': [], + 'total': 0, + 'page': 1, + 'page_size': 20, + }), + queryParameters: { + 'page': 1, + 'page_size': 20, + 'status': 'approved', + 'start_date': '2024-01-01', + 'end_date': '2024-01-31', + }, + ); + + final result = await repo.list( + status: 'approved', + startDate: '2024-01-01', + endDate: '2024-01-31', + ); + expect(result.data, isEmpty); + }); + + test('401 throws AppException', () async { + adapter.onGet( + '/stock-out/orders', + (server) => server.reply(401, {'error': 'unauthorized'}), + queryParameters: {'page': 1, 'page_size': 20}, + ); + + expect( + () => repo.list(), + throwsA(predicate((e) => e.statusCode == 401)), + ); + }); + + test('400 throws AppException with error message', () async { + adapter.onGet( + '/stock-out/orders', + (server) => server.reply(400, {'error': 'invalid filter'}), + queryParameters: {'page': 1, 'page_size': 20}, + ); + + expect( + () => repo.list(), + throwsA( + predicate( + (e) => e.message == 'invalid filter' && e.statusCode == 400, + ), + ), + ); + }); + + test('network timeout throws AppException', () async { + final badDio = Dio(BaseOptions( + baseUrl: 'http://localhost:19999', + connectTimeout: const Duration(milliseconds: 100), + )); + final badRepo = StockOutRepository(_TestApiClient(badDio)); + + try { + await badRepo.list(); + fail('Expected AppException'); + } on AppException catch (e) { + expect(e.message, isNotEmpty); + } + }); + }); + + // --------------------------------------------------------------------------- + // get() + // --------------------------------------------------------------------------- + group('StockOutRepository.get()', () { + test('returns order detail on 200', () async { + adapter.onGet( + '/stock-out/orders/1', + (server) => server.reply(200, {'data': _orderJson(id: 1)}), + ); + + final order = await repo.get(1); + + expect(order.id, 1); + expect(order.orderNo, 'SO2024010001'); + }); + + test('404 throws AppException', () async { + adapter.onGet( + '/stock-out/orders/999', + (server) => server.reply(404, {'error': 'order not found'}), + ); + + expect( + () => repo.get(999), + throwsA(predicate((e) => e.statusCode == 404)), + ); + }); + }); + + // --------------------------------------------------------------------------- + // create() + // --------------------------------------------------------------------------- + group('StockOutRepository.create()', () { + test('returns created order on 201', () async { + final payload = { + 'warehouse_id': 1, + 'partner_id': 2, + 'items': [ + {'product_id': 1, 'quantity': 5.0, 'unit_price': 200.0, 'total_price': 1000.0} + ] + }; + adapter.onPost( + '/stock-out/orders', + (server) => server.reply(201, {'data': _orderJson(id: 10)}), + data: payload, + ); + + final order = await repo.create(payload); + + expect(order.id, 10); + }); + + test('400 response throws AppException', () async { + adapter.onPost( + '/stock-out/orders', + (server) => server.reply(400, {'error': 'insufficient stock'}), + data: {'warehouse_id': 1, 'items': []}, + ); + + expect( + () => repo.create({'warehouse_id': 1, 'items': []}), + throwsA(isA()), + ); + }); + }); + + // --------------------------------------------------------------------------- + // submit() / approve() / reject() + // --------------------------------------------------------------------------- + group('StockOutRepository workflow actions', () { + test('submit completes without error on 200', () async { + adapter.onPut( + '/stock-out/orders/1/submit', + (server) => server.reply(200, {'message': 'submitted'}), + ); + + await expectLater(repo.submit(1), completes); + }); + + test('submit 400 throws AppException', () async { + adapter.onPut( + '/stock-out/orders/1/submit', + (server) => server.reply(400, {'error': 'already submitted'}), + ); + + expect( + () => repo.submit(1), + throwsA( + predicate( + (e) => e.message == 'already submitted' && e.statusCode == 400, + ), + ), + ); + }); + + test('approve completes without error on 200', () async { + adapter.onPut( + '/stock-out/orders/1/approve', + (server) => server.reply(200, {'message': 'approved'}), + ); + + await expectLater(repo.approve(1), completes); + }); + + test('approve 400 (insufficient stock) throws AppException', () async { + adapter.onPut( + '/stock-out/orders/1/approve', + (server) => server.reply(400, {'error': 'insufficient stock for product 茅台'}), + ); + + expect( + () => repo.approve(1), + throwsA( + predicate( + (e) => e.message.contains('insufficient stock') && e.statusCode == 400, + ), + ), + ); + }); + + test('reject completes without error on 200', () async { + adapter.onPut( + '/stock-out/orders/1/reject', + (server) => server.reply(200, {'message': 'rejected'}), + ); + + await expectLater(repo.reject(1), completes); + }); + + test('reject 400 throws AppException', () async { + adapter.onPut( + '/stock-out/orders/1/reject', + (server) => server.reply(400, {'error': 'cannot reject approved order'}), + ); + + expect( + () => repo.reject(1), + throwsA(isA()), + ); + }); + }); +} diff --git a/client/test/warehouse_repository_test.dart b/client/test/warehouse_repository_test.dart new file mode 100644 index 0000000..4261bad --- /dev/null +++ b/client/test/warehouse_repository_test.dart @@ -0,0 +1,227 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; +import 'package:jiu_client/core/api/api_client.dart'; +import 'package:jiu_client/core/exceptions.dart'; +import 'package:jiu_client/repositories/warehouse_repository.dart'; + +const _baseUrl = 'http://localhost:8080/api/v1'; + +class _TestApiClient extends ApiClient { + final Dio _testDio; + _TestApiClient(this._testDio) : super(token: 'test-token'); + + @override + Future get(String path, {Map? params}) => + _testDio.get(path, queryParameters: params); + + @override + Future post(String path, {dynamic data}) => + _testDio.post(path, data: data); + + @override + Future put(String path, {dynamic data}) => + _testDio.put(path, data: data); + + @override + Future delete(String path) => _testDio.delete(path); +} + +void main() { + late Dio dio; + late DioAdapter adapter; + late WarehouseRepository repo; + + setUp(() { + dio = Dio(BaseOptions(baseUrl: _baseUrl)); + adapter = DioAdapter(dio: dio, matcher: const FullHttpRequestMatcher()); + repo = WarehouseRepository(_TestApiClient(dio)); + }); + + // --------------------------------------------------------------------------- + // list() + // --------------------------------------------------------------------------- + group('WarehouseRepository.list()', () { + test('returns list of warehouses on 200', () async { + adapter.onGet( + '/warehouses', + (server) => server.reply(200, { + 'data': [ + {'id': 1, 'name': '主仓库', 'location': '一楼', 'is_default': true}, + {'id': 2, 'name': '冷藏仓', 'location': '地下室', 'is_default': false}, + ] + }), + ); + + final list = await repo.list(); + + expect(list.length, 2); + expect(list.first.name, '主仓库'); + expect(list.first.isDefault, true); + expect(list[1].name, '冷藏仓'); + expect(list[1].isDefault, false); + }); + + test('returns empty list without error when data is empty', () async { + adapter.onGet( + '/warehouses', + (server) => server.reply(200, {'data': []}), + ); + + final list = await repo.list(); + expect(list, isEmpty); + }); + + test('401 response throws AppException with status 401', () async { + adapter.onGet( + '/warehouses', + (server) => server.reply(401, {'error': 'unauthorized'}), + ); + + expect( + () => repo.list(), + throwsA( + predicate((e) => e.statusCode == 401), + ), + ); + }); + + test('400 response throws AppException with server error message', () async { + adapter.onGet( + '/warehouses', + (server) => server.reply(400, {'error': 'bad request'}), + ); + + expect( + () => repo.list(), + throwsA( + predicate( + (e) => e.message == 'bad request' && e.statusCode == 400, + ), + ), + ); + }); + + test('network error throws AppException with fallback message', () async { + final badDio = Dio(BaseOptions( + baseUrl: 'http://localhost:19999', + connectTimeout: const Duration(milliseconds: 100), + )); + final badRepo = WarehouseRepository(_TestApiClient(badDio)); + + try { + await badRepo.list(); + fail('Expected AppException'); + } on AppException catch (e) { + expect(e.message, isNotEmpty); + } + }); + }); + + // --------------------------------------------------------------------------- + // create() + // --------------------------------------------------------------------------- + group('WarehouseRepository.create()', () { + test('returns created Warehouse on 201', () async { + final payload = {'name': '新仓库', 'location': '二楼', 'is_default': false}; + adapter.onPost( + '/warehouses', + (server) => server.reply(201, { + 'data': { + 'id': 3, + 'name': '新仓库', + 'location': '二楼', + 'is_default': false, + } + }), + data: payload, + ); + + final warehouse = await repo.create(payload); + + expect(warehouse.id, 3); + expect(warehouse.name, '新仓库'); + expect(warehouse.isDefault, false); + }); + + test('400 response throws AppException', () async { + adapter.onPost( + '/warehouses', + (server) => server.reply(400, {'error': 'name is required'}), + data: {'name': '', 'is_default': false}, + ); + + expect( + () => repo.create({'name': '', 'is_default': false}), + throwsA(isA()), + ); + }); + }); + + // --------------------------------------------------------------------------- + // update() + // --------------------------------------------------------------------------- + group('WarehouseRepository.update()', () { + test('returns updated Warehouse on 200', () async { + final payload = {'name': '主仓库(改)', 'is_default': true}; + adapter.onPut( + '/warehouses/1', + (server) => server.reply(200, { + 'data': { + 'id': 1, + 'name': '主仓库(改)', + 'location': '一楼', + 'is_default': true, + } + }), + data: payload, + ); + + final warehouse = await repo.update(1, payload); + + expect(warehouse.id, 1); + expect(warehouse.name, '主仓库(改)'); + }); + + test('404 throws AppException', () async { + adapter.onPut( + '/warehouses/999', + (server) => server.reply(404, {'error': 'not found'}), + data: {'name': 'X'}, + ); + + expect( + () => repo.update(999, {'name': 'X'}), + throwsA( + predicate((e) => e.statusCode == 404), + ), + ); + }); + }); + + // --------------------------------------------------------------------------- + // delete() + // --------------------------------------------------------------------------- + group('WarehouseRepository.delete()', () { + test('completes without error on 200', () async { + adapter.onDelete( + '/warehouses/1', + (server) => server.reply(200, {'message': 'deleted'}), + ); + + await expectLater(repo.delete(1), completes); + }); + + test('404 response throws AppException', () async { + adapter.onDelete( + '/warehouses/999', + (server) => server.reply(404, {'error': 'not found'}), + ); + + expect( + () => repo.delete(999), + throwsA(isA()), + ); + }); + }); +} diff --git a/scripts/.logs/backend.hash b/scripts/.logs/backend.hash new file mode 100644 index 0000000..9bd3ed6 --- /dev/null +++ b/scripts/.logs/backend.hash @@ -0,0 +1 @@ +d24579c900c93402148db8060972a2177db2dbb3 diff --git a/scripts/.logs/backend.pid b/scripts/.logs/backend.pid new file mode 100644 index 0000000..750a0a2 --- /dev/null +++ b/scripts/.logs/backend.pid @@ -0,0 +1 @@ +13163 diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..6dd9e75 --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# 如果用 sh 调用,自动切换到 bash +if [ -z "$BASH_VERSION" ]; then + exec bash "$0" "$@" +fi +# dev.sh — 本地开发环境管理 +# +# 启动: +# bash scripts/dev.sh run # 启动前后端(后端无改动则跳过重启) +# bash scripts/dev.sh run --force # 强制重启后端 +# bash scripts/dev.sh --backend-only # 仅启动后端 +# bash scripts/dev.sh --frontend-only # 仅启动前端 +# +# 数据库: +# bash scripts/dev.sh reset # 删表重建 + 写入测试数据 +# bash scripts/dev.sh seed # 写入测试数据(已存在则跳过) +# bash scripts/dev.sh clear # 清空所有数据(不写入) + +set -e + +export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:$PATH" + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BACKEND_DIR="$ROOT/backend" +CLIENT_DIR="$ROOT/client" +LOG_DIR="$ROOT/scripts/.logs" +BACKEND_LOG="$LOG_DIR/backend.log" +BACKEND_PID_FILE="$LOG_DIR/backend.pid" +BACKEND_HASH_FILE="$LOG_DIR/backend.hash" + +mkdir -p "$LOG_DIR" + +# ── 颜色输出 ────────────────────────────────────────────── +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +CYAN='\033[0;36m' +NC='\033[0m' + +info() { echo -e "${CYAN}[dev]${NC} $*"; } +success() { echo -e "${GREEN}[dev]${NC} $*"; } +warn() { echo -e "${YELLOW}[dev]${NC} $*"; } +error() { echo -e "${RED}[dev]${NC} $*"; } + +# ── 参数解析 ────────────────────────────────────────────── +COMMAND="${1:-run}" +FORCE=false +RUN_BACKEND=true +RUN_FRONTEND=true + +case "$COMMAND" in + run) + [ "${2:-}" = "--force" ] && FORCE=true + ;; + seed|reset|clear) + # seed / reset / clear 三个命令 + SEED_ARG="" + [ "$COMMAND" = "reset" ] && SEED_ARG="--reset" + [ "$COMMAND" = "clear" ] && SEED_ARG="--clear" + [ -n "${2:-}" ] && SEED_ARG="$2" + info "写入初始测试数据..." + cd "$BACKEND_DIR" + go run cmd/seed/main.go $SEED_ARG + exit 0 + ;; + --backend-only) + COMMAND=run + RUN_FRONTEND=false + [ "${2:-}" = "--force" ] && FORCE=true + ;; + --frontend-only) + COMMAND=run + RUN_BACKEND=false + ;; + *) + error "未知命令: $COMMAND" + echo "用法: bash scripts/dev.sh [run [--force] | reset | clear | seed | --backend-only | --frontend-only]" + exit 1 + ;; +esac + +# ── 清理函数 ────────────────────────────────────────────── +cleanup() { + echo "" + info "正在关闭服务..." + # 不杀后端:run 模式下后端是持久进程,Ctrl+C 只退出 flutter + info "后端继续在后台运行。如需停止:bash scripts/dev.sh stop" + exit 0 +} +trap cleanup INT TERM + +# ── 依赖检查 ────────────────────────────────────────────── +check_deps() { + local missing=0 + if [ "$RUN_BACKEND" = true ] && ! command -v go &>/dev/null; then + error "未找到 go,请确认 PATH 包含 Homebrew bin" + missing=1 + fi + if [ "$RUN_FRONTEND" = true ] && ! command -v flutter &>/dev/null; then + error "未找到 flutter,请安装 Flutter SDK" + missing=1 + fi + [ "$missing" -eq 1 ] && exit 1 + return 0 +} + +# ── 计算后端源码 hash ────────────────────────────────────── +backend_hash() { + # 对所有 .go 文件 + go.mod 内容做 hash + find "$BACKEND_DIR" -name "*.go" -o -name "go.mod" -o -name "go.sum" \ + | sort \ + | xargs shasum 2>/dev/null \ + | shasum \ + | awk '{print $1}' +} + +# ── 检查后端进程是否还活着 ─────────────────────────────── +backend_running() { + if [ ! -f "$BACKEND_PID_FILE" ]; then return 1; fi + local pid + pid=$(cat "$BACKEND_PID_FILE") + kill -0 "$pid" 2>/dev/null +} + +# ── 杀掉占用 8080 的进程 ────────────────────────────────── +kill_port_8080() { + local pids + pids=$(lsof -ti:8080 2>/dev/null || true) + if [ -n "$pids" ]; then + warn "端口 8080 被占用,正在清理..." + echo "$pids" | xargs kill -9 2>/dev/null || true + sleep 1 + success "端口 8080 已释放" + fi +} + +# ── 等待后端就绪 ────────────────────────────────────────── +wait_backend() { + info "等待后端启动..." + local i=0 + while [ $i -lt 30 ]; do + if curl -s -o /dev/null http://localhost:8080/api/v1/auth/login 2>/dev/null; then + success "后端已就绪 → http://localhost:8080" + return 0 + fi + sleep 1 + i=$((i + 1)) + done + warn "后端启动超时,请查看日志:tail -f $BACKEND_LOG" +} + +# ── 启动后端 ────────────────────────────────────────────── +start_backend() { + local current_hash + current_hash=$(backend_hash) + local saved_hash="" + [ -f "$BACKEND_HASH_FILE" ] && saved_hash=$(cat "$BACKEND_HASH_FILE") + + # 判断是否需要重启 + if [ "$FORCE" = false ] && backend_running && [ "$current_hash" = "$saved_hash" ]; then + local pid + pid=$(cat "$BACKEND_PID_FILE") + success "后端无改动,跳过重启 (PID $pid) → http://localhost:8080" + return 0 + fi + + if backend_running; then + local pid + pid=$(cat "$BACKEND_PID_FILE") + warn "后端代码已变更,重启中 (旧 PID $pid)..." + kill "$pid" 2>/dev/null || true + sleep 1 + fi + + kill_port_8080 + + info "编译并启动后端 (Go)..." + cd "$BACKEND_DIR" + go run main.go >>"$BACKEND_LOG" 2>&1 & + local new_pid=$! + echo "$new_pid" > "$BACKEND_PID_FILE" + echo "$current_hash" > "$BACKEND_HASH_FILE" + info "后端进程 PID=$new_pid,日志:tail -f $BACKEND_LOG" + wait_backend +} + +# ════════════════════════════════════════════════════════ +check_deps + +if [ "$RUN_BACKEND" = true ]; then + start_backend +fi + +if [ "$RUN_FRONTEND" = true ]; then + info "启动 Flutter (macOS)..." + cd "$CLIENT_DIR" + flutter pub get + echo "" + success "Flutter 启动中,窗口将自动弹出..." + echo -e "${CYAN}提示:关闭 Flutter 窗口或按 q 退出前端,后端继续运行${NC}" + echo -e "${CYAN} 再次执行 bash scripts/dev.sh run 可复用已有后端${NC}" + echo "" + flutter run -d macos +else + echo "" + success "后端运行中,按 Ctrl+C 退出此脚本(后端仍在后台)" + echo -e " 查看日志:tail -f $BACKEND_LOG" + wait "$(cat "$BACKEND_PID_FILE")" 2>/dev/null || true +fi diff --git a/ui/Weixin Image_20260403212930_23_261.jpg b/ui/Weixin Image_20260403212930_23_261.jpg new file mode 100644 index 0000000..1ae58b5 Binary files /dev/null and b/ui/Weixin Image_20260403212930_23_261.jpg differ diff --git a/ui/Weixin Image_20260403212930_24_261.jpg b/ui/Weixin Image_20260403212930_24_261.jpg new file mode 100644 index 0000000..601ea21 Binary files /dev/null and b/ui/Weixin Image_20260403212930_24_261.jpg differ diff --git a/ui/Weixin Image_20260403212931_25_261.jpg b/ui/Weixin Image_20260403212931_25_261.jpg new file mode 100644 index 0000000..79bc01d Binary files /dev/null and b/ui/Weixin Image_20260403212931_25_261.jpg differ diff --git a/ui/Weixin Image_20260403212932_26_261.jpg b/ui/Weixin Image_20260403212932_26_261.jpg new file mode 100644 index 0000000..f5f8795 Binary files /dev/null and b/ui/Weixin Image_20260403212932_26_261.jpg differ diff --git a/ui/Weixin Image_20260403212933_27_261.jpg b/ui/Weixin Image_20260403212933_27_261.jpg new file mode 100644 index 0000000..47de0e1 Binary files /dev/null and b/ui/Weixin Image_20260403212933_27_261.jpg differ diff --git a/ui/Weixin Image_20260403212934_28_261.jpg b/ui/Weixin Image_20260403212934_28_261.jpg new file mode 100644 index 0000000..ea48a32 Binary files /dev/null and b/ui/Weixin Image_20260403212934_28_261.jpg differ diff --git a/ui/Weixin Image_20260403212935_29_261.jpg b/ui/Weixin Image_20260403212935_29_261.jpg new file mode 100644 index 0000000..35a8030 Binary files /dev/null and b/ui/Weixin Image_20260403212935_29_261.jpg differ diff --git a/ui/Weixin Image_20260403212936_30_261.jpg b/ui/Weixin Image_20260403212936_30_261.jpg new file mode 100644 index 0000000..c037fd3 Binary files /dev/null and b/ui/Weixin Image_20260403212936_30_261.jpg differ diff --git a/ui/Weixin Image_20260403212936_31_261.jpg b/ui/Weixin Image_20260403212936_31_261.jpg new file mode 100644 index 0000000..afd467e Binary files /dev/null and b/ui/Weixin Image_20260403212936_31_261.jpg differ diff --git a/ui/Weixin Image_20260403212937_32_261.jpg b/ui/Weixin Image_20260403212937_32_261.jpg new file mode 100644 index 0000000..845d1e3 Binary files /dev/null and b/ui/Weixin Image_20260403212937_32_261.jpg differ diff --git a/ui/Weixin Image_20260403212938_33_261.jpg b/ui/Weixin Image_20260403212938_33_261.jpg new file mode 100644 index 0000000..32810b5 Binary files /dev/null and b/ui/Weixin Image_20260403212938_33_261.jpg differ diff --git a/ui/Weixin Image_20260403212939_34_261.jpg b/ui/Weixin Image_20260403212939_34_261.jpg new file mode 100644 index 0000000..4b377ca Binary files /dev/null and b/ui/Weixin Image_20260403212939_34_261.jpg differ diff --git a/ui/Weixin Image_20260403212940_35_261.jpg b/ui/Weixin Image_20260403212940_35_261.jpg new file mode 100644 index 0000000..615512d Binary files /dev/null and b/ui/Weixin Image_20260403212940_35_261.jpg differ diff --git a/ui/Weixin Image_20260403212941_36_261.jpg b/ui/Weixin Image_20260403212941_36_261.jpg new file mode 100644 index 0000000..c81ff75 Binary files /dev/null and b/ui/Weixin Image_20260403212941_36_261.jpg differ diff --git a/ui/Weixin Image_20260403212942_37_261.jpg b/ui/Weixin Image_20260403212942_37_261.jpg new file mode 100644 index 0000000..2f525dc Binary files /dev/null and b/ui/Weixin Image_20260403212942_37_261.jpg differ diff --git a/ui/Weixin Image_20260403212943_38_261.jpg b/ui/Weixin Image_20260403212943_38_261.jpg new file mode 100644 index 0000000..7e87eb3 Binary files /dev/null and b/ui/Weixin Image_20260403212943_38_261.jpg differ diff --git a/ui/Weixin Image_20260403212943_39_261.jpg b/ui/Weixin Image_20260403212943_39_261.jpg new file mode 100644 index 0000000..b90506a Binary files /dev/null and b/ui/Weixin Image_20260403212943_39_261.jpg differ diff --git a/ui/Weixin Image_20260403212944_40_261.jpg b/ui/Weixin Image_20260403212944_40_261.jpg new file mode 100644 index 0000000..d607eff Binary files /dev/null and b/ui/Weixin Image_20260403212944_40_261.jpg differ diff --git a/ui/Weixin Image_20260403212945_41_261.jpg b/ui/Weixin Image_20260403212945_41_261.jpg new file mode 100644 index 0000000..b2ee752 Binary files /dev/null and b/ui/Weixin Image_20260403212945_41_261.jpg differ diff --git a/ui/Weixin Image_20260403212946_42_261.jpg b/ui/Weixin Image_20260403212946_42_261.jpg new file mode 100644 index 0000000..d7dca5f Binary files /dev/null and b/ui/Weixin Image_20260403212946_42_261.jpg differ diff --git a/ui/Weixin Image_20260403212947_43_261.jpg b/ui/Weixin Image_20260403212947_43_261.jpg new file mode 100644 index 0000000..ecfce93 Binary files /dev/null and b/ui/Weixin Image_20260403212947_43_261.jpg differ