feat(client): 初版 Flutter UI 框架,仿照参考截图实现

- 蓝色顶栏(#1565C0)+ 深蓝侧边栏(#0D47A1),含折叠/展开
- 底部状态栏:门店编号、登录用户、登录时间、实时时钟、版本号
- 登录页:居中卡片,支持密码显示/隐藏
- 入库单列表:筛选/搜索/日期选择/分页,三个 Tab(入库单/查询/审核)
- 新建入库单:基本信息 + 商品明细动态增删,实时计算合计金额
- 出库单列表:同入库单风格
- 库存查询:4 张统计卡片 + 颜色高亮缺货/预警行,库存预警 Tab
- 往来单位、财务、商品、系统设置(用户/仓库/编号规则/系统参数)
- 使用 Riverpod 状态管理 + go_router 路由 + Dio HTTP

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-04-04 12:51:41 +08:00
parent d6397083c0
commit 76ab19ea47
22 changed files with 6386 additions and 0 deletions
@@ -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<ProductsScreen> createState() => _ProductsScreenState();
}
class _ProductsScreenState extends ConsumerState<ProductsScreen> {
int _productsPage = 1;
int _categoriesPage = 1;
int _warehousesPage = 1;
final _searchCtrl = TextEditingController();
String _categoryFilter = '全部';
final List<Map<String, dynamic>> _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': '泸州老窖(国窖157352度500ml',
'category': '白酒',
'brand': '泸州老窖',
'spec': '500ml/瓶',
'unit': '',
'barcode': '6910123456789',
'costPrice': '680.00',
'salePrice': '860.00',
'minStock': 20,
'maxStock': 200,
'status': '启用',
},
{
'code': 'SP006',
'name': '汾酒(青花3053度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<Map<String, dynamic>> _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<Map<String, dynamic>> _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<Map<String, dynamic>> 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<String>(
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<String>(
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<String> items;
final ValueChanged<String?> 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<String>(
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),
),
),
);
}
}