feat(client): 移动端布局适配 + 信息架构整理

- 入库/出库详情页:窄屏商品明细切换为卡片竖排,顶部操作收入溢出菜单
- 基础数据:新增「仓库」tab,使用 DataTableCard + mobileCards 适配窄屏
- 系统设置:移除仓库 tab(已迁入基础数据),新增「授权」tab,数据导入改名「数据管理」
- 关于我们:移除授权信息,补充帮助与文档/扫码防伪/法律信息/系统信息等模块
- AppInfo 新增 phone/wechat/termsUrl/privacyUrl/docsUrl 字段(可选,空则不渲染)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-07 20:16:34 +08:00
parent e550b73d6d
commit ced407ea87
7 changed files with 975 additions and 678 deletions
@@ -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<StockInFormScreen> {
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<StockInFormScreen> {
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<String>(
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<StockInFormScreen> {
],
),
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<StockInFormScreen> {
);
}
// ── 明细字段构件(表格行与移动卡片共用,保持两种布局一致)──────────
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<StockInFormScreen> {
),
),
),
// 数量
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<StockInFormScreen> {
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<StockInFormScreen> {
);
}
/// 窄屏(手机):每个明细行渲染为一张卡片,字段竖排,避免表格横向溢出。
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(