diff --git a/client/lib/core/api/api_client.dart b/client/lib/core/api/api_client.dart new file mode 100644 index 0000000..0df216f --- /dev/null +++ b/client/lib/core/api/api_client.dart @@ -0,0 +1,35 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../auth/auth_state.dart'; + +final apiClientProvider = Provider((ref) { + final authState = ref.watch(authStateProvider); + return ApiClient(token: authState.user?.token); +}); + +class ApiClient { + late final Dio _dio; + + ApiClient({String? token}) { + _dio = Dio(BaseOptions( + baseUrl: 'http://localhost:8080/api/v1', + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + headers: { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }, + )); + } + + Future get(String path, {Map? params}) => + _dio.get(path, queryParameters: params); + + Future post(String path, {dynamic data}) => + _dio.post(path, data: data); + + Future put(String path, {dynamic data}) => + _dio.put(path, data: data); + + Future delete(String path) => _dio.delete(path); +} diff --git a/client/lib/core/auth/auth_state.dart b/client/lib/core/auth/auth_state.dart new file mode 100644 index 0000000..7d0a4ce --- /dev/null +++ b/client/lib/core/auth/auth_state.dart @@ -0,0 +1,34 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class AuthUser { + final String token; + final String username; + final String hotelName; + final String hotelNo; + final int hotelId; + + const AuthUser({ + required this.token, + required this.username, + required this.hotelName, + required this.hotelNo, + required this.hotelId, + }); +} + +class AuthState { + final AuthUser? user; + const AuthState({this.user}); + bool get isLoggedIn => user != null; +} + +class AuthNotifier extends StateNotifier { + AuthNotifier() : super(const AuthState()); + + void login(AuthUser user) => state = AuthState(user: user); + void logout() => state = const AuthState(); +} + +final authStateProvider = StateNotifierProvider( + (ref) => AuthNotifier(), +); diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart new file mode 100644 index 0000000..6329c97 --- /dev/null +++ b/client/lib/core/router/app_router.dart @@ -0,0 +1,63 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../screens/auth/login_screen.dart'; +import '../../screens/shell/app_shell.dart'; +import '../../screens/stock_in/stock_in_list_screen.dart'; +import '../../screens/stock_in/stock_in_form_screen.dart'; +import '../../screens/stock_out/stock_out_list_screen.dart'; +import '../../screens/inventory/inventory_list_screen.dart'; +import '../../screens/inventory/inventory_check_screen.dart'; +import '../../screens/partners/partners_screen.dart'; +import '../../screens/finance/finance_screen.dart'; +import '../../screens/products/products_screen.dart'; +import '../../screens/settings/settings_screen.dart'; +import '../auth/auth_state.dart'; + +final appRouterProvider = Provider((ref) { + final authState = ref.watch(authStateProvider); + + return 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; + }, + routes: [ + GoRoute( + path: '/login', + builder: (context, state) => const LoginScreen(), + ), + ShellRoute( + builder: (context, state, child) => AppShell(child: child), + routes: [ + GoRoute( + path: '/stock-in', + builder: (_, __) => const StockInListScreen()), + GoRoute( + path: '/stock-in/new', + builder: (_, __) => const StockInFormScreen()), + GoRoute( + path: '/stock-out', + builder: (_, __) => const StockOutListScreen()), + GoRoute( + path: '/inventory', + builder: (_, __) => const InventoryListScreen()), + GoRoute( + path: '/inventory/check', + builder: (_, __) => const InventoryCheckScreen()), + GoRoute( + path: '/partners', builder: (_, __) => const PartnersScreen()), + GoRoute( + path: '/finance', builder: (_, __) => const FinanceScreen()), + GoRoute( + path: '/products', builder: (_, __) => const ProductsScreen()), + GoRoute( + path: '/settings', builder: (_, __) => const SettingsScreen()), + ], + ), + ], + ); +}); diff --git a/client/lib/core/theme/app_theme.dart b/client/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..ae14011 --- /dev/null +++ b/client/lib/core/theme/app_theme.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + static const Color primary = Color(0xFF1565C0); + static const Color primaryDark = Color(0xFF0D47A1); + static const Color primaryLight = Color(0xFF1976D2); + static const Color accent = Color(0xFFFF6F00); + static const Color success = Color(0xFF2E7D32); + static const Color danger = Color(0xFFC62828); + static const Color background = Color(0xFFF5F5F5); + static const Color surface = Color(0xFFFFFFFF); + static const Color border = Color(0xFFE0E0E0); + static const Color textPrimary = Color(0xFF212121); + static const Color textSecondary = Color(0xFF757575); + + static ThemeData light() { + return ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: primary, + brightness: Brightness.light, + ).copyWith( + primary: primary, + surface: surface, + onPrimary: Colors.white, + ), + scaffoldBackgroundColor: background, + appBarTheme: const AppBarTheme( + backgroundColor: primary, + foregroundColor: Colors.white, + elevation: 0, + centerTitle: false, + toolbarHeight: 56, + ), + cardTheme: CardTheme( + color: surface, + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + side: const BorderSide(color: border, width: 0.5), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: primary, + foregroundColor: Colors.white, + minimumSize: const Size(0, 36), + padding: const EdgeInsets.symmetric(horizontal: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: primary, + minimumSize: const Size(0, 36), + padding: const EdgeInsets.symmetric(horizontal: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + side: const BorderSide(color: primary), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: primary, + minimumSize: const Size(0, 36), + padding: const EdgeInsets.symmetric(horizontal: 12), + ), + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: const BorderSide(color: border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: const BorderSide(color: border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: const BorderSide(color: primary, width: 1.5), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + isDense: true, + filled: true, + fillColor: surface, + ), + dividerTheme: const DividerThemeData(color: border, thickness: 0.5), + textTheme: const TextTheme( + bodyLarge: TextStyle(fontSize: 14, color: textPrimary), + bodyMedium: TextStyle(fontSize: 14, color: textPrimary), + bodySmall: TextStyle(fontSize: 12, color: textSecondary), + titleMedium: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: textPrimary), + labelMedium: TextStyle(fontSize: 12, color: textSecondary), + ), + ); + } +} diff --git a/client/lib/main.dart b/client/lib/main.dart new file mode 100644 index 0000000..88d812b --- /dev/null +++ b/client/lib/main.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'core/router/app_router.dart'; +import 'core/theme/app_theme.dart'; + +void main() { + runApp(const ProviderScope(child: JiuApp())); +} + +class JiuApp extends ConsumerWidget { + const JiuApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(appRouterProvider); + return MaterialApp.router( + title: '酒库管理系统', + theme: AppTheme.light(), + routerConfig: router, + debugShowCheckedModeBanner: false, + ); + } +} diff --git a/client/lib/screens/auth/login_screen.dart b/client/lib/screens/auth/login_screen.dart new file mode 100644 index 0000000..b3508d8 --- /dev/null +++ b/client/lib/screens/auth/login_screen.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../core/auth/auth_state.dart'; +import '../../core/theme/app_theme.dart'; + +class LoginScreen extends ConsumerStatefulWidget { + const LoginScreen({super.key}); + + @override + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _usernameCtrl = TextEditingController(); + final _passwordCtrl = TextEditingController(); + bool _loading = false; + bool _obscure = true; + + @override + void dispose() { + _usernameCtrl.dispose(); + _passwordCtrl.dispose(); + super.dispose(); + } + + Future _login() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _loading = true); + await Future.delayed(const Duration(milliseconds: 600)); + // Mock login for UI demo + ref.read(authStateProvider.notifier).login(AuthUser( + token: 'demo-token', + username: _usernameCtrl.text, + hotelName: '示范大酒店', + hotelNo: 'H001', + hotelId: 1, + )); + if (mounted) { + context.go('/stock-in'); + } + if (mounted) setState(() => _loading = false); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.primaryDark, + body: Stack( + children: [ + // Background pattern + Positioned.fill( + child: CustomPaint(painter: _BackgroundPainter()), + ), + Center( + child: Card( + elevation: 12, + shadowColor: Colors.black38, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), + child: Container( + width: 400, + padding: const EdgeInsets.all(40), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Logo + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppTheme.primaryLight, AppTheme.primaryDark], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: AppTheme.primary.withOpacity(0.4), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: const Icon(Icons.wine_bar, + color: Colors.white, size: 44), + ), + const SizedBox(height: 20), + const Text( + '酒库管理系统', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + color: AppTheme.primaryDark, + letterSpacing: 1, + ), + ), + const SizedBox(height: 6), + const Text( + '酒店仓库管理解决方案', + style: TextStyle( + fontSize: 13, color: AppTheme.textSecondary), + ), + const SizedBox(height: 36), + TextFormField( + controller: _usernameCtrl, + decoration: const InputDecoration( + labelText: '用户名', + hintText: '请输入用户名', + prefixIcon: + Icon(Icons.person_outline, size: 20), + ), + validator: (v) => + (v == null || v.isEmpty) ? '请输入用户名' : null, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordCtrl, + obscureText: _obscure, + decoration: InputDecoration( + labelText: '密码', + hintText: '请输入密码', + prefixIcon: + const Icon(Icons.lock_outline, size: 20), + suffixIcon: IconButton( + icon: Icon( + _obscure + ? Icons.visibility_off + : Icons.visibility, + size: 20), + onPressed: () => + setState(() => _obscure = !_obscure), + ), + ), + validator: (v) => + (v == null || v.isEmpty) ? '请输入密码' : null, + onFieldSubmitted: (_) => _login(), + ), + const SizedBox(height: 28), + SizedBox( + width: double.infinity, + height: 46, + child: ElevatedButton( + onPressed: _loading ? null : _login, + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4)), + ), + child: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Text('登 录', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + letterSpacing: 2)), + ), + ), + const SizedBox(height: 12), + TextButton( + onPressed: () {}, + child: const Text('忘记密码?'), + ), + ], + ), + ), + ), + ), + ), + // Bottom version info + Positioned( + bottom: 16, + left: 0, + right: 0, + child: Center( + child: Text( + '© 2026 酒库管理系统 v1.0.0', + style: TextStyle( + color: Colors.white.withOpacity(0.4), fontSize: 12), + ), + ), + ), + ], + ), + ); + } +} + +class _BackgroundPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = const Color(0xFF1976D2).withOpacity(0.15) + ..style = PaintingStyle.fill; + canvas.drawCircle( + Offset(size.width * 0.1, size.height * 0.2), 180, paint); + canvas.drawCircle( + Offset(size.width * 0.9, size.height * 0.8), 220, paint); + paint.color = const Color(0xFF0D47A1).withOpacity(0.1); + canvas.drawCircle( + Offset(size.width * 0.8, size.height * 0.1), 140, paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/client/lib/screens/finance/finance_screen.dart b/client/lib/screens/finance/finance_screen.dart new file mode 100644 index 0000000..2ca990c --- /dev/null +++ b/client/lib/screens/finance/finance_screen.dart @@ -0,0 +1,881 @@ +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'; + +class FinanceScreen extends ConsumerStatefulWidget { + const FinanceScreen({super.key}); + + @override + ConsumerState createState() => _FinanceScreenState(); +} + +class _FinanceScreenState extends ConsumerState { + int _page = 1; + String _monthFilter = '2026-04'; + String _typeFilter = '全部'; + + final List> _transactions = [ + { + 'no': 'FN20260401001', + 'type': '应付账款', + 'partner': '贵州茅台酒股份有限公司', + 'orderNo': 'RK20260401001', + 'amount': '85000.00', + 'paid': '85000.00', + 'balance': '0.00', + 'date': '2026-04-01', + 'dueDate': '2026-05-01', + 'status': '已结清', + }, + { + 'no': 'FN20260401002', + 'type': '应付账款', + 'partner': '四川五粮液股份有限公司', + 'orderNo': 'RK20260401002', + 'amount': '42500.00', + 'paid': '0.00', + 'balance': '42500.00', + 'date': '2026-04-01', + 'dueDate': '2026-05-01', + 'status': '未付款', + }, + { + 'no': 'FN20260402001', + 'type': '应付账款', + 'partner': '江苏洋河酒厂股份有限公司', + 'orderNo': 'RK20260402001', + 'amount': '36800.00', + 'paid': '20000.00', + 'balance': '16800.00', + 'date': '2026-04-02', + 'dueDate': '2026-05-17', + 'status': '部分付款', + }, + { + 'no': 'FN20260402002', + 'type': '应付账款', + 'partner': '泸州老窖股份有限公司', + 'orderNo': 'RK20260403001', + 'amount': '55200.00', + 'paid': '55200.00', + 'balance': '0.00', + 'date': '2026-04-03', + 'dueDate': '2026-05-03', + 'status': '已结清', + }, + { + 'no': 'FN20260403001', + 'type': '应付账款', + 'partner': '法国拉菲集团中国总代理', + 'orderNo': 'RK20260403002', + 'amount': '124800.00', + 'paid': '0.00', + 'balance': '124800.00', + 'date': '2026-04-03', + 'dueDate': '2026-06-02', + 'status': '未付款', + }, + { + 'no': 'FN20260404001', + 'type': '应付账款', + 'partner': '人头马轩尼诗(中国)有限公司', + 'orderNo': 'RK20260404001', + 'amount': '30240.00', + 'paid': '0.00', + 'balance': '30240.00', + 'date': '2026-04-04', + 'dueDate': '2026-06-03', + 'status': '未付款', + }, + { + 'no': 'FN20260404002', + 'type': '费用报销', + 'partner': '张三', + 'orderNo': 'BX20260404001', + 'amount': '1280.00', + 'paid': '1280.00', + 'balance': '0.00', + 'date': '2026-04-04', + 'dueDate': '2026-04-04', + 'status': '已结清', + }, + ]; + + // Summary stats + double get _totalPayable => _transactions + .where((t) => t['type'] == '应付账款') + .fold(0.0, (s, t) => s + (double.tryParse(t['balance'] as String) ?? 0)); + + double get _totalPaid => _transactions.fold( + 0.0, (s, t) => s + (double.tryParse(t['paid'] as String) ?? 0)); + + double get _totalAmount => _transactions.fold( + 0.0, (s, t) => s + (double.tryParse(t['amount'] as String) ?? 0)); + + @override + Widget build(BuildContext context) { + return PageScaffold( + title: '财务管理', + tabs: const [ + Tab(text: '应付账款'), + Tab(text: '付款记录'), + Tab(text: '财务报表'), + ], + tabViews: [ + _buildPayableList(), + _buildPaymentHistory(), + _buildFinanceReport(), + ], + ); + } + + Widget _buildPayableList() { + final filtered = _typeFilter == '全部' + ? _transactions + : _transactions.where((t) => t['type'] == _typeFilter).toList(); + + return Column( + children: [ + // Summary bar + Container( + color: AppTheme.background, + padding: const EdgeInsets.all(12), + child: Row( + children: [ + _FinanceSummaryCard( + title: '本月采购总额', + value: '¥${(_totalAmount / 10000).toStringAsFixed(1)}万', + icon: Icons.shopping_bag, + color: AppTheme.primary, + ), + const SizedBox(width: 12), + _FinanceSummaryCard( + title: '已付款', + value: '¥${(_totalPaid / 10000).toStringAsFixed(1)}万', + icon: Icons.check_circle, + color: AppTheme.success, + ), + const SizedBox(width: 12), + _FinanceSummaryCard( + title: '未付款', + value: '¥${(_totalPayable / 10000).toStringAsFixed(1)}万', + icon: Icons.pending_actions, + color: AppTheme.danger, + ), + const SizedBox(width: 12), + _FinanceSummaryCard( + title: '即将到期(7天)', + value: '¥16.8万', + icon: Icons.alarm, + color: AppTheme.accent, + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: DataTableCard( + totalCount: filtered.length, + page: _page, + onPageChanged: (p) => setState(() => _page = p), + toolbar: Row( + children: [ + ElevatedButton.icon( + onPressed: () => _showPaymentDialog(context), + icon: const Icon(Icons.payment, 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(), + // Month filter + _MonthSelector( + value: _monthFilter, + onChanged: (v) => setState(() => _monthFilter = v), + ), + const SizedBox(width: 8), + _DropdownFilter( + value: _typeFilter, + items: ['全部', '应付账款', '费用报销'], + onChanged: (v) => setState(() => _typeFilter = v!), + hint: '类型', + ), + ], + ), + 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: filtered + .map((t) => DataRow( + color: WidgetStateProperty.resolveWith((states) { + if (t['status'] == '未付款') { + final due = DateTime.tryParse(t['dueDate'] as String); + if (due != null && + due.difference(DateTime.now()).inDays <= 7) { + return AppTheme.accent.withOpacity(0.05); + } + } + return null; + }), + cells: [ + DataCell(Text(t['no'] as String, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: AppTheme.textSecondary))), + DataCell(Text(t['type'] as String)), + DataCell(SizedBox( + width: 160, + child: Text(t['partner'] as String, + overflow: TextOverflow.ellipsis), + )), + DataCell(Text(t['orderNo'] as String, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: AppTheme.primary))), + DataCell(Text('¥${t['amount']}', + style: const TextStyle( + fontWeight: FontWeight.w500))), + DataCell(Text('¥${t['paid']}', + style: const TextStyle( + color: AppTheme.success))), + DataCell(Text( + '¥${t['balance']}', + style: TextStyle( + color: (t['balance'] as String) != '0.00' + ? AppTheme.danger + : AppTheme.textSecondary, + fontWeight: FontWeight.w600, + ), + )), + DataCell(Text(t['dueDate'] as String)), + DataCell(_PaymentStatusBadge(t['status'] as String)), + DataCell(Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () {}, + child: const Text('详情', + style: TextStyle(fontSize: 12))), + if (t['status'] != '已结清') + TextButton( + onPressed: () => + _showPaymentDialog(context), + child: const Text('付款', + style: TextStyle( + color: AppTheme.success, + fontSize: 12))), + ], + )), + ], + )) + .toList(), + ), + ), + ], + ); + } + + Widget _buildPaymentHistory() { + final payments = [ + { + 'date': '2026-04-01', + 'no': 'ZF20260401001', + 'partner': '贵州茅台酒股份有限公司', + 'amount': '85000.00', + 'method': '银行转账', + 'bank': '招商银行', + 'remark': '4月货款结清', + }, + { + 'date': '2026-04-02', + 'no': 'ZF20260402001', + 'partner': '泸州老窖股份有限公司', + 'amount': '55200.00', + 'method': '银行转账', + 'bank': '工商银行', + 'remark': '4月货款', + }, + { + 'date': '2026-04-02', + 'no': 'ZF20260402002', + 'partner': '江苏洋河酒厂股份有限公司', + 'amount': '20000.00', + 'method': '银行转账', + 'bank': '建设银行', + 'remark': '部分预付', + }, + { + 'date': '2026-04-04', + 'no': 'ZF20260404001', + 'partner': '张三', + 'amount': '1280.00', + 'method': '现金', + 'bank': '-', + 'remark': '差旅报销', + }, + ]; + + return DataTableCard( + totalCount: payments.length, + page: 1, + toolbar: Row( + children: [ + ElevatedButton.icon( + onPressed: () => _showPaymentDialog(context), + 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(), + _MonthSelector( + value: _monthFilter, + onChanged: (v) => setState(() => _monthFilter = v), + ), + ], + ), + columns: const [ + DataColumn(label: Text('付款日期')), + DataColumn(label: Text('付款单号')), + DataColumn(label: Text('收款方')), + DataColumn(label: Text('金额'), numeric: true), + DataColumn(label: Text('付款方式')), + DataColumn(label: Text('银行')), + DataColumn(label: Text('备注')), + DataColumn(label: Text('操作')), + ], + rows: payments + .map((p) => DataRow(cells: [ + DataCell(Text(p['date'] as String)), + DataCell(Text(p['no'] as String, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: AppTheme.textSecondary))), + DataCell(SizedBox( + width: 160, + child: Text(p['partner'] as String, + overflow: TextOverflow.ellipsis), + )), + DataCell(Text( + '¥${p['amount']}', + style: const TextStyle( + color: AppTheme.danger, fontWeight: FontWeight.w600), + )), + DataCell(Text(p['method'] as String)), + DataCell(Text(p['bank'] as String)), + DataCell(Text(p['remark'] as String)), + DataCell(TextButton( + onPressed: () {}, + child: const Text('查看', + style: TextStyle(fontSize: 12)))), + ])) + .toList(), + ); + } + + Widget _buildFinanceReport() { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Month/period selector + Row( + children: [ + const Text('报表周期:', + style: TextStyle(fontSize: 14)), + const SizedBox(width: 8), + _MonthSelector( + value: _monthFilter, + onChanged: (v) => setState(() => _monthFilter = v), + ), + const SizedBox(width: 16), + ElevatedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.picture_as_pdf, size: 16), + label: const Text('导出报表'), + ), + ], + ), + const SizedBox(height: 16), + // Metrics row + Row( + children: [ + _ReportCard( + title: '本月采购总额', + value: '¥${(_totalAmount / 10000).toStringAsFixed(2)}万', + change: '+12.5%', + positive: false, + ), + const SizedBox(width: 12), + _ReportCard( + title: '本月付款总额', + value: '¥${(_totalPaid / 10000).toStringAsFixed(2)}万', + change: '+8.3%', + positive: true, + ), + const SizedBox(width: 12), + _ReportCard( + title: '应付账款余额', + value: '¥${(_totalPayable / 10000).toStringAsFixed(2)}万', + change: '-5.2%', + positive: true, + ), + const SizedBox(width: 12), + _ReportCard( + title: '库存总价值', + value: '¥103.17万', + change: '+3.8%', + positive: false, + ), + ], + ), + const SizedBox(height: 16), + // Category breakdown + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('按品类采购金额', + style: TextStyle( + fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(height: 16), + ...[ + ('白酒', 238600.0, AppTheme.primary), + ('葡萄酒', 124800.0, const Color(0xFF7B1FA2)), + ('洋酒', 30240.0, AppTheme.accent), + ('啤酒', 1970.0, const Color(0xFF0097A7)), + ].map((item) { + final total = 395610.0; + final pct = item.$2 / total; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + SizedBox( + width: 60, + child: Text(item.$1, + style: const TextStyle(fontSize: 13))), + const SizedBox(width: 8), + Expanded( + child: LinearProgressIndicator( + value: pct, + backgroundColor: AppTheme.border, + color: item.$3, + minHeight: 8, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 100, + child: Text( + '¥${(item.$2 / 10000).toStringAsFixed(1)}万 (${(pct * 100).toStringAsFixed(1)}%)', + style: const TextStyle(fontSize: 12), + textAlign: TextAlign.right, + ), + ), + ], + ), + ], + ), + ); + }), + ], + ), + ), + ), + ], + ), + ); + } + + void _showPaymentDialog(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => FormDialog( + title: '登记付款', + width: 480, + onConfirm: () { + Navigator.of(ctx).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('付款记录已登记'), + backgroundColor: AppTheme.success, + ), + ); + }, + content: Column( + children: [ + _DialogRow( + children: [ + _DialogField( + label: '付款方式', + child: DropdownButtonFormField( + value: '银行转账', + items: ['银行转账', '现金', '支票', '网银'] + .map((s) => DropdownMenuItem( + value: s, + child: Text(s, + style: const TextStyle(fontSize: 13)))) + .toList(), + onChanged: (_) {}, + decoration: const InputDecoration(), + )), + _DialogField( + label: '付款金额', + required: true, + child: TextFormField( + decoration: const InputDecoration( + hintText: '0.00', prefixText: '¥'), + keyboardType: + const TextInputType.numberWithOptions(decimal: true))), + ], + ), + const SizedBox(height: 12), + _DialogRow( + children: [ + _DialogField( + label: '付款银行', + child: DropdownButtonFormField( + value: '招商银行', + items: ['招商银行', '工商银行', '建设银行', '农业银行', '中国银行'] + .map((s) => DropdownMenuItem( + value: s, + child: Text(s, + style: const TextStyle(fontSize: 13)))) + .toList(), + onChanged: (_) {}, + decoration: const InputDecoration(), + )), + _DialogField( + label: '付款日期', + child: InputDecorator( + decoration: const InputDecoration(), + child: const Text('2026-04-04', + style: TextStyle(fontSize: 13)), + )), + ], + ), + const SizedBox(height: 12), + _DialogField( + label: '备注', + fullWidth: true, + child: TextFormField( + maxLines: 2, + decoration: const InputDecoration(hintText: '付款说明'), + ), + ), + ], + ), + ), + ); + } +} + +class _DialogRow extends StatelessWidget { + final List children; + const _DialogRow({required this.children}); + + @override + Widget build(BuildContext context) { + return Row( + children: children + .expand((child) => [ + Expanded(child: child), + if (child != children.last) const SizedBox(width: 12), + ]) + .toList(), + ); + } +} + +class _DialogField extends StatelessWidget { + final String label; + final Widget child; + final bool required; + final bool fullWidth; + + const _DialogField({ + 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 _FinanceSummaryCard extends StatelessWidget { + final String title; + final String value; + final IconData icon; + final Color color; + + const _FinanceSummaryCard({ + required this.title, + required this.value, + required this.icon, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + height: 72, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: AppTheme.border, width: 0.5), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: color, size: 22), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(title, + style: const TextStyle( + fontSize: 12, color: AppTheme.textSecondary)), + const SizedBox(height: 4), + Text(value, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: color)), + ], + ), + ], + ), + ), + ); + } +} + +class _PaymentStatusBadge extends StatelessWidget { + final String status; + const _PaymentStatusBadge(this.status); + + @override + Widget build(BuildContext context) { + Color bg, 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; + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: + BoxDecoration(color: bg, borderRadius: BorderRadius.circular(3)), + child: Text(status, + style: TextStyle( + color: fg, fontSize: 12, fontWeight: FontWeight.w500)), + ); + } +} + +class _ReportCard extends StatelessWidget { + final String title; + final String value; + final String change; + final bool positive; + + const _ReportCard({ + required this.title, + required this.value, + required this.change, + required this.positive, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + height: 88, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: AppTheme.border, width: 0.5), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: const TextStyle( + fontSize: 12, color: AppTheme.textSecondary)), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(value, + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.w700)), + const Spacer(), + Text( + change, + style: TextStyle( + fontSize: 12, + color: positive ? AppTheme.success : AppTheme.danger, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _MonthSelector extends StatelessWidget { + final String value; + final ValueChanged onChanged; + + const _MonthSelector({required this.value, required this.onChanged}); + + @override + Widget build(BuildContext context) { + final months = [ + '2026-04', + '2026-03', + '2026-02', + '2026-01', + '2025-12', + '2025-11', + ]; + 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: months + .map((m) => DropdownMenuItem( + value: m, + child: Text(m, style: const TextStyle(fontSize: 13)))) + .toList(), + onChanged: (v) => onChanged(v!), + style: const TextStyle( + fontSize: 13, color: AppTheme.textPrimary), + ), + ), + ); + } +} + +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/inventory/inventory_check_screen.dart b/client/lib/screens/inventory/inventory_check_screen.dart new file mode 100644 index 0000000..7e5b198 --- /dev/null +++ b/client/lib/screens/inventory/inventory_check_screen.dart @@ -0,0 +1,490 @@ +import 'package:flutter/material.dart'; +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'; + +class InventoryCheckScreen extends ConsumerStatefulWidget { + const InventoryCheckScreen({super.key}); + + @override + ConsumerState createState() => + _InventoryCheckScreenState(); +} + +class _InventoryCheckScreenState + extends ConsumerState { + final _checkNoCtrl = + TextEditingController(text: 'PD20260404001'); + String _warehouse = '主仓库'; + String _checkType = '全盘'; + bool _submitting = false; + + // Mock inventory items for checking + final List> _checkItems = [ + { + 'code': 'SP001', + 'name': '茅台酒(飞天)53度500ml', + 'unit': '瓶', + 'systemQty': 286, + 'actualQtyCtrl': TextEditingController(text: '286'), + 'remark': TextEditingController(), + }, + { + 'code': 'SP002', + 'name': '五粮液(普五)52度500ml', + 'unit': '瓶', + 'systemQty': 152, + 'actualQtyCtrl': TextEditingController(text: '150'), + 'remark': TextEditingController(text: '破损2瓶'), + }, + { + 'code': 'SP003', + 'name': '洋河梦之蓝M6+ 45度500ml', + 'unit': '瓶', + 'systemQty': 88, + 'actualQtyCtrl': TextEditingController(text: '88'), + 'remark': TextEditingController(), + }, + { + 'code': 'SP005', + 'name': '泸州老窖(国窖1573)52度500ml', + 'unit': '瓶', + 'systemQty': 68, + 'actualQtyCtrl': TextEditingController(text: '70'), + 'remark': TextEditingController(text: '盘盈2瓶'), + }, + { + 'code': 'SP006', + 'name': '汾酒(青花30)53度500ml', + 'unit': '瓶', + 'systemQty': 45, + 'actualQtyCtrl': TextEditingController(text: '45'), + 'remark': TextEditingController(), + }, + { + 'code': 'SP010', + 'name': '青岛啤酒(经典)500ml', + 'unit': '箱', + 'systemQty': 35, + 'actualQtyCtrl': TextEditingController(text: '35'), + 'remark': TextEditingController(), + }, + ]; + + @override + void dispose() { + _checkNoCtrl.dispose(); + for (final item in _checkItems) { + (item['actualQtyCtrl'] as TextEditingController).dispose(); + (item['remark'] as TextEditingController).dispose(); + } + super.dispose(); + } + + int _getDiff(Map item) { + final actual = int.tryParse( + (item['actualQtyCtrl'] as TextEditingController).text) ?? + 0; + return actual - (item['systemQty'] as int); + } + + Future _submit() async { + setState(() => _submitting = true); + await Future.delayed(const Duration(milliseconds: 800)); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('盘点单已提交,待审核'), + backgroundColor: AppTheme.success, + ), + ); + context.go('/inventory'); + } + if (mounted) setState(() => _submitting = false); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.background, + body: Column( + children: [ + // Header + Container( + height: 52, + color: AppTheme.surface, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, size: 20), + onPressed: () => context.go('/inventory'), + ), + const SizedBox(width: 8), + const Text('库存盘点', + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w600)), + const Spacer(), + OutlinedButton( + onPressed: () {}, + child: const Text('保存草稿'), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: _submitting ? null : _submit, + icon: _submitting + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.check_circle_outline, size: 16), + label: const Text('提交盘点'), + ), + const SizedBox(width: 8), + OutlinedButton( + onPressed: () => context.go('/inventory'), + child: const Text('取消'), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // Basic info + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('盘点基本信息', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark)), + const SizedBox(height: 16), + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + _InfoField( + label: '盘点单号', + child: TextFormField( + controller: _checkNoCtrl, + readOnly: true, + style: const TextStyle( + fontFamily: 'monospace'), + decoration: const InputDecoration(), + ), + ), + _InfoField( + label: '盘点仓库', + 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(), + ), + ), + _InfoField( + label: '盘点类型', + child: DropdownButtonFormField( + value: _checkType, + items: ['全盘', '抽盘', '循环盘点'] + .map((s) => DropdownMenuItem( + value: s, + child: Text(s, + style: const TextStyle( + fontSize: 13)))) + .toList(), + onChanged: (v) => + setState(() => _checkType = v!), + decoration: const InputDecoration(), + ), + ), + _InfoField( + label: '盘点日期', + child: InputDecorator( + decoration: const InputDecoration(), + child: Text( + '2026-04-04', + style: const TextStyle(fontSize: 13), + ), + ), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 12), + // Check items table + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('盘点明细', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark)), + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: AppTheme.accent.withOpacity(0.1), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + '差异 ${_checkItems.where((i) => _getDiff(i) != 0).length} 项', + style: const TextStyle( + fontSize: 12, + color: AppTheme.accent), + ), + ), + ], + ), + const SizedBox(height: 12), + // Table + Table( + columnWidths: const { + 0: FixedColumnWidth(36), + 1: FixedColumnWidth(80), + 2: FlexColumnWidth(3), + 3: FixedColumnWidth(50), + 4: FixedColumnWidth(80), + 5: FixedColumnWidth(120), + 6: FixedColumnWidth(80), + 7: FlexColumnWidth(2), + }, + children: [ + TableRow( + decoration: const BoxDecoration( + color: Color(0xFFF0F4FF)), + children: [ + '序号', + '商品编码', + '商品名称', + '单位', + '账面数量', + '实际数量', + '差异', + '备注', + ] + .map((h) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 10), + child: Text(h, + style: const TextStyle( + fontSize: 13, + fontWeight: + FontWeight.w600, + color: AppTheme + .primaryDark)), + )) + .toList(), + ), + ...List.generate( + _checkItems.length, + (i) => _buildCheckRow(i)), + ], + ), + const Divider(height: 1), + // Summary + Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + children: [ + _SummaryItem( + label: '盘点商品', + value: '${_checkItems.length}种', + color: AppTheme.primary, + ), + const SizedBox(width: 24), + _SummaryItem( + label: '盘盈', + value: + '${_checkItems.where((i) => _getDiff(i) > 0).length}种', + color: AppTheme.success, + ), + const SizedBox(width: 24), + _SummaryItem( + label: '盘亏', + value: + '${_checkItems.where((i) => _getDiff(i) < 0).length}种', + color: AppTheme.danger, + ), + const SizedBox(width: 24), + _SummaryItem( + label: '相符', + value: + '${_checkItems.where((i) => _getDiff(i) == 0).length}种', + color: AppTheme.textSecondary, + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + TableRow _buildCheckRow(int index) { + final item = _checkItems[index]; + final diff = _getDiff(item); + + return TableRow( + decoration: BoxDecoration( + color: diff != 0 + ? (diff > 0 + ? AppTheme.success.withOpacity(0.04) + : AppTheme.danger.withOpacity(0.04)) + : (index.isEven ? Colors.white : const Color(0xFFFAFAFA)), + ), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + child: Text('${index + 1}', + style: const TextStyle( + fontSize: 13, color: AppTheme.textSecondary)), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + child: Text(item['code'] as String, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: AppTheme.textSecondary)), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + child: Text(item['name'] as String, + style: const TextStyle(fontSize: 13), + overflow: TextOverflow.ellipsis), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + child: Text(item['unit'] as String, + style: const TextStyle(fontSize: 13)), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + child: Text('${item['systemQty']}', + style: const TextStyle(fontSize: 13)), + ), + Padding( + padding: const EdgeInsets.all(4), + child: TextFormField( + controller: item['actualQtyCtrl'] as TextEditingController, + decoration: const InputDecoration(), + style: const TextStyle(fontSize: 13), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onChanged: (_) => setState(() {}), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + child: Text( + diff == 0 ? '0' : (diff > 0 ? '+$diff' : '$diff'), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: diff == 0 + ? AppTheme.textSecondary + : (diff > 0 ? AppTheme.success : AppTheme.danger), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(4), + child: TextFormField( + controller: item['remark'] as TextEditingController, + decoration: const InputDecoration(hintText: '备注'), + style: const TextStyle(fontSize: 13), + ), + ), + ], + ); + } +} + +class _InfoField extends StatelessWidget { + final String label; + final Widget child; + + const _InfoField({required this.label, required this.child}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 220, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle( + fontSize: 13, color: AppTheme.textSecondary)), + const SizedBox(height: 6), + child, + ], + ), + ); + } +} + +class _SummaryItem extends StatelessWidget { + final String label; + final String value; + final Color color; + + const _SummaryItem( + {required this.label, + required this.value, + required this.color}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text(label, + style: const TextStyle( + fontSize: 13, color: AppTheme.textSecondary)), + const SizedBox(width: 4), + Text(value, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: color)), + ], + ); + } +} diff --git a/client/lib/screens/inventory/inventory_list_screen.dart b/client/lib/screens/inventory/inventory_list_screen.dart new file mode 100644 index 0000000..e42fa78 --- /dev/null +++ b/client/lib/screens/inventory/inventory_list_screen.dart @@ -0,0 +1,700 @@ +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'; + +class InventoryListScreen extends ConsumerStatefulWidget { + const InventoryListScreen({super.key}); + + @override + ConsumerState createState() => + _InventoryListScreenState(); +} + +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': '缺货', + }, + ]; + + @override + void dispose() { + _searchCtrl.dispose(); + 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); + }); + } + + int get _lowStockCount { + return _mockInventory.where((item) { + return (item['qty'] as int) < (item['minQty'] as int); + }).length; + } + + @override + Widget build(BuildContext context) { + return PageScaffold( + title: '库存管理', + tabs: const [ + Tab(text: '库存查询'), + Tab(text: '库存预警'), + Tab(text: '库存盘点'), + ], + tabViews: [ + _buildInventoryList(), + _buildWarningList(), + _buildCheckTab(), + ], + ); + } + + Widget _buildInventoryList() { + final items = _filtered; + + return Column( + children: [ + // Summary cards + Container( + color: AppTheme.background, + padding: const EdgeInsets.all(12), + child: 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 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, + 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)), + ), + ], + ), + ); + } +} + +class _SummaryCard extends StatelessWidget { + final String title; + final String value; + final String unit; + final IconData icon; + final Color color; + + const _SummaryCard({ + required this.title, + required this.value, + required this.unit, + required this.icon, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + height: 72, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: AppTheme.border, width: 0.5), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: color, size: 22), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(title, + style: const TextStyle( + fontSize: 12, color: AppTheme.textSecondary)), + const SizedBox(height: 4), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text(value, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: color)), + if (unit.isNotEmpty) ...[ + const SizedBox(width: 2), + Text(unit, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary)), + ], + ], + ), + ], + ), + ], + ), + ), + ); + } +} + +class _InventoryStatusBadge extends StatelessWidget { + final String status; + const _InventoryStatusBadge(this.status); + + @override + Widget build(BuildContext context) { + 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; + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: bg, borderRadius: BorderRadius.circular(3)), + child: Text(status, + style: TextStyle( + color: fg, 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/partners/partners_screen.dart b/client/lib/screens/partners/partners_screen.dart new file mode 100644 index 0000000..499145e --- /dev/null +++ b/client/lib/screens/partners/partners_screen.dart @@ -0,0 +1,519 @@ +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'; + +class PartnersScreen extends ConsumerStatefulWidget { + const PartnersScreen({super.key}); + + @override + ConsumerState createState() => _PartnersScreenState(); +} + +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': '合作中', + }, + ]; + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PageScaffold( + title: '往来单位', + tabs: const [ + Tab(text: '供应商'), + Tab(text: '客户'), + ], + tabViews: [ + _buildSupplierList(), + _buildCustomerList(), + ], + ); + } + + 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(); + + return DataTableCard( + totalCount: filtered.length, + page: _suppliersPage, + onPageChanged: (p) => setState(() => _suppliersPage = p), + toolbar: Row( + children: [ + ElevatedButton.icon( + onPressed: () => _showAddPartnerDialog(context, isSupplier: true), + 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( + controller: _searchCtrl, + decoration: const InputDecoration( + hintText: '搜索供应商名称/编码', + prefixIcon: Icon(Icons.search, size: 16), + hintStyle: TextStyle(fontSize: 13), + ), + onChanged: (_) => setState(() {}), + ), + ), + ], + ), + 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('操作')), + ], + 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(), + ); + } + + 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(); + + 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, + ), + ); + }, + content: Column( + children: [ + _DialogField( + label: isSupplier ? '供应商名称' : '客户名称', + required: true, + child: TextFormField( + controller: nameCtrl, + decoration: InputDecoration( + hintText: isSupplier ? '请输入供应商全称' : '请输入客户名称', + ), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _DialogField( + label: '联系人', + child: TextFormField( + controller: contactCtrl, + decoration: const InputDecoration(hintText: '联系人姓名'), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _DialogField( + label: '联系电话', + child: TextFormField( + controller: phoneCtrl, + decoration: const InputDecoration(hintText: '手机/座机'), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + _DialogField( + label: '地址', + child: TextFormField( + controller: addressCtrl, + decoration: const InputDecoration(hintText: '详细地址'), + ), + ), + if (isSupplier) ...[ + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _DialogField( + label: '账期(天)', + child: TextFormField( + initialValue: '30', + keyboardType: TextInputType.number, + decoration: const InputDecoration(hintText: '结款账期'), + ), + ), + ), + 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(), + ), + ), + ), + ], + ), + ], + ], + ), + ), + ); + } +} + +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 new file mode 100644 index 0000000..66687d8 --- /dev/null +++ b/client/lib/screens/products/products_screen.dart @@ -0,0 +1,758 @@ +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'; + +class ProductsScreen extends ConsumerStatefulWidget { + const ProductsScreen({super.key}); + + @override + ConsumerState createState() => _ProductsScreenState(); +} + +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': '禁用', + }, + ]; + + @override + void dispose() { + _searchCtrl.dispose(); + 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; + } + } + return true; + }).toList(); + } + + @override + Widget build(BuildContext context) { + return PageScaffold( + title: '基础数据', + tabs: const [ + Tab(text: '商品档案'), + Tab(text: '商品分类'), + Tab(text: '仓库管理'), + ], + tabViews: [ + _buildProductList(), + _buildCategoryList(), + _buildWarehouseList(), + ], + ); + } + + Widget _buildProductList() { + final products = _filteredProducts; + return DataTableCard( + totalCount: products.length, + page: _productsPage, + onPageChanged: (p) => setState(() => _productsPage = 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('导出'), + ), + const Spacer(), + _DropdownFilter( + value: _categoryFilter, + items: ['全部', '白酒', '葡萄酒', '洋酒', '啤酒', '黄酒'], + onChanged: (v) => setState(() => _categoryFilter = v!), + hint: '分类', + ), + 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(() {}), + ), + ), + ], + ), + 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('状态')), + 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), + )), + ], + )), + ], + )) + .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(), + ); + } + + 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('新增仓库'), + ), + ], + ), + 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), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text('$usageRate%', + style: const TextStyle(fontSize: 11)), + ), + ], + )), + DataCell(Text(w['manager'] as String)), + DataCell(_StatusBadge(w['status'] as String)), + DataCell(Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () {}, + child: const Text('编辑', + style: TextStyle(fontSize: 12))), + ], + )), + ]); + }) + .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 new file mode 100644 index 0000000..d6a102f --- /dev/null +++ b/client/lib/screens/settings/settings_screen.dart @@ -0,0 +1,480 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/theme/app_theme.dart'; + +class SettingsScreen extends ConsumerStatefulWidget { + const SettingsScreen({super.key}); + + @override + ConsumerState createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends ConsumerState { + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 4, + child: Column( + children: [ + Container( + color: AppTheme.surface, + child: const TabBar( + isScrollable: true, + labelColor: AppTheme.primary, + unselectedLabelColor: AppTheme.textSecondary, + indicatorColor: AppTheme.primary, + indicatorWeight: 2, + labelStyle: + TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + tabs: [ + Tab(text: '用户管理'), + Tab(text: '仓库管理'), + Tab(text: '编号规则'), + Tab(text: '系统参数'), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: TabBarView( + children: [ + _buildUsersTab(), + _buildWarehousesTab(), + _buildNumberRulesTab(), + _buildSystemParamsTab(), + ], + ), + ), + ], + ), + ); + } + + Widget _buildUsersTab() { + final users = [ + {'name': '张三', 'username': 'zhangsan', 'role': '管理员', 'status': true, 'lastLogin': '2026-04-04 09:15'}, + {'name': '李四', 'username': 'lisi', 'role': '操作员', 'status': true, 'lastLogin': '2026-04-04 08:30'}, + {'name': '王五', 'username': 'wangwu', 'role': '操作员', 'status': true, 'lastLogin': '2026-04-03 17:45'}, + {'name': '赵六', 'username': 'zhaoliu', 'role': '只读', 'status': false, 'lastLogin': '2026-03-28 10:20'}, + ]; + + return Column( + children: [ + Container( + height: 52, + color: AppTheme.surface, + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + ElevatedButton.icon( + onPressed: () => _showAddUserDialog(context), + icon: const Icon(Icons.person_add, size: 16), + 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('操作')), + ], + rows: users + .map((u) => DataRow(cells: [ + DataCell(Row( + mainAxisSize: MainAxisSize.min, + children: [ + CircleAvatar( + radius: 14, + backgroundColor: AppTheme.primary.withOpacity(0.15), + child: Text( + (u['name'] as String).substring(0, 1), + style: const TextStyle( + fontSize: 12, + color: AppTheme.primary, + fontWeight: FontWeight.w600), + ), + ), + const SizedBox(width: 8), + Text(u['name'] as String), + ], + )), + DataCell(Text(u['username'] as String, + style: const TextStyle( + fontFamily: 'monospace', fontSize: 12))), + DataCell(_RoleBadge(u['role'] as String)), + DataCell(Switch( + value: u['status'] as bool, + onChanged: (_) {}, + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + )), + DataCell(Text(u['lastLogin'] as String, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary))), + 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(), + ), + ), + ), + ], + ); + } + + 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}, + ]; + + return Column( + children: [ + Container( + height: 52, + color: AppTheme.surface, + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + ElevatedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.add, size: 16), + 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(), + ), + ), + ), + ], + ); + } + + Widget _buildNumberRulesTab() { + final rules = [ + {'type': '入库单', 'prefix': 'RK', 'format': 'RK{年}{月}{日}{序号4}', 'example': 'RK20260404001', 'currentNo': 4}, + {'type': '出库单', 'prefix': 'CK', 'format': 'CK{年}{月}{日}{序号4}', 'example': 'CK20260404001', 'currentNo': 2}, + {'type': '盘点单', 'prefix': 'PD', 'format': 'PD{年}{月}{日}{序号4}', 'example': 'PD20260404001', 'currentNo': 1}, + {'type': '商品编码', 'prefix': 'SP', 'format': 'SP{序号3}', 'example': 'SP001', 'currentNo': 12}, + ]; + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('编号规则配置', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + const Text('配置各类单据的自动编号规则,修改后对新建单据生效', + style: TextStyle(fontSize: 13, color: AppTheme.textSecondary)), + const SizedBox(height: 16), + Card( + 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('当前序号'), numeric: true), + DataColumn(label: Text('操作')), + ], + rows: rules + .map((r) => DataRow(cells: [ + DataCell(Text(r['type'] as String, + style: const TextStyle(fontWeight: FontWeight.w500))), + DataCell(Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: AppTheme.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(3), + ), + child: Text(r['prefix'] as String, + style: const TextStyle( + color: AppTheme.primary, + fontFamily: 'monospace', + fontSize: 13, + fontWeight: FontWeight.w600)), + )), + DataCell(Text(r['format'] as String, + style: const TextStyle( + fontSize: 12, color: AppTheme.textSecondary))), + DataCell(Text(r['example'] as String, + style: const TextStyle( + fontFamily: 'monospace', fontSize: 12))), + DataCell(Text('${r['currentNo']}')), + DataCell(TextButton( + onPressed: () {}, + child: const Text('编辑', style: TextStyle(fontSize: 12)))), + ])) + .toList(), + ), + ), + ], + ), + ); + } + + Widget _buildSystemParamsTab() { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('系统参数', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('基本设置', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.primaryDark)), + const Divider(height: 24), + _ParamRow(label: '系统名称', value: '酒库管理系统'), + _ParamRow(label: '货币单位', value: '人民币(CNY)'), + _ParamRow(label: '日期格式', value: 'YYYY-MM-DD'), + _ParamRow(label: '时区', value: 'Asia/Shanghai (UTC+8)'), + 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: '是', 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: '系统内通知'), + ], + ), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + ElevatedButton( + onPressed: () {}, + child: const Text('保存设置'), + ), + const SizedBox(width: 8), + OutlinedButton( + onPressed: () {}, + child: const Text('重置默认'), + ), + ], + ), + ], + ), + ); + } + + void _showAddUserDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('新增用户'), + content: SizedBox( + width: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const TextField( + decoration: InputDecoration(labelText: '姓名', hintText: '请输入姓名'), + ), + const SizedBox(height: 12), + const TextField( + decoration: InputDecoration(labelText: '用户名', hintText: '请输入登录用户名'), + ), + const SizedBox(height: 12), + const TextField( + obscureText: true, + decoration: InputDecoration(labelText: '初始密码', hintText: '请设置初始密码'), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: '操作员', + items: ['管理员', '操作员', '只读'] + .map((r) => DropdownMenuItem(value: r, child: Text(r))) + .toList(), + onChanged: (_) {}, + decoration: const InputDecoration(labelText: '角色'), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context), + child: const Text('确定'), + ), + ], + ), + ); + } +} + +class _RoleBadge extends StatelessWidget { + final String role; + const _RoleBadge(this.role); + + @override + Widget build(BuildContext context) { + final Color bg; + final Color fg; + switch (role) { + case '管理员': + bg = const Color(0xFFE3F2FD); + fg = AppTheme.primary; + break; + case '操作员': + bg = const Color(0xFFE8F5E9); + fg = AppTheme.success; + break; + default: + bg = const Color(0xFFF5F5F5); + fg = AppTheme.textSecondary; + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(3)), + child: Text(role, style: TextStyle(color: fg, fontSize: 12, fontWeight: FontWeight.w500)), + ); + } +} + +class _ParamRow extends StatelessWidget { + final String label; + final String value; + final bool? isSwitch; + + const _ParamRow({required this.label, required this.value, this.isSwitch}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + SizedBox( + width: 180, + child: Text(label, + style: const TextStyle(fontSize: 14, color: AppTheme.textSecondary)), + ), + if (isSwitch != null) + Switch( + value: isSwitch!, + onChanged: (_) {}, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ) + else + Text(value, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + const Spacer(), + TextButton(onPressed: () {}, child: const Text('修改', style: TextStyle(fontSize: 12))), + ], + ), + ); + } +} diff --git a/client/lib/screens/shell/app_shell.dart b/client/lib/screens/shell/app_shell.dart new file mode 100644 index 0000000..b9c12c7 --- /dev/null +++ b/client/lib/screens/shell/app_shell.dart @@ -0,0 +1,345 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'dart:async'; +import '../../core/auth/auth_state.dart'; +import '../../core/theme/app_theme.dart'; + +class AppShell extends ConsumerStatefulWidget { + final Widget child; + const AppShell({super.key, required this.child}); + + @override + ConsumerState createState() => _AppShellState(); +} + +class _AppShellState extends ConsumerState { + bool _sidebarExpanded = true; + late Timer _timer; + late String _currentTime; + final String _loginTime = + DateFormat('HH:mm:ss').format(DateTime.now()); + + final List<_NavItem> _navItems = const [ + _NavItem(icon: Icons.input, label: '入库管理', path: '/stock-in'), + _NavItem(icon: Icons.output, label: '出库管理', path: '/stock-out'), + _NavItem(icon: Icons.inventory_2, label: '库存管理', path: '/inventory'), + _NavItem( + icon: Icons.account_balance_wallet, + label: '财务管理', + path: '/finance'), + _NavItem(icon: Icons.people, label: '往来单位', path: '/partners'), + _NavItem(icon: Icons.category, label: '基础数据', path: '/products'), + _NavItem(icon: Icons.settings, label: '系统设置', path: '/settings'), + ]; + + @override + void initState() { + super.initState(); + _currentTime = DateFormat('HH:mm:ss').format(DateTime.now()); + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) { + setState(() => + _currentTime = DateFormat('HH:mm:ss').format(DateTime.now())); + } + }); + } + + @override + void dispose() { + _timer.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final user = ref.watch(authStateProvider).user; + final location = GoRouterState.of(context).matchedLocation; + final sidebarWidth = _sidebarExpanded ? 200.0 : 56.0; + + return Scaffold( + body: Column( + children: [ + // Top Bar + Container( + height: 56, + color: AppTheme.primary, + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + IconButton( + icon: Icon( + _sidebarExpanded ? Icons.menu_open : Icons.menu, + color: Colors.white), + onPressed: () => + setState(() => _sidebarExpanded = !_sidebarExpanded), + tooltip: _sidebarExpanded ? '收起侧边栏' : '展开侧边栏', + ), + const SizedBox(width: 4), + const Icon(Icons.wine_bar, color: Colors.white, size: 22), + const SizedBox(width: 8), + const Text( + '酒库管理系统', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + letterSpacing: 0.5), + ), + const Spacer(), + if (user != null) ...[ + const Icon(Icons.business, + color: Colors.white70, size: 14), + const SizedBox(width: 4), + Text(user.hotelName, + style: const TextStyle( + color: Colors.white70, fontSize: 13)), + const SizedBox(width: 20), + const Icon(Icons.person_outline, + color: Colors.white70, size: 14), + const SizedBox(width: 4), + Text(user.username, + style: const TextStyle( + color: Colors.white70, fontSize: 13)), + const SizedBox(width: 8), + PopupMenuButton( + icon: const Icon(Icons.keyboard_arrow_down, + color: Colors.white70), + onSelected: (v) { + if (v == 'logout') { + ref.read(authStateProvider.notifier).logout(); + context.go('/login'); + } + }, + 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 SizedBox(width: 8), + ], + ], + ), + ), + // Main area + Expanded( + child: Row( + children: [ + // Sidebar + AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + width: sidebarWidth, + color: AppTheme.primaryDark, + child: Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: _navItems.map((item) { + final isActive = + location.startsWith(item.path); + return _SidebarItem( + item: item, + isActive: isActive, + expanded: _sidebarExpanded, + onTap: () => context.go(item.path), + ); + }).toList(), + ), + ), + ], + ), + ), + // Content area + Expanded( + child: Column( + children: [ + Expanded(child: widget.child), + // Status bar + Container( + height: 28, + color: const Color(0xFF37474F), + padding: + const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + if (user != null) ...[ + _StatusItem( + icon: Icons.store, + text: '门店编号:${user.hotelNo}'), + const _StatusDivider(), + _StatusItem( + icon: Icons.person, + text: '登录用户:${user.username}'), + const _StatusDivider(), + _StatusItem( + icon: Icons.login, + text: '登录时间:$_loginTime'), + const _StatusDivider(), + ], + _StatusItem( + icon: Icons.access_time, + text: '当前时间:$_currentTime'), + const Spacer(), + const _StatusItem( + icon: Icons.info_outline, + text: 'v1.0.0'), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _NavItem { + final IconData icon; + final String label; + final String path; + const _NavItem( + {required this.icon, required this.label, required this.path}); +} + +class _SidebarItem extends StatefulWidget { + final _NavItem item; + final bool isActive; + final bool expanded; + final VoidCallback onTap; + + const _SidebarItem({ + required this.item, + required this.isActive, + required this.expanded, + required this.onTap, + }); + + @override + State<_SidebarItem> createState() => _SidebarItemState(); +} + +class _SidebarItemState extends State<_SidebarItem> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final isActive = widget.isActive; + final expanded = widget.expanded; + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: 48, + color: isActive + ? AppTheme.primary.withOpacity(0.3) + : _hovered + ? Colors.white.withOpacity(0.05) + : Colors.transparent, + padding: EdgeInsets.symmetric(horizontal: expanded ? 0 : 0), + child: Row( + children: [ + // Active indicator + AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 3, + height: isActive ? 28 : 0, + color: Colors.white, + margin: EdgeInsets.only(right: expanded ? 13 : 0), + ), + if (!isActive) SizedBox(width: expanded ? 16 : 3), + Expanded( + child: Row( + mainAxisAlignment: expanded + ? MainAxisAlignment.start + : MainAxisAlignment.center, + children: [ + Icon( + widget.item.icon, + color: isActive ? Colors.white : Colors.white60, + size: 20, + ), + if (expanded) ...[ + const SizedBox(width: 12), + Flexible( + child: Text( + widget.item.label, + style: TextStyle( + color: isActive + ? Colors.white + : Colors.white70, + fontSize: 14, + fontWeight: isActive + ? FontWeight.w500 + : FontWeight.normal, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _StatusItem extends StatelessWidget { + final IconData icon; + final String text; + const _StatusItem({required this.icon, required this.text}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 11, color: Colors.white54), + const SizedBox(width: 4), + Text(text, + style: const TextStyle(color: Colors.white54, fontSize: 11)), + ], + ); + } +} + +class _StatusDivider extends StatelessWidget { + const _StatusDivider(); + @override + Widget build(BuildContext context) { + return Container( + width: 1, + height: 12, + color: Colors.white24, + margin: const EdgeInsets.symmetric(horizontal: 10), + ); + } +} diff --git a/client/lib/screens/stock_in/stock_in_form_screen.dart b/client/lib/screens/stock_in/stock_in_form_screen.dart new file mode 100644 index 0000000..e8a982e --- /dev/null +++ b/client/lib/screens/stock_in/stock_in_form_screen.dart @@ -0,0 +1,549 @@ +import 'package:flutter/material.dart'; +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'; + +class StockInFormScreen extends ConsumerStatefulWidget { + const StockInFormScreen({super.key}); + + @override + ConsumerState createState() => _StockInFormScreenState(); +} + +class _StockInFormScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _orderNoCtrl = TextEditingController(text: 'RK20260404004'); + final _remarkCtrl = TextEditingController(); + String _supplier = '贵州茅台酒股份有限公司'; + String _warehouse = '主仓库'; + 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'), + }, + ]; + + @override + void dispose() { + _orderNoCtrl.dispose(); + _remarkCtrl.dispose(); + for (final item in _items) { + (item['qty'] as TextEditingController).dispose(); + (item['price'] as TextEditingController).dispose(); + } + super.dispose(); + } + + 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; + total += qty * price; + } + return total; + } + + void _addItem() { + setState(() { + _items.add({ + 'name': '', + 'spec': '', + 'unit': '瓶', + 'qty': TextEditingController(), + 'price': TextEditingController(), + }); + }); + } + + void _removeItem(int index) { + setState(() { + final item = _items.removeAt(index); + (item['qty'] as TextEditingController).dispose(); + (item['price'] as TextEditingController).dispose(); + }); + } + + Future _submit(bool asDraft) async { + if (!asDraft && !_formKey.currentState!.validate()) return; + setState(() => _submitting = true); + await Future.delayed(const Duration(milliseconds: 600)); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(asDraft ? '已保存为草稿' : '入库单已提交审核'), + backgroundColor: AppTheme.success, + ), + ); + context.go('/stock-in'); + } + if (mounted) setState(() => _submitting = false); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.background, + body: Column( + children: [ + // Page header + Container( + height: 52, + color: AppTheme.surface, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, size: 20), + onPressed: () => context.go('/stock-in'), + tooltip: '返回', + ), + const SizedBox(width: 8), + const Text('新建入库单', + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w600)), + const Spacer(), + OutlinedButton( + onPressed: _submitting ? null : () => _submit(true), + child: const Text('保存草稿'), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: _submitting ? null : () => _submit(false), + icon: _submitting + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.send, size: 16), + label: const Text('提交审核'), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () => context.go('/stock-in'), + icon: const Icon(Icons.cancel_outlined, size: 16), + label: const Text('取消'), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Basic info card + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('基本信息', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark)), + const SizedBox(height: 16), + Wrap( + 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(), + ), + ), + _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(), + ), + ), + _FormField( + label: '入库日期', + required: true, + child: InkWell( + onTap: _pickDate, + child: InputDecorator( + decoration: const InputDecoration(), + child: Row( + children: [ + Expanded( + child: Text( + '${_orderDate.year}-${_orderDate.month.toString().padLeft(2, '0')}-${_orderDate.day.toString().padLeft(2, '0')}', + style: const TextStyle( + fontSize: 13), + ), + ), + const Icon( + Icons.calendar_today, + size: 16, + color: AppTheme.textSecondary), + ], + ), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + _FormField( + label: '备注', + width: double.infinity, + child: TextFormField( + controller: _remarkCtrl, + maxLines: 2, + decoration: const InputDecoration( + hintText: '选填,如有特殊说明请在此注明', + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + // Items card + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('商品明细', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark)), + const Spacer(), + ElevatedButton.icon( + onPressed: _addItem, + icon: const Icon(Icons.add, size: 16), + label: const Text('添加商品'), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 32)), + ), + ], + ), + const SizedBox(height: 12), + // Items table + Table( + columnWidths: const { + 0: FixedColumnWidth(36), + 1: FlexColumnWidth(3), + 2: FlexColumnWidth(2), + 3: FixedColumnWidth(60), + 4: FlexColumnWidth(1.5), + 5: FlexColumnWidth(1.5), + 6: FlexColumnWidth(1.5), + 7: FixedColumnWidth(60), + }, + children: [ + TableRow( + decoration: const BoxDecoration( + color: Color(0xFFF0F4FF)), + children: [ + '序号', + '商品名称', + '规格', + '单位', + '数量', + '单价', + '金额', + '操作', + ] + .map((h) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 10), + child: Text(h, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: + AppTheme.primaryDark)), + )) + .toList(), + ), + ...List.generate( + _items.length, + (i) => _buildItemRow(i)), + ], + ), + const Divider(height: 1), + // Total + Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const Text('合计金额:', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500)), + Text( + '¥${_totalAmount.toStringAsFixed(2)}', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppTheme.danger), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + 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 amount = qty * price; + + return TableRow( + decoration: BoxDecoration( + 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: 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, + decoration: const InputDecoration(hintText: '0'), + style: const TextStyle(fontSize: 13), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')) + ], + onChanged: (_) => setState(() {}), + validator: (v) { + 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, + decoration: const InputDecoration( + hintText: '0.00', prefixText: '¥'), + style: const TextStyle(fontSize: 13), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')) + ], + onChanged: (_) => setState(() {}), + validator: (v) { + 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( + '¥${amount.toStringAsFixed(2)}', + style: const TextStyle( + 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, + tooltip: '删除', + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 28, minHeight: 28), + ), + ), + ], + ); + } + + Future _pickDate() async { + final date = await showDatePicker( + context: context, + initialDate: _orderDate, + firstDate: DateTime(2020), + lastDate: DateTime(2030), + ); + if (date != null) setState(() => _orderDate = date); + } +} + +class _FormField extends StatelessWidget { + final String label; + final Widget child; + final bool required; + final double width; + + const _FormField({ + required this.label, + required this.child, + this.required = false, + this.width = 240, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + child: 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, + ], + ), + ); + } +} diff --git a/client/lib/screens/stock_in/stock_in_list_screen.dart b/client/lib/screens/stock_in/stock_in_list_screen.dart new file mode 100644 index 0000000..14f3b21 --- /dev/null +++ b/client/lib/screens/stock_in/stock_in_list_screen.dart @@ -0,0 +1,395 @@ +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'; + +class StockInListScreen extends ConsumerStatefulWidget { + const StockInListScreen({super.key}); + + @override + ConsumerState createState() => + _StockInListScreenState(); +} + +class _StockInListScreenState extends ConsumerState { + int _page = 1; + final _searchCtrl = TextEditingController(); + 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', + }, + ]; + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + 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(); + } + + @override + Widget build(BuildContext context) { + return PageScaffold( + title: '入库管理', + tabs: const [ + Tab(text: '入库单'), + Tab(text: '入库查询'), + Tab(text: '入库审核'), + ], + tabViews: [ + _buildOrderList(), + _buildQueryView(), + _buildOrderList(forceStatus: OrderStatus.pending), + ], + ); + } + + Widget _buildOrderList({OrderStatus? forceStatus}) { + final orders = _filteredOrders(forceStatus: forceStatus); + return DataTableCard( + totalCount: orders.length, + page: _page, + onPageChanged: (p) => setState(() => _page = p), + toolbar: Row( + children: [ + ElevatedButton.icon( + onPressed: () => context.go('/stock-in/new'), + 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) ...[ + _StatusFilterDropdown( + value: _statusFilter, + onChanged: (v) => setState(() { + _statusFilter = v!; + _page = 1; + }), + ), + 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)}', + style: const TextStyle(fontSize: 13), + ), + ), + ], + ), + columns: const [ + DataColumn(label: Text('入库单号')), + DataColumn(label: Text('供应商')), + 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()), + ], + ); + } + + Future _pickDateRange() async { + final range = await showDateRangePicker( + context: context, + firstDate: DateTime(2020), + lastDate: DateTime(2030), + initialDateRange: _dateRange, + ); + if (range != null) setState(() => _dateRange = range); + } +} + +class _StatusFilterDropdown extends StatelessWidget { + final String value; + final ValueChanged onChanged; + + const _StatusFilterDropdown({ + required this.value, + required this.onChanged, + }); + + @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: ['全部', '草稿', '待审核', '已审核', '已拒绝'] + .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/stock_out/stock_out_list_screen.dart b/client/lib/screens/stock_out/stock_out_list_screen.dart new file mode 100644 index 0000000..3bcee6a --- /dev/null +++ b/client/lib/screens/stock_out/stock_out_list_screen.dart @@ -0,0 +1,372 @@ +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'; + +class StockOutListScreen extends ConsumerStatefulWidget { + const StockOutListScreen({super.key}); + + @override + ConsumerState createState() => + _StockOutListScreenState(); +} + +class _StockOutListScreenState extends ConsumerState { + int _page = 1; + final _searchCtrl = TextEditingController(); + String _statusFilter = '全部'; + String _typeFilter = '全部'; + + 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': '领用', + }, + ]; + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + 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(); + } + + @override + Widget build(BuildContext context) { + return PageScaffold( + title: '出库管理', + tabs: const [ + Tab(text: '出库单'), + Tab(text: '出库查询'), + Tab(text: '出库审核'), + ], + tabViews: [ + _buildList(), + _buildList(), + _buildList(forceStatus: OrderStatus.pending), + ], + ); + } + + Widget _buildList({OrderStatus? forceStatus}) { + final orders = forceStatus != null + ? _mockOrders + .where((o) => o['status'] == forceStatus) + .toList() + : _filtered; + + return DataTableCard( + totalCount: orders.length, + page: _page, + onPageChanged: (p) => setState(() => _page = p), + toolbar: Row( + children: [ + ElevatedButton.icon( + onPressed: () => _showCreateDialog(context), + 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( + value: _statusFilter, + items: ['全部', '草稿', '待审核', '已审核', '已拒绝'], + onChanged: (v) => setState(() => _statusFilter = v!), + hint: '状态', + ), + 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), + ), + ), + ], + ), + 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('日期')), + 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))), + ], + ], + )), + ])) + .toList(), + ); + } + + void _showCreateDialog(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('新建出库单'), + content: const SizedBox( + width: 400, + child: Text('出库单创建功能完整实现请参考入库单流程。'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + 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)), + ), + child: Text(type, + style: TextStyle( + color: color, 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, + hint: Text(hint, style: const TextStyle(fontSize: 13)), + 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/widgets/data_table_card.dart b/client/lib/widgets/data_table_card.dart new file mode 100644 index 0000000..e36ff53 --- /dev/null +++ b/client/lib/widgets/data_table_card.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import '../core/theme/app_theme.dart'; + +class DataTableCard extends StatelessWidget { + final List columns; + final List rows; + final Widget? toolbar; + final int? totalCount; + final int page; + final int pageSize; + final ValueChanged? onPageChanged; + + const DataTableCard({ + super.key, + required this.columns, + required this.rows, + this.toolbar, + this.totalCount, + this.page = 1, + this.pageSize = 20, + this.onPageChanged, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + if (toolbar != null) + Container( + height: 52, + color: AppTheme.surface, + padding: const EdgeInsets.symmetric(horizontal: 12), + child: toolbar!, + ), + if (toolbar != null) const Divider(height: 1), + Expanded( + child: SingleChildScrollView( + child: SizedBox( + width: double.infinity, + child: DataTable( + headingRowColor: WidgetStateProperty.all( + const Color(0xFFF0F4FF)), + headingTextStyle: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark, + ), + dataTextStyle: const TextStyle( + fontSize: 13, color: AppTheme.textPrimary), + columnSpacing: 24, + horizontalMargin: 16, + dataRowMinHeight: 40, + dataRowMaxHeight: 48, + dividerThickness: 0.5, + columns: columns, + rows: rows, + ), + ), + ), + ), + // Pagination + if (totalCount != null) + Container( + height: 48, + color: AppTheme.surface, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Text( + '共 $totalCount 条记录', + style: const TextStyle( + fontSize: 13, color: AppTheme.textSecondary), + ), + const Spacer(), + _PaginationBar( + page: page, + total: totalCount!, + pageSize: pageSize, + onPageChanged: onPageChanged, + ), + ], + ), + ), + ], + ); + } +} + +class _PaginationBar extends StatelessWidget { + final int page; + final int total; + final int pageSize; + final ValueChanged? onPageChanged; + + const _PaginationBar({ + required this.page, + required this.total, + required this.pageSize, + this.onPageChanged, + }); + + @override + Widget build(BuildContext context) { + final totalPages = (total / pageSize).ceil().clamp(1, 9999); + return Row( + children: [ + IconButton( + icon: const Icon(Icons.first_page, size: 18), + onPressed: page > 1 ? () => onPageChanged?.call(1) : null, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + ), + IconButton( + icon: const Icon(Icons.chevron_left, size: 18), + onPressed: page > 1 ? () => onPageChanged?.call(page - 1) : null, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + ), + Text('$page / $totalPages', + style: const TextStyle(fontSize: 13)), + IconButton( + icon: const Icon(Icons.chevron_right, size: 18), + onPressed: + page < totalPages ? () => onPageChanged?.call(page + 1) : null, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + ), + IconButton( + icon: const Icon(Icons.last_page, size: 18), + onPressed: page < totalPages + ? () => onPageChanged?.call(totalPages) + : null, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + ), + ], + ); + } +} diff --git a/client/lib/widgets/form_dialog.dart b/client/lib/widgets/form_dialog.dart new file mode 100644 index 0000000..2d13d58 --- /dev/null +++ b/client/lib/widgets/form_dialog.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import '../core/theme/app_theme.dart'; + +/// Generic dialog wrapper for forms +class FormDialog extends StatelessWidget { + final String title; + final Widget content; + final String confirmLabel; + final VoidCallback? onConfirm; + final bool loading; + final double width; + + const FormDialog({ + super.key, + required this.title, + required this.content, + this.confirmLabel = '保存', + this.onConfirm, + this.loading = false, + this.width = 560, + }); + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + child: SizedBox( + width: width, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title bar + Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 20), + decoration: const BoxDecoration( + color: AppTheme.primary, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(4), + topRight: Radius.circular(4), + ), + ), + child: Row( + children: [ + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w500), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, color: Colors.white, size: 18), + onPressed: () => Navigator.of(context).pop(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + ), + ], + ), + ), + // Content + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: content, + ), + ), + // Action bar + const Divider(height: 1), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: loading ? null : onConfirm, + child: loading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : Text(confirmLabel), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// Show a form dialog and return the result +Future showFormDialog({ + required BuildContext context, + required String title, + required Widget content, + String confirmLabel = '保存', + VoidCallback? onConfirm, + double width = 560, +}) { + return showDialog( + context: context, + builder: (_) => FormDialog( + title: title, + content: content, + confirmLabel: confirmLabel, + onConfirm: onConfirm, + width: width, + ), + ); +} diff --git a/client/lib/widgets/page_scaffold.dart b/client/lib/widgets/page_scaffold.dart new file mode 100644 index 0000000..7d84523 --- /dev/null +++ b/client/lib/widgets/page_scaffold.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import '../core/theme/app_theme.dart'; + +class PageScaffold extends StatelessWidget { + final String title; + final List tabs; + final List tabViews; + final int initialTab; + + const PageScaffold({ + super.key, + required this.title, + required this.tabs, + required this.tabViews, + this.initialTab = 0, + }); + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: tabs.length, + initialIndex: initialTab, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + color: AppTheme.surface, + child: TabBar( + isScrollable: true, + tabAlignment: TabAlignment.start, + labelColor: AppTheme.primary, + unselectedLabelColor: AppTheme.textSecondary, + indicatorColor: AppTheme.primary, + indicatorWeight: 2, + labelStyle: + const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + tabs: tabs, + ), + ), + const Divider(height: 1), + Expanded( + child: TabBarView(children: tabViews), + ), + ], + ), + ); + } +} diff --git a/client/lib/widgets/status_badge.dart b/client/lib/widgets/status_badge.dart new file mode 100644 index 0000000..e5be78f --- /dev/null +++ b/client/lib/widgets/status_badge.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import '../core/theme/app_theme.dart'; + +enum OrderStatus { draft, pending, approved, rejected } + +extension OrderStatusLabel on OrderStatus { + String get label { + switch (this) { + case OrderStatus.draft: + return '草稿'; + case OrderStatus.pending: + return '待审核'; + case OrderStatus.approved: + return '已审核'; + case OrderStatus.rejected: + return '已拒绝'; + } + } +} + +class StatusBadge extends StatelessWidget { + final OrderStatus status; + const StatusBadge(this.status, {super.key}); + + @override + Widget build(BuildContext context) { + final Color bg; + final Color fg; + switch (status) { + case OrderStatus.draft: + bg = const Color(0xFFF5F5F5); + fg = AppTheme.textSecondary; + break; + case OrderStatus.pending: + bg = const Color(0xFFFFF3E0); + fg = AppTheme.accent; + break; + case OrderStatus.approved: + bg = const Color(0xFFE8F5E9); + fg = AppTheme.success; + break; + case OrderStatus.rejected: + bg = const Color(0xFFFFEBEE); + fg = AppTheme.danger; + break; + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + status.label, + style: TextStyle( + color: fg, fontSize: 12, fontWeight: FontWeight.w500), + ), + ); + } +} diff --git a/client/pubspec.yaml b/client/pubspec.yaml new file mode 100644 index 0000000..2d2e2b8 --- /dev/null +++ b/client/pubspec.yaml @@ -0,0 +1,24 @@ +name: jiu_client +description: 酒库管理系统 - 酒店仓库管理 +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + flutter_riverpod: ^2.5.1 + go_router: ^14.0.0 + dio: ^5.4.3+1 + flutter_secure_storage: ^9.0.0 + intl: ^0.19.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + +flutter: + uses-material-design: true diff --git a/docs/context/project.md b/docs/context/project.md index 1a054dd..d70168c 100644 --- a/docs/context/project.md +++ b/docs/context/project.md @@ -137,6 +137,43 @@ jiu/ - POST /api/v1/import/partners (Excel/CSV) ``` +## Flutter 客户端结构 + +``` +client/ +├── pubspec.yaml # 依赖:riverpod, go_router, dio, intl +├── lib/ +│ ├── main.dart # 入口,ProviderScope + MaterialApp.router +│ ├── core/ +│ │ ├── theme/app_theme.dart # 颜色常量 + ThemeData +│ │ ├── auth/auth_state.dart # AuthUser, AuthState, AuthNotifier +│ │ ├── api/api_client.dart # Dio 封装 +│ │ └── router/app_router.dart # go_router 路由定义(含登录重定向) +│ ├── screens/ +│ │ ├── auth/login_screen.dart # 登录页(蓝色背景 + 居中卡片) +│ │ ├── shell/app_shell.dart # 主框架(顶栏 + 侧边栏 + 状态栏) +│ │ ├── stock_in/ # 入库单列表 + 新建表单 +│ │ ├── stock_out/ # 出库单列表 +│ │ ├── inventory/ # 库存查询 + 盘点 +│ │ ├── partners/ # 往来单位 +│ │ ├── finance/ # 财务管理 +│ │ ├── products/ # 商品管理 +│ │ └── settings/ # 系统设置(用户/仓库/编号规则) +│ └── widgets/ +│ ├── page_scaffold.dart # Tab 页面封装 +│ ├── data_table_card.dart # 含工具栏+分页的数据表格 +│ ├── status_badge.dart # 状态标签(草稿/待审/已审/拒绝) +│ └── form_dialog.dart # 弹窗表单封装 +``` + +**启动 Flutter 客户端**(需先安装 Flutter): +```bash +cd client +flutter pub get +flutter run -d macos # macOS 桌面 +flutter run -d chrome # Web +``` + ## 本地开发启动 ```bash