From ced407ea879b45825922af3b37510254f31b0837 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sun, 7 Jun 2026 20:16:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E7=A7=BB=E5=8A=A8=E7=AB=AF?= =?UTF-8?q?=E5=B8=83=E5=B1=80=E9=80=82=E9=85=8D=20+=20=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 入库/出库详情页:窄屏商品明细切换为卡片竖排,顶部操作收入溢出菜单 - 基础数据:新增「仓库」tab,使用 DataTableCard + mobileCards 适配窄屏 - 系统设置:移除仓库 tab(已迁入基础数据),新增「授权」tab,数据导入改名「数据管理」 - 关于我们:移除授权信息,补充帮助与文档/扫码防伪/法律信息/系统信息等模块 - AppInfo 新增 phone/wechat/termsUrl/privacyUrl/docsUrl 字段(可选,空则不渲染) Co-Authored-By: Claude Sonnet 4.6 --- client/assets/config/app_info.json | 7 +- client/lib/core/config/app_info.dart | 10 + client/lib/screens/about/about_screen.dart | 228 ++++---- .../lib/screens/products/products_screen.dart | 237 ++++++++ .../lib/screens/settings/settings_screen.dart | 410 +++++--------- .../stock_in/stock_in_form_screen.dart | 535 ++++++++++-------- .../stock_out/stock_out_form_screen.dart | 226 +++++--- 7 files changed, 975 insertions(+), 678 deletions(-) diff --git a/client/assets/config/app_info.json b/client/assets/config/app_info.json index ad75e47..4c42061 100644 --- a/client/assets/config/app_info.json +++ b/client/assets/config/app_info.json @@ -1,5 +1,10 @@ { "provider": "岩美技术有限公司", "website": "https://jiu.yanmei.com", - "email": "yammy2023@163.com" + "email": "yammy2023@163.com", + "phone": "", + "wechat": "", + "terms_url": "https://jiu.yanmei.com/terms/", + "privacy_url": "https://jiu.yanmei.com/privacy/", + "docs_url": "https://jiu.yanmei.com/docs/" } diff --git a/client/lib/core/config/app_info.dart b/client/lib/core/config/app_info.dart index 3c5808d..2fc46ec 100644 --- a/client/lib/core/config/app_info.dart +++ b/client/lib/core/config/app_info.dart @@ -12,6 +12,11 @@ class AppInfo { static String provider = ''; static String website = ''; static String email = ''; + static String phone = ''; + static String wechat = ''; + static String termsUrl = ''; + static String privacyUrl = ''; + static String docsUrl = ''; /// 加载配置文件。失败时保留为空(不影响应用启动)。 static Future load() async { @@ -21,6 +26,11 @@ class AppInfo { provider = (map['provider'] as String?) ?? ''; website = (map['website'] as String?) ?? ''; email = (map['email'] as String?) ?? ''; + phone = (map['phone'] as String?) ?? ''; + wechat = (map['wechat'] as String?) ?? ''; + termsUrl = (map['terms_url'] as String?) ?? ''; + privacyUrl = (map['privacy_url'] as String?) ?? ''; + docsUrl = (map['docs_url'] as String?) ?? ''; } catch (e) { debugPrint('AppInfo.load failed: $e'); } diff --git a/client/lib/screens/about/about_screen.dart b/client/lib/screens/about/about_screen.dart index d0793f2..99612fa 100644 --- a/client/lib/screens/about/about_screen.dart +++ b/client/lib/screens/about/about_screen.dart @@ -1,9 +1,10 @@ +import 'dart:io'; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../core/responsive/responsive.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:intl/intl.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../core/config/app_config.dart'; import '../../core/config/app_info.dart'; @@ -11,10 +12,9 @@ import '../../core/theme/app_theme.dart'; import '../../core/update/app_updater.dart'; import '../../core/utils/dialog_util.dart'; import '../../providers/feedback_provider.dart'; -import '../../providers/license_provider.dart'; import '../../providers/update_provider.dart'; -/// 「关于我们」独立页面(左侧菜单项)。原为系统设置里的「关于」Tab。 +/// 「关于我们」独立页面(左侧菜单项)。 class AboutScreen extends ConsumerStatefulWidget { const AboutScreen({super.key}); @@ -27,7 +27,6 @@ class _AboutScreenState extends ConsumerState { Widget build(BuildContext context) { final appVersion = ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0'; final updateInfo = ref.watch(updateProvider).valueOrNull; - final licenseAsync = ref.watch(licenseProvider); return SingleChildScrollView( padding: const EdgeInsets.all(24), @@ -80,76 +79,33 @@ class _AboutScreenState extends ConsumerState { ), const SizedBox(height: 20), - // ── 授权信息 ── + // ── 帮助与文档 ── _AboutSection( - title: '授权信息', + title: '帮助与文档', children: [ - licenseAsync.when( - loading: () => const _AboutRow(label: '授权状态', value: '加载中…'), - error: (_, __) => - const _AboutRow(label: '授权状态', value: '暂无授权信息'), - data: (lic) { - if (lic == null) { - return const _AboutRow(label: '授权状态', value: '未激活'); - } - return Column( - children: [ - _AboutRow(label: '授权类型', value: lic.typeLabel), - _AboutRow( - label: '授权状态', - value: lic.isExpired - ? '已过期' - : lic.isActive - ? '正常' - : '已停用', - valueColor: lic.isExpired - ? AppTheme.danger - : lic.isActive - ? AppTheme.success - : AppTheme.textSecondary, - ), - if (lic.expiresAt != null) - _AboutRow( - label: '到期时间', - value: DateFormat('yyyy-MM-dd').format(lic.expiresAt!), - trailing: lic.daysRemaining != null && - lic.daysRemaining! <= 30 - ? Chip( - label: Text( - lic.isExpired - ? '已过期' - : '剩余 ${lic.daysRemaining} 天', - style: const TextStyle( - fontSize: 11, color: Colors.white), - ), - backgroundColor: lic.isExpired - ? AppTheme.danger - : Colors.orange, - padding: EdgeInsets.zero, - materialTapTargetSize: - MaterialTapTargetSize.shrinkWrap, - ) - : null, - ) - else - const _AboutRow(label: '到期时间', value: '永久有效'), - if (lic.activatedAt != null) - _AboutRow( - label: '激活时间', - value: DateFormat('yyyy-MM-dd') - .format(lic.activatedAt!), - ), - ], - ); - }, - ), + const _AboutRow(label: '使用手册', value: '查看产品使用文档与操作指南'), const SizedBox(height: 8), - Row( + Wrap( + spacing: 8, children: [ OutlinedButton.icon( - onPressed: () => _showRenewDialog(), - icon: const Icon(Icons.card_membership, size: 16), - label: const Text('续费 / 升级授权'), + onPressed: () async { + final url = Uri.parse( + AppInfo.docsUrl.isNotEmpty + ? AppInfo.docsUrl + : '${AppInfo.website}/docs/'); + if (await canLaunchUrl(url)) launchUrl(url); + }, + icon: const Icon(Icons.menu_book_outlined, size: 16), + label: const Text('打开文档'), + ), + OutlinedButton.icon( + onPressed: () async { + final url = Uri.parse('${AppInfo.website}/downloads/'); + if (await canLaunchUrl(url)) launchUrl(url); + }, + icon: const Icon(Icons.history_outlined, size: 16), + label: const Text('更新日志'), ), ], ), @@ -162,9 +118,25 @@ class _AboutScreenState extends ConsumerState { title: '关于我们', children: [ _AboutRow(label: '服务商', value: AppInfo.provider), - _AboutRow(label: '官方网站', value: AppInfo.website), + _AboutRow( + label: '官方网站', + value: AppInfo.website, + trailing: AppInfo.website.isNotEmpty + ? TextButton( + onPressed: () async { + final uri = Uri.parse(AppInfo.website); + if (await canLaunchUrl(uri)) launchUrl(uri); + }, + child: const Text('访问', style: TextStyle(fontSize: 12)), + ) + : null, + ), _AboutRow(label: '联系邮箱', value: AppInfo.email), - const _AboutRow(label: '技术支持', value: '周一至周五 9:00 - 18:00'), + if (AppInfo.phone.isNotEmpty) + _AboutRow(label: '联系电话', value: AppInfo.phone), + if (AppInfo.wechat.isNotEmpty) + _AboutRow(label: '微信', value: AppInfo.wechat), + const _AboutRow(label: '服务时间', value: '周一至周五 9:00–18:00'), const SizedBox(height: 8), Wrap( spacing: 8, @@ -184,6 +156,81 @@ class _AboutScreenState extends ConsumerState { ), const SizedBox(height: 20), + // ── 扫码防伪 ── + const _AboutSection( + title: '扫码防伪', + children: [ + _AboutRow( + label: '功能说明', + value: '每件商品可生成专属二维码,顾客扫码即可查看商品名称、系列、规格及批次信息,帮助验证商品真实性。', + ), + ], + ), + const SizedBox(height: 20), + + // ── 法律信息 ── + _AboutSection( + title: '法律信息', + children: [ + if (AppInfo.termsUrl.isNotEmpty) + _AboutRow( + label: '服务条款', + value: '查看服务条款', + trailing: TextButton( + onPressed: () async { + final uri = Uri.parse(AppInfo.termsUrl); + if (await canLaunchUrl(uri)) launchUrl(uri); + }, + child: const Text('查看', style: TextStyle(fontSize: 12)), + ), + ), + if (AppInfo.privacyUrl.isNotEmpty) + _AboutRow( + label: '隐私政策', + value: '查看隐私政策', + trailing: TextButton( + onPressed: () async { + final uri = Uri.parse(AppInfo.privacyUrl); + if (await canLaunchUrl(uri)) launchUrl(uri); + }, + child: const Text('查看', style: TextStyle(fontSize: 12)), + ), + ), + _AboutRow(label: '版权', value: '© ${DateTime.now().year} ${AppInfo.provider}'), + ], + ), + const SizedBox(height: 20), + + // ── 系统信息 ── + _AboutSection( + title: '系统信息', + children: [ + _AboutRow( + label: '运行平台', + value: kIsWeb + ? 'Web' + : Platform.isAndroid + ? 'Android' + : Platform.isIOS + ? 'iOS' + : Platform.isMacOS + ? 'macOS' + : Platform.isWindows + ? 'Windows' + : Platform.operatingSystem, + ), + _AboutRow(label: '应用版本', value: appVersion), + FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (ctx, snap) => _AboutRow( + label: '构建号', + value: snap.data?.buildNumber ?? '-', + ), + ), + ], + ), + const SizedBox(height: 20), + // ── 意见反馈 ── _AboutSection( title: '意见反馈', @@ -215,43 +262,6 @@ class _AboutScreenState extends ConsumerState { ); } - void _showRenewDialog() { - showAppDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('续费 / 升级授权'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('请联系我们获取续费报价:'), - const SizedBox(height: 12), - SelectableText('📧 ${AppInfo.email}', - style: const TextStyle(fontSize: 13)), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('关闭'), - ), - ElevatedButton( - onPressed: () async { - await Clipboard.setData(ClipboardData(text: AppInfo.email)); - if (ctx.mounted) { - Navigator.pop(ctx); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('邮箱已复制到剪贴板')), - ); - } - }, - child: const Text('复制邮箱'), - ), - ], - ), - ); - } - void _showFeedbackDialog({required bool isBug}) { showAppDialog( context: context, diff --git a/client/lib/screens/products/products_screen.dart b/client/lib/screens/products/products_screen.dart index 0caaea3..e6b9710 100644 --- a/client/lib/screens/products/products_screen.dart +++ b/client/lib/screens/products/products_screen.dart @@ -3,7 +3,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/responsive/responsive.dart'; import '../../core/theme/app_theme.dart'; +import '../../models/warehouse.dart'; import '../../providers/product_option_provider.dart'; +import '../../providers/warehouse_provider.dart'; import '../../widgets/data_table_card.dart'; import '../../widgets/mobile_list_card.dart'; import '../../widgets/page_scaffold.dart'; @@ -41,11 +43,13 @@ class _ProductsScreenState extends ConsumerState { Tab(text: '商品名称'), Tab(text: '系列'), Tab(text: '规格'), + Tab(text: '仓库'), ], tabViews: [ _buildNameTab(), _buildSeriesTab(), _buildSpecTab(), + _buildWarehousesTab(), ], ); } @@ -451,6 +455,123 @@ class _ProductsScreenState extends ConsumerState { } } + // ── 仓库 tab ────────────────────────────────────────────── + + Widget _buildWarehousesTab() { + final asyncWarehouses = ref.watch(warehouseListProvider); + return asyncWarehouses.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => _buildError(() => ref.read(warehouseListProvider.notifier).reload()), + data: (warehouses) => DataTableCard( + totalCount: warehouses.length, + page: 1, + pageSize: warehouses.length + 1, + onPageChanged: (_) {}, + onPageSizeChanged: (_) {}, + toolbar: Row( + children: [ + ElevatedButton.icon( + onPressed: () => _showWarehouseDialog(), + icon: const Icon(Icons.add, size: 16), + label: const Text('新建'), + ), + ], + ), + mobileCards: warehouses + .map((w) => MobileListCard( + title: Text(w.name), + subtitle: w.location?.isNotEmpty == true ? Text(w.location!) : null, + fields: [ + if (w.isDefault) const MobileCardField('默认仓库', '是'), + ], + actions: [ + TextButton( + onPressed: () => _showWarehouseDialog(warehouse: w), + child: const Text('编辑', style: TextStyle(fontSize: 13)), + ), + TextButton( + onPressed: () => _confirmDeleteWarehouse(w), + child: const Text('删除', + style: TextStyle(fontSize: 13, color: AppTheme.danger)), + ), + ], + )) + .toList(), + columns: const [ + DataColumn(label: Text('仓库名称')), + DataColumn(label: Text('位置')), + DataColumn(label: Text('默认仓库')), + DataColumn(label: Text('操作')), + ], + rows: warehouses.isEmpty + ? [const DataRow(cells: [ + DataCell(SizedBox()), + DataCell(Text('暂无仓库', style: TextStyle(color: AppTheme.textSecondary))), + DataCell(SizedBox()), + DataCell(SizedBox()), + ])] + : warehouses + .map((w) => DataRow(cells: [ + DataCell(Text(w.name, style: const TextStyle(fontWeight: FontWeight.w500))), + DataCell(Text(w.location ?? '-')), + DataCell(w.isDefault + ? const Icon(Icons.check_circle, color: AppTheme.success, size: 18) + : const SizedBox()), + DataCell(_actionButtons( + onEdit: () => _showWarehouseDialog(warehouse: w), + onDelete: () => _confirmDeleteWarehouse(w), + )), + ])) + .toList(), + ), + ); + } + + void _showWarehouseDialog({Warehouse? warehouse}) { + showAppDialog( + context: context, + builder: (ctx) => _WarehouseFormDialog( + warehouse: warehouse, + onSaved: () => ref.read(warehouseListProvider.notifier).reload(), + ), + ); + } + + Future _confirmDeleteWarehouse(Warehouse w) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('确认删除'), + content: Text('确认删除仓库「${w.name}」?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('取消')), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.danger, foregroundColor: Colors.white), + child: const Text('删除'), + ), + ], + ), + ); + if (confirmed == true && mounted) { + try { + await ref.read(warehouseListProvider.notifier).deleteWarehouse(w.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('删除成功'), backgroundColor: AppTheme.success)); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('删除失败:$e'), backgroundColor: AppTheme.danger)); + } + } + } + } + Future _showOptionDialog({ required String title, required bool hasQuantity, @@ -535,3 +656,119 @@ class _ProductsScreenState extends ConsumerState { ); } } + +class _WarehouseFormDialog extends ConsumerStatefulWidget { + final Warehouse? warehouse; + final VoidCallback onSaved; + + const _WarehouseFormDialog({this.warehouse, required this.onSaved}); + + @override + ConsumerState<_WarehouseFormDialog> createState() => _WarehouseFormDialogState(); +} + +class _WarehouseFormDialogState extends ConsumerState<_WarehouseFormDialog> { + final _formKey = GlobalKey(); + late final TextEditingController _nameCtrl; + late final TextEditingController _locationCtrl; + late bool _isDefault; + bool _saving = false; + + @override + void initState() { + super.initState(); + _nameCtrl = TextEditingController(text: widget.warehouse?.name ?? ''); + _locationCtrl = TextEditingController(text: widget.warehouse?.location ?? ''); + _isDefault = widget.warehouse?.isDefault ?? false; + } + + @override + void dispose() { + _nameCtrl.dispose(); + _locationCtrl.dispose(); + super.dispose(); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _saving = true); + final data = { + 'name': _nameCtrl.text.trim(), + if (_locationCtrl.text.trim().isNotEmpty) 'location': _locationCtrl.text.trim(), + 'is_default': _isDefault, + }; + try { + final notifier = ref.read(warehouseListProvider.notifier); + if (widget.warehouse != null) { + await notifier.updateWarehouse(widget.warehouse!.id, data); + } else { + await notifier.createWarehouse(data); + } + if (mounted) { + Navigator.of(context).pop(); + widget.onSaved(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(widget.warehouse != null ? '仓库更新成功' : '仓库创建成功'), + backgroundColor: AppTheme.success, + )); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('保存失败:$e'), backgroundColor: AppTheme.danger)); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.warehouse != null ? '编辑仓库' : '新建仓库'), + content: SizedBox( + width: context.dialogWidth(400), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _nameCtrl, + decoration: const InputDecoration(labelText: '仓库名称'), + validator: (v) => (v == null || v.isEmpty) ? '不能为空' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _locationCtrl, + decoration: const InputDecoration(labelText: '位置'), + ), + const SizedBox(height: 12), + CheckboxListTile( + title: const Text('设为默认仓库'), + value: _isDefault, + onChanged: (v) => setState(() => _isDefault = v ?? false), + contentPadding: EdgeInsets.zero, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + ElevatedButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('保存'), + ), + ], + ); + } +} diff --git a/client/lib/screens/settings/settings_screen.dart b/client/lib/screens/settings/settings_screen.dart index 05a6d5f..348e8f5 100644 --- a/client/lib/screens/settings/settings_screen.dart +++ b/client/lib/screens/settings/settings_screen.dart @@ -4,19 +4,20 @@ import 'package:dio/dio.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/auth/auth_state.dart'; import '../../core/config/app_config.dart'; +import '../../core/config/app_info.dart'; import '../../core/responsive/responsive.dart'; import '../../core/theme/app_theme.dart'; import '../../models/number_rule.dart'; import '../../models/user.dart'; -import '../../models/warehouse.dart'; +import '../../providers/license_provider.dart'; import '../../providers/number_rule_provider.dart'; import '../../providers/user_provider.dart'; -import '../../providers/warehouse_provider.dart'; import '../../providers/shop_provider.dart'; import '../../models/shop.dart'; @@ -56,10 +57,10 @@ class _SettingsScreenState extends ConsumerState { tabs: [ Tab(text: '酒行信息'), Tab(text: '用户管理'), - Tab(text: '仓库管理'), Tab(text: '编号规则'), Tab(text: '系统参数'), - Tab(text: '数据导入'), + Tab(text: '授权'), + Tab(text: '数据管理'), ], ), ), @@ -69,9 +70,9 @@ class _SettingsScreenState extends ConsumerState { children: [ _buildShopInfoTab(), _buildUsersTab(), - _buildWarehousesTab(), _buildNumberRulesTab(), _buildSystemParamsTab(), + _buildLicenseTab(), _buildImportTab(), ], ), @@ -289,153 +290,151 @@ class _SettingsScreenState extends ConsumerState { ); } - Widget _buildWarehousesTab() { - final asyncWarehouses = ref.watch(warehouseListProvider); - return Column( - children: [ - Container( - height: 52, - color: AppTheme.surface, - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - ElevatedButton.icon( - onPressed: () => _showWarehouseDialog(context), - icon: const Icon(Icons.add, size: 16), - label: const Text('新建'), - ), - ], - ), - ), - const Divider(height: 1), - Expanded( - child: asyncWarehouses.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary), - const SizedBox(height: 12), - const Text('暂无数据,网络不可用', - style: const TextStyle(color: AppTheme.textSecondary)), - const SizedBox(height: 12), - ElevatedButton( - onPressed: () => - ref.read(warehouseListProvider.notifier).reload(), - child: const Text('重试'), - ), - ], + // ── 授权 Tab ────────────────────────────────────────────── + Widget _buildLicenseTab() { + final licenseAsync = ref.watch(licenseProvider); + 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: Padding( + padding: const EdgeInsets.all(20), + child: licenseAsync.when( + loading: () => const _ParamRow(label: '授权状态', value: '加载中…'), + error: (_, __) => const _ParamRow(label: '授权状态', value: '暂无授权信息'), + data: (lic) { + if (lic == null) { + return Column( + children: [ + const _ParamRow(label: '授权状态', value: '未激活'), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _showRenewLicenseDialog, + icon: const Icon(Icons.card_membership, size: 16), + label: const Text('续费 / 升级授权'), + ), + ], + ); + } + final statusColor = lic.isExpired + ? AppTheme.danger + : lic.isActive + ? AppTheme.success + : AppTheme.textSecondary; + final statusText = lic.isExpired + ? '已过期' + : lic.isActive + ? '正常' + : '已停用'; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ParamRow(label: '授权类型', value: lic.typeLabel), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + const SizedBox( + width: 180, + child: Text('授权状态', + style: TextStyle( + fontSize: 14, + color: AppTheme.textSecondary)), + ), + Text(statusText, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: statusColor)), + if (lic.daysRemaining != null && + lic.daysRemaining! <= 30) ...[ + const SizedBox(width: 8), + Chip( + label: Text( + lic.isExpired + ? '已过期' + : '剩余 ${lic.daysRemaining} 天', + style: const TextStyle( + fontSize: 11, color: Colors.white), + ), + backgroundColor: + lic.isExpired ? AppTheme.danger : Colors.orange, + padding: EdgeInsets.zero, + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + ), + ], + ], + ), + ), + if (lic.expiresAt != null) + _ParamRow( + label: '到期时间', + value: DateFormat('yyyy-MM-dd').format(lic.expiresAt!)) + else + const _ParamRow(label: '到期时间', value: '永久有效'), + if (lic.activatedAt != null) + _ParamRow( + label: '激活时间', + value: DateFormat('yyyy-MM-dd').format(lic.activatedAt!)), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _showRenewLicenseDialog, + icon: const Icon(Icons.card_membership, size: 16), + label: const Text('续费 / 升级授权'), + ), + ], + ); + }, ), ), - data: (warehouses) { - if (warehouses.isEmpty) { - return const Center( - child: Text('暂无仓库', - style: - TextStyle(color: AppTheme.textSecondary))); - } - return SingleChildScrollView( - child: DataTable( - headingRowColor: - WidgetStateProperty.all(const Color(0xFFF0F4FF)), - columns: const [ - DataColumn(label: Text('仓库名称')), - DataColumn(label: Text('位置')), - DataColumn(label: Text('默认仓库')), - DataColumn(label: Text('操作')), - ], - rows: warehouses - .map((w) => DataRow(cells: [ - DataCell(Text(w.name, - style: const TextStyle( - fontWeight: FontWeight.w500))), - DataCell(Text(w.location ?? '-')), - DataCell(w.isDefault - ? const Icon(Icons.check_circle, - color: AppTheme.success, size: 18) - : const SizedBox()), - DataCell(Row( - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - key: Key('btn_edit_${w.id}'), - onPressed: () => - _showWarehouseDialog(context, - warehouse: w), - child: const Text('编辑', - style: TextStyle(fontSize: 12)), - ), - TextButton( - key: Key('btn_delete_${w.id}'), - onPressed: () => - _confirmDeleteWarehouse(context, w), - child: const Text('删除', - style: TextStyle( - fontSize: 12, - color: AppTheme.danger)), - ), - ], - )), - ])) - .toList(), - ), - ); - }, - ), - ), - ], - ); - } - - Future _confirmDeleteWarehouse( - BuildContext context, Warehouse w) async { - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('确认删除'), - content: Text('确认删除仓库「${w.name}」?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: const Text('取消')), - ElevatedButton( - onPressed: () => Navigator.of(ctx).pop(true), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.danger, - foregroundColor: Colors.white), - child: const Text('删除'), ), ], ), ); - if (confirmed == true && mounted) { - try { - await ref - .read(warehouseListProvider.notifier) - .deleteWarehouse(w.id); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text('删除成功'), - backgroundColor: AppTheme.success)); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('删除失败:$e'), - backgroundColor: AppTheme.danger)); - } - } - } } - void _showWarehouseDialog(BuildContext context, {Warehouse? warehouse}) { + void _showRenewLicenseDialog() { showAppDialog( context: context, - builder: (ctx) => _WarehouseFormDialog( - warehouse: warehouse, - onSaved: () => - ref.read(warehouseListProvider.notifier).reload(), + builder: (ctx) => AlertDialog( + title: const Text('续费 / 升级授权'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('请联系我们获取续费报价:'), + const SizedBox(height: 12), + SelectableText('📧 ${AppInfo.email}', + style: const TextStyle(fontSize: 13)), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('关闭'), + ), + ElevatedButton( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: AppInfo.email)); + if (ctx.mounted) { + Navigator.pop(ctx); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('邮箱已复制到剪贴板')), + ); + } + }, + child: const Text('复制邮箱'), + ), + ], ), ); } @@ -760,133 +759,6 @@ class _SettingsScreenState extends ConsumerState { } } -class _WarehouseFormDialog extends ConsumerStatefulWidget { - final Warehouse? warehouse; - final VoidCallback onSaved; - - const _WarehouseFormDialog({this.warehouse, required this.onSaved}); - - @override - ConsumerState<_WarehouseFormDialog> createState() => - _WarehouseFormDialogState(); -} - -class _WarehouseFormDialogState extends ConsumerState<_WarehouseFormDialog> { - final _formKey = GlobalKey(); - late final TextEditingController _nameCtrl; - late final TextEditingController _locationCtrl; - late bool _isDefault; - bool _saving = false; - - @override - void initState() { - super.initState(); - _nameCtrl = TextEditingController(text: widget.warehouse?.name ?? ''); - _locationCtrl = - TextEditingController(text: widget.warehouse?.location ?? ''); - _isDefault = widget.warehouse?.isDefault ?? false; - } - - @override - void dispose() { - _nameCtrl.dispose(); - _locationCtrl.dispose(); - super.dispose(); - } - - Future _save() async { - if (!_formKey.currentState!.validate()) return; - setState(() => _saving = true); - final data = { - 'name': _nameCtrl.text.trim(), - if (_locationCtrl.text.trim().isNotEmpty) - 'location': _locationCtrl.text.trim(), - 'is_default': _isDefault, - }; - try { - final notifier = ref.read(warehouseListProvider.notifier); - if (widget.warehouse != null) { - await notifier.updateWarehouse(widget.warehouse!.id, data); - } else { - await notifier.createWarehouse(data); - } - if (mounted) { - Navigator.of(context).pop(); - widget.onSaved(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - widget.warehouse != null ? '仓库更新成功' : '仓库创建成功'), - backgroundColor: AppTheme.success, - ), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('保存失败:$e'), - backgroundColor: AppTheme.danger), - ); - } - } finally { - if (mounted) setState(() => _saving = false); - } - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Text(widget.warehouse != null ? '编辑仓库' : '新建仓库'), - content: SizedBox( - width: context.dialogWidth(400), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextFormField( - controller: _nameCtrl, - decoration: const InputDecoration(labelText: '仓库名称'), - validator: (v) => - (v == null || v.isEmpty) ? '不能为空' : null, - ), - const SizedBox(height: 12), - TextFormField( - controller: _locationCtrl, - decoration: const InputDecoration(labelText: '位置'), - ), - const SizedBox(height: 12), - CheckboxListTile( - title: const Text('设为默认仓库'), - value: _isDefault, - onChanged: (v) => setState(() => _isDefault = v ?? false), - contentPadding: EdgeInsets.zero, - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('取消'), - ), - ElevatedButton( - onPressed: _saving ? null : _save, - child: _saving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white)) - : const Text('保存'), - ), - ], - ); - } -} - // ── 新增/编辑用户弹窗 ───────────────────────────────────── class _UserFormDialog extends ConsumerStatefulWidget { final AppUser? user; diff --git a/client/lib/screens/stock_in/stock_in_form_screen.dart b/client/lib/screens/stock_in/stock_in_form_screen.dart index b27c399..6f3ce59 100644 --- a/client/lib/screens/stock_in/stock_in_form_screen.dart +++ b/client/lib/screens/stock_in/stock_in_form_screen.dart @@ -14,6 +14,7 @@ import '../../providers/inventory_provider.dart'; import '../../providers/stock_in_provider.dart'; import '../../providers/warehouse_provider.dart'; import '../../widgets/searchable_option_field.dart'; +import '../../widgets/mobile_list_card.dart'; class StockInFormScreen extends ConsumerStatefulWidget { final int? editOrderId; @@ -296,6 +297,7 @@ class _StockInFormScreenState extends ConsumerState { final asyncWarehouses = ref.watch(warehouseListProvider); final asyncSuppliers = ref.watch(supplierListProvider); final currentUser = ref.watch(authStateProvider).user; + final isMobile = context.isMobile; return Scaffold( backgroundColor: AppTheme.background, @@ -316,35 +318,71 @@ class _StockInFormScreenState extends ConsumerState { Text(_isEdit ? '修改入库单' : '新建入库单', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const Spacer(), - if (_isEdit && _loadedOrder != null) ...[ - OutlinedButton.icon( - onPressed: _printOrder, - icon: const Icon(Icons.print_outlined, size: 16), - label: const Text('打印'), + // 窄屏:仅保留主操作「提交」,其余收进溢出菜单,避免按钮挤爆顶栏 + if (isMobile) ...[ + ElevatedButton( + onPressed: _submitting ? null : () => _submit(false), + child: _submitting + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Text('提交'), + ), + PopupMenuButton( + onSelected: (v) { + switch (v) { + case 'draft': + _submit(true); + break; + case 'print': + _printOrder(); + break; + case 'cancel': + context.go('/stock-in'); + break; + } + }, + itemBuilder: (_) => [ + const PopupMenuItem(value: 'draft', child: Text('保存草稿')), + if (_isEdit && _loadedOrder != null) + const PopupMenuItem(value: 'print', child: Text('打印')), + const PopupMenuItem(value: 'cancel', child: Text('取消')), + ], + ), + ] else ...[ + if (_isEdit && _loadedOrder != null) ...[ + OutlinedButton.icon( + onPressed: _printOrder, + icon: const Icon(Icons.print_outlined, size: 16), + label: const Text('打印'), + ), + const SizedBox(width: 8), + ], + 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('取消'), + ), ], - 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('取消'), - ), ], ), ), @@ -498,41 +536,53 @@ class _StockInFormScreenState extends ConsumerState { ], ), const SizedBox(height: 12), - Table( - columnWidths: const { - 0: FixedColumnWidth(36), // 序号 - 1: FlexColumnWidth(1.2), // 商品编码 - 2: FlexColumnWidth(2.0), // 名称 - 3: FlexColumnWidth(1.3), // 系列 - 4: FlexColumnWidth(1.3), // 规格 - 5: FlexColumnWidth(0.9), // 单品数量 - 6: FlexColumnWidth(1.0), // 数量 - 7: FlexColumnWidth(1.0), // 单价 - 8: FlexColumnWidth(1.0), // 金额 - 9: FlexColumnWidth(1.2), // 批次号 - 10: FlexColumnWidth(1.2), // 生产日期 - 11: 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)), - ], - ), + // 窄屏:逐项卡片竖排,避免 12 列表格横向溢出;宽屏保持表格 + if (isMobile) + Column( + children: List.generate( + _items.length, + (i) => Padding( + padding: + const EdgeInsets.only(bottom: 10), + child: _buildItemCard(i), + )), + ) + else + Table( + columnWidths: const { + 0: FixedColumnWidth(36), // 序号 + 1: FlexColumnWidth(1.2), // 商品编码 + 2: FlexColumnWidth(2.0), // 名称 + 3: FlexColumnWidth(1.3), // 系列 + 4: FlexColumnWidth(1.3), // 规格 + 5: FlexColumnWidth(0.9), // 单品数量 + 6: FlexColumnWidth(1.0), // 数量 + 7: FlexColumnWidth(1.0), // 单价 + 8: FlexColumnWidth(1.0), // 金额 + 9: FlexColumnWidth(1.2), // 批次号 + 10: FlexColumnWidth(1.2), // 生产日期 + 11: 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), Padding( padding: const EdgeInsets.only(top: 12), @@ -566,104 +616,191 @@ class _StockInFormScreenState extends ConsumerState { ); } + // ── 明细字段构件(表格行与移动卡片共用,保持两种布局一致)────────── + + Widget _nameField(_ItemRow item) { + final asyncNames = ref.watch(productNameListProvider); + return asyncNames.when( + loading: () => const LinearProgressIndicator(), + error: (_, __) => const Text('加载失败'), + data: (names) => SearchableOptionField( + options: names + .map((o) => OptionItem(id: o.id, name: o.name, code: o.code)) + .toList(), + selectedId: item.selectedNameId, + hint: '选择名称', + dialogTitle: '选择商品名称', + isRequired: true, + onChanged: (v) => setState(() { + item.selectedNameId = v; + item.productId = null; + }), + ), + ); + } + + Widget _seriesField(_ItemRow item) { + final asyncSeries = ref.watch(productSeriesListProvider); + return asyncSeries.when( + loading: () => const LinearProgressIndicator(), + error: (_, __) => const Text('加载失败'), + data: (series) => SearchableOptionField( + options: series + .map((o) => OptionItem(id: o.id, name: o.name, code: o.code)) + .toList(), + selectedId: item.selectedSeriesId, + hint: '选择系列', + dialogTitle: '选择系列', + isRequired: true, + onChanged: (v) => setState(() { + item.selectedSeriesId = v; + item.productId = null; + }), + ), + ); + } + + Widget _specField(_ItemRow item) { + final asyncSpecs = ref.watch(productSpecListProvider); + return asyncSpecs.when( + loading: () => const LinearProgressIndicator(), + error: (_, __) => const Text('加载失败'), + data: (specs) => SearchableOptionField( + options: specs + .map((o) => OptionItem(id: o.id, name: o.name, code: o.code)) + .toList(), + selectedId: item.selectedSpecId, + hint: '选择规格', + dialogTitle: '选择规格', + isRequired: true, + onChanged: (v) => setState(() { + item.selectedSpecId = v; + item.productId = null; + }), + ), + ); + } + + Widget _qtyField(_ItemRow item) { + return TextFormField( + controller: item.qtyCtrl, + decoration: const InputDecoration(hintText: '0', isDense: true), + 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; + }, + ); + } + + Widget _priceField(_ItemRow item) { + return TextFormField( + controller: item.priceCtrl, + decoration: const InputDecoration( + hintText: '0.00', prefixText: '¥', isDense: true), + 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; + }, + ); + } + + Widget _batchField(_ItemRow item) { + return TextFormField( + controller: item.batchNoCtrl, + decoration: const InputDecoration( + hintText: '选填', + labelText: '批次号', + isDense: true, + ), + style: const TextStyle(fontSize: 13), + onChanged: (_) => setState(() {}), + ); + } + + Widget _dateField(_ItemRow item) { + return TextFormField( + controller: item.productionDateCtrl, + readOnly: true, + decoration: const InputDecoration( + hintText: '请选择', + labelText: '* 生产日期', + isDense: true, + suffixIcon: Icon(Icons.calendar_today, size: 14), + ), + style: const TextStyle(fontSize: 13), + onTap: () async { + final date = await showDatePicker( + context: context, + initialDate: item.productionDate ?? DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime(2100), + locale: const Locale('zh', 'CN'), + ); + if (date != null) { + setState(() { + item.productionDate = date; + item.productionDateCtrl.text = + '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + }); + } + }, + ); + } + + int _specQtyOf(_ItemRow item) => + ref.read(productSpecListProvider).valueOrNull + ?.where((o) => o.id == item.selectedSpecId) + .firstOrNull + ?.quantity ?? + 0; + + String _productCodeOf(_ItemRow item) => + ref.read(productNameListProvider).valueOrNull + ?.where((o) => o.id == item.selectedNameId) + .firstOrNull + ?.code ?? + ''; + TableRow _buildItemRow(int index) { final item = _items[index]; - final asyncNames = ref.watch(productNameListProvider); - final asyncSeries = ref.watch(productSeriesListProvider); - final asyncSpecs = ref.watch(productSpecListProvider); final qty = double.tryParse(item.qtyCtrl.text) ?? 0; final price = double.tryParse(item.priceCtrl.text) ?? 0; final amount = qty * price; - final specQty = asyncSpecs.valueOrNull - ?.where((o) => o.id == item.selectedSpecId) - .firstOrNull - ?.quantity ?? 0; - final productCode = asyncNames.valueOrNull - ?.where((o) => o.id == item.selectedNameId) - .firstOrNull - ?.code ?? ''; + final specQty = _specQtyOf(item); + final productCode = _productCodeOf(item); return TableRow( decoration: BoxDecoration( color: index.isEven ? Colors.white : const Color(0xFFFAFAFA), ), children: [ - // 序号 Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), child: Text('${index + 1}', style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)), ), - // 商品编码 Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), child: Text(productCode, style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)), ), - // 名称 - Padding( - padding: const EdgeInsets.all(4), - child: asyncNames.when( - loading: () => const LinearProgressIndicator(), - error: (_, __) => const Text('加载失败'), - data: (names) => SearchableOptionField( - options: names - .map((o) => OptionItem(id: o.id, name: o.name, code: o.code)) - .toList(), - selectedId: item.selectedNameId, - hint: '选择名称', - dialogTitle: '选择商品名称', - isRequired: true, - onChanged: (v) => setState(() { - item.selectedNameId = v; - item.productId = null; - }), - ), - ), - ), - // 系列 - Padding( - padding: const EdgeInsets.all(4), - child: asyncSeries.when( - loading: () => const LinearProgressIndicator(), - error: (_, __) => const Text('加载失败'), - data: (series) => SearchableOptionField( - options: series - .map((o) => OptionItem(id: o.id, name: o.name, code: o.code)) - .toList(), - selectedId: item.selectedSeriesId, - hint: '选择系列', - dialogTitle: '选择系列', - isRequired: true, - onChanged: (v) => setState(() { - item.selectedSeriesId = v; - item.productId = null; - }), - ), - ), - ), - // 规格 - Padding( - padding: const EdgeInsets.all(4), - child: asyncSpecs.when( - loading: () => const LinearProgressIndicator(), - error: (_, __) => const Text('加载失败'), - data: (specs) => SearchableOptionField( - options: specs - .map((o) => OptionItem(id: o.id, name: o.name, code: o.code)) - .toList(), - selectedId: item.selectedSpecId, - hint: '选择规格', - dialogTitle: '选择规格', - isRequired: true, - onChanged: (v) => setState(() { - item.selectedSpecId = v; - item.productId = null; - }), - ), - ), - ), - // 单品数量 + Padding(padding: const EdgeInsets.all(4), child: _nameField(item)), + Padding(padding: const EdgeInsets.all(4), child: _seriesField(item)), + Padding(padding: const EdgeInsets.all(4), child: _specField(item)), Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), child: Text( @@ -674,46 +811,8 @@ class _StockInFormScreenState extends ConsumerState { ), ), ), - // 数量 - Padding( - padding: const EdgeInsets.all(4), - child: TextFormField( - controller: item.qtyCtrl, - decoration: const InputDecoration(hintText: '0', isDense: true), - 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; - }, - ), - ), - // 单价 - Padding( - padding: const EdgeInsets.all(4), - child: TextFormField( - controller: item.priceCtrl, - decoration: const InputDecoration( - hintText: '0.00', prefixText: '¥', isDense: true), - 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; - }, - ), - ), - // 金额 + Padding(padding: const EdgeInsets.all(4), child: _qtyField(item)), + Padding(padding: const EdgeInsets.all(4), child: _priceField(item)), Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), child: Text( @@ -721,52 +820,8 @@ class _StockInFormScreenState extends ConsumerState { style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), ), ), - // 批次号 - Padding( - padding: const EdgeInsets.all(4), - child: TextFormField( - controller: item.batchNoCtrl, - decoration: const InputDecoration( - hintText: '选填', - labelText: '批次号', - isDense: true, - ), - style: const TextStyle(fontSize: 13), - onChanged: (_) => setState(() {}), - ), - ), - // 生产日期 - Padding( - padding: const EdgeInsets.all(4), - child: TextFormField( - controller: item.productionDateCtrl, - readOnly: true, - decoration: const InputDecoration( - hintText: '请选择', - labelText: '* 生产日期', - isDense: true, - suffixIcon: Icon(Icons.calendar_today, size: 14), - ), - style: const TextStyle(fontSize: 13), - onTap: () async { - final date = await showDatePicker( - context: context, - initialDate: item.productionDate ?? DateTime.now(), - firstDate: DateTime(2000), - lastDate: DateTime(2100), - locale: const Locale('zh', 'CN'), - ); - if (date != null) { - setState(() { - item.productionDate = date; - item.productionDateCtrl.text = - '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; - }); - } - }, - ), - ), - // 操作 + Padding(padding: const EdgeInsets.all(4), child: _batchField(item)), + Padding(padding: const EdgeInsets.all(4), child: _dateField(item)), Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), child: IconButton( @@ -781,6 +836,38 @@ class _StockInFormScreenState extends ConsumerState { ); } + /// 窄屏(手机):每个明细行渲染为一张卡片,字段竖排,避免表格横向溢出。 + Widget _buildItemCard(int index) { + final item = _items[index]; + final qty = double.tryParse(item.qtyCtrl.text) ?? 0; + final price = double.tryParse(item.priceCtrl.text) ?? 0; + final amount = qty * price; + final specQty = _specQtyOf(item); + final productCode = _productCodeOf(item); + + return MobileListCard( + title: Text('商品 ${index + 1}'), + subtitle: productCode.isNotEmpty ? Text('编码 $productCode') : null, + trailing: IconButton( + icon: const Icon(Icons.delete_outline, size: 20, color: AppTheme.danger), + onPressed: _items.length > 1 ? () => _removeItem(index) : null, + tooltip: '删除', + visualDensity: VisualDensity.compact, + ), + fields: [ + MobileCardField('名称', null, valueWidget: _nameField(item)), + MobileCardField('系列', null, valueWidget: _seriesField(item)), + MobileCardField('规格', null, valueWidget: _specField(item)), + MobileCardField('单品数量', specQty > 0 ? '$specQty' : '-'), + MobileCardField('数量', null, valueWidget: _qtyField(item)), + MobileCardField('单价', null, valueWidget: _priceField(item)), + MobileCardField('金额', '¥${amount.toStringAsFixed(2)}'), + MobileCardField('批次号', null, valueWidget: _batchField(item)), + MobileCardField('生产日期', null, valueWidget: _dateField(item)), + ], + ); + } + Widget _buildInventoryCell(int? productId) { if (productId == null) { return const Padding( diff --git a/client/lib/screens/stock_out/stock_out_form_screen.dart b/client/lib/screens/stock_out/stock_out_form_screen.dart index fda8778..b0220ff 100644 --- a/client/lib/screens/stock_out/stock_out_form_screen.dart +++ b/client/lib/screens/stock_out/stock_out_form_screen.dart @@ -11,6 +11,7 @@ import '../../providers/inventory_provider.dart'; import '../../providers/partner_provider.dart'; import '../../providers/stock_out_provider.dart'; import '../../providers/warehouse_provider.dart'; +import '../../widgets/mobile_list_card.dart'; // Aggregated per-product inventory item for the picker dialog class _PickerItem { @@ -307,6 +308,7 @@ class _StockOutFormScreenState extends ConsumerState { final asyncWarehouses = ref.watch(warehouseListProvider); final asyncCustomers = ref.watch(customerListProvider); final currentUser = ref.watch(authStateProvider).user; + final isMobile = context.isMobile; return Scaffold( backgroundColor: AppTheme.background, @@ -327,35 +329,71 @@ class _StockOutFormScreenState extends ConsumerState { Text(_isEdit ? '修改出库单' : '新建出库单', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const Spacer(), - if (_isEdit && _loadedOrder != null) ...[ - OutlinedButton.icon( - onPressed: _printOrder, - icon: const Icon(Icons.print_outlined, size: 16), - label: const Text('打印'), + // 窄屏:仅保留主操作「提交」,其余收进溢出菜单,避免按钮挤爆顶栏 + if (isMobile) ...[ + ElevatedButton( + onPressed: _submitting ? null : () => _submit(false), + child: _submitting + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Text('提交'), + ), + PopupMenuButton( + onSelected: (v) { + switch (v) { + case 'draft': + _submit(true); + break; + case 'print': + _printOrder(); + break; + case 'cancel': + context.go('/stock-out'); + break; + } + }, + itemBuilder: (_) => [ + const PopupMenuItem(value: 'draft', child: Text('保存草稿')), + if (_isEdit && _loadedOrder != null) + const PopupMenuItem(value: 'print', child: Text('打印')), + const PopupMenuItem(value: 'cancel', child: Text('取消')), + ], + ), + ] else ...[ + if (_isEdit && _loadedOrder != null) ...[ + OutlinedButton.icon( + onPressed: _printOrder, + icon: const Icon(Icons.print_outlined, size: 16), + label: const Text('打印'), + ), + const SizedBox(width: 8), + ], + 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-out'), + icon: const Icon(Icons.cancel_outlined, size: 16), + label: const Text('取消'), + ), ], - 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-out'), - icon: const Icon(Icons.cancel_outlined, size: 16), - label: const Text('取消'), - ), ], ), ), @@ -507,38 +545,50 @@ class _StockOutFormScreenState extends ConsumerState { ], ), const SizedBox(height: 12), - Table( - columnWidths: const { - 0: FixedColumnWidth(36), // 序号 - 1: FlexColumnWidth(1.2), // 商品编码 - 2: FlexColumnWidth(2.0), // 商品名称 - 3: FlexColumnWidth(1.2), // 系列 - 4: FlexColumnWidth(1.2), // 规格 - 5: FlexColumnWidth(1.0), // 单价 - 6: FlexColumnWidth(0.8), // 数量 - 7: FlexColumnWidth(1.0), // 金额 - 8: FixedColumnWidth(48), // 操作 - }, - 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)), - ], - ), + // 窄屏:逐项卡片竖排,避免 9 列表格横向溢出;宽屏保持表格 + if (isMobile) + Column( + children: List.generate( + _items.length, + (i) => Padding( + padding: + const EdgeInsets.only(bottom: 10), + child: _buildItemCard(i), + )), + ) + else + Table( + columnWidths: const { + 0: FixedColumnWidth(36), // 序号 + 1: FlexColumnWidth(1.2), // 商品编码 + 2: FlexColumnWidth(2.0), // 商品名称 + 3: FlexColumnWidth(1.2), // 系列 + 4: FlexColumnWidth(1.2), // 规格 + 5: FlexColumnWidth(1.0), // 单价 + 6: FlexColumnWidth(0.8), // 数量 + 7: FlexColumnWidth(1.0), // 金额 + 8: FixedColumnWidth(48), // 操作 + }, + 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)), + ], + ), if (_items.isEmpty) const Padding( padding: EdgeInsets.symmetric(vertical: 24), @@ -583,7 +633,6 @@ class _StockOutFormScreenState extends ConsumerState { final qty = double.tryParse(item.qtyCtrl.text) ?? 0; final price = item.unitPrice ?? 0; final amount = qty * price; - final available = _inventoryMap[item.productId]; return TableRow( decoration: BoxDecoration( @@ -603,22 +652,7 @@ class _StockOutFormScreenState extends ConsumerState { // 单价 _cell(Text(price > 0 ? '¥${price.toStringAsFixed(2)}' : '-', style: const TextStyle(fontSize: 13))), // 数量 - Padding( - padding: const EdgeInsets.all(4), - child: TextFormField( - controller: item.qtyCtrl, - decoration: const InputDecoration(hintText: '0', isDense: true), - 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; - }, - ), - ), + Padding(padding: const EdgeInsets.all(4), child: _qtyField(item)), // 金额 _cell(Text('¥${amount.toStringAsFixed(2)}', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500))), // 操作 @@ -636,6 +670,48 @@ class _StockOutFormScreenState extends ConsumerState { ); } + Widget _qtyField(_ItemRow item) { + return TextFormField( + controller: item.qtyCtrl, + decoration: const InputDecoration(hintText: '0', isDense: true), + 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; + }, + ); + } + + /// 窄屏(手机):每个明细行渲染为一张卡片,字段竖排,避免表格横向溢出。 + Widget _buildItemCard(int index) { + final item = _items[index]; + final qty = double.tryParse(item.qtyCtrl.text) ?? 0; + final price = item.unitPrice ?? 0; + final amount = qty * price; + + return MobileListCard( + title: Text(item.productName), + subtitle: item.productCode.isNotEmpty ? Text('编码 ${item.productCode}') : null, + trailing: IconButton( + icon: const Icon(Icons.delete_outline, size: 20, color: AppTheme.danger), + onPressed: () => _removeItem(index), + tooltip: '删除', + visualDensity: VisualDensity.compact, + ), + fields: [ + if (item.series.isNotEmpty) MobileCardField('系列', item.series), + if (item.spec.isNotEmpty) MobileCardField('规格', item.spec), + MobileCardField('单价', price > 0 ? '¥${price.toStringAsFixed(2)}' : '-'), + MobileCardField('数量', null, valueWidget: _qtyField(item)), + MobileCardField('金额', '¥${amount.toStringAsFixed(2)}'), + ], + ); + } + Widget _cell(Widget child) => Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), child: child,