feat: 商品详情页、XLS导入修复、分页选择器、导出功能

后端:
- 新增 product_images 表,支持每商品最多5张图(服务端压缩至1200px/JPEG85%)
- products 表新增 public_id(UUID)、description 字段
- 新增商品详情接口、二维码接口、公开商品接口(无鉴权)
- 修复 XLS 导入:OLE2 magic bytes 检测 + 临时文件解析,兼容 extrame/xls
- 修复商品/名称/系列/规格三张表导入数据为0(LastCol()=0 bug)
- 所有导入接口返回 total/imported/skipped 统计
- config 新增 StorageConfig,支持 STORAGE_* 环境变量覆盖
- 种子数据修复:products 补 public_id、新增 product_images TRUNCATE、schema.sql 表名修正

前端:
- 商品详情页:图片上传/删除、描述内联编辑、二维码弹窗、公开链接复制
- 公开商品页:无鉴权路由 /product/:public_id,Flutter Web SPA
- 商品详情列表(批次追踪)商品名超链接跳转详情页
- 导航「商品管理」改名「商品详情」
- 所有列表表格新增每页条数选择(10/20/50/100)
- 表格列头内嵌筛选(FilterableColumnHeader)
- 导出 Excel 功能(入库/出库/库存/财务/批次/往来单位)
- 网络恢复自动刷新 + 离线缓存展示

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-04-27 00:29:51 +08:00
parent 5dd7c07138
commit 393e227de5
70 changed files with 4993 additions and 1169 deletions
@@ -0,0 +1,541 @@
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/config/app_config.dart';
import '../../core/theme/app_theme.dart';
import '../../models/product.dart';
import '../../models/product_image.dart';
import '../../providers/product_provider.dart';
class ProductDetailScreen extends ConsumerStatefulWidget {
final int productId;
const ProductDetailScreen({super.key, required this.productId});
@override
ConsumerState<ProductDetailScreen> createState() =>
_ProductDetailScreenState();
}
class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
late Future<Product> _future;
Product? _product;
bool _uploading = false;
bool _savingDesc = false;
bool _descChanged = false;
late TextEditingController _descCtrl;
@override
void initState() {
super.initState();
_descCtrl = TextEditingController();
_descCtrl.addListener(() {
final changed = _descCtrl.text != (_product?.description ?? '');
if (changed != _descChanged) setState(() => _descChanged = changed);
});
_load();
}
@override
void dispose() {
_descCtrl.dispose();
super.dispose();
}
void _load() {
_future = ref
.read(productRepositoryProvider)
.getDetail(widget.productId)
.then((p) {
_product = p;
_descCtrl.text = p.description ?? '';
_descChanged = false;
return p;
});
}
void _reload() => setState(() => _load());
Future<void> _saveDesc() async {
if (_product == null) return;
setState(() => _savingDesc = true);
try {
final updated = await ref.read(productRepositoryProvider).update(
_product!.id,
{..._product!.toJson(), 'description': _descCtrl.text.trim()},
);
setState(() {
_product = updated;
_descCtrl.text = updated.description ?? '';
_descChanged = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('描述已保存')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存失败:$e'), backgroundColor: AppTheme.danger),
);
}
} finally {
if (mounted) setState(() => _savingDesc = false);
}
}
Future<void> _pickAndUpload() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.image,
allowMultiple: false,
);
if (result == null || result.files.isEmpty) return;
final path = result.files.first.path;
if (path == null) return;
setState(() => _uploading = true);
try {
final img = await ref
.read(productRepositoryProvider)
.uploadImage(_product!.id, path);
_updateImages([..._product!.images, img]);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('图片上传成功')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('上传失败:$e'), backgroundColor: AppTheme.danger),
);
}
} finally {
if (mounted) setState(() => _uploading = false);
}
}
Future<void> _deleteImage(ProductImage img) async {
try {
await ref.read(productRepositoryProvider).deleteImage(_product!.id, img.id);
_updateImages(_product!.images.where((i) => i.id != img.id).toList());
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('删除失败:$e'), backgroundColor: AppTheme.danger),
);
}
}
}
void _updateImages(List<ProductImage> images) {
final p = _product!;
setState(() {
_product = Product(
id: p.id, publicId: p.publicId, code: p.code, barcode: p.barcode,
name: p.name, series: p.series, spec: p.spec, unit: p.unit,
categoryId: p.categoryId, brand: p.brand, purchasePrice: p.purchasePrice,
salePrice: p.salePrice, minStock: p.minStock, description: p.description,
remark: p.remark, customFields: p.customFields, images: images,
);
});
}
void _showQRCode() {
showDialog(
context: context,
builder: (_) => _QRCodeDialog(productId: widget.productId),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: FutureBuilder<Product>(
future: _future,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting && _product == null) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError && _product == null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 40, color: AppTheme.danger),
const SizedBox(height: 12),
Text('加载失败:${snap.error}'),
const SizedBox(height: 12),
ElevatedButton(onPressed: _reload, child: const Text('重试')),
],
),
);
}
final p = _product!;
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题行
Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
tooltip: '返回',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 8),
Expanded(
child: Text(p.name,
style: const TextStyle(
fontSize: 20, fontWeight: FontWeight.w700)),
),
OutlinedButton.icon(
onPressed: _showQRCode,
icon: const Icon(Icons.qr_code, size: 16),
label: const Text('二维码'),
),
],
),
const SizedBox(height: 20),
_buildImageSection(p),
const SizedBox(height: 20),
_buildInfoSection(p),
const SizedBox(height: 20),
_buildDescSection(),
const SizedBox(height: 20),
_buildPublicLinkSection(p),
],
),
);
},
),
);
}
Widget _buildImageSection(Product p) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('图片',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textSecondary)),
const SizedBox(height: 8),
SizedBox(
height: 120,
child: Row(
children: [
...p.images.map((img) => _ImageThumbnail(
url: AppConfig.baseUrl + img.url,
onDelete: () => _deleteImage(img),
)),
if (p.images.length < 5)
_UploadButton(uploading: _uploading, onTap: _pickAndUpload),
],
),
),
if (p.images.isEmpty && !_uploading)
const Padding(
padding: EdgeInsets.only(top: 4),
child: Text('暂无图片,点击 + 上传',
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary)),
),
],
);
}
Widget _buildInfoSection(Product p) {
Widget row(String label, String value) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 56,
child: Text(label,
style: const TextStyle(
fontSize: 13, color: AppTheme.textSecondary))),
const SizedBox(width: 8),
Expanded(
child: Text(value, style: const TextStyle(fontSize: 13))),
],
),
);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
side: BorderSide(color: AppTheme.border, width: 0.5),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Wrap(
spacing: 32,
runSpacing: 0,
children: [
SizedBox(width: 200, child: row('编号', p.code.isEmpty ? '-' : p.code)),
SizedBox(width: 200, child: row('系列', p.series?.isEmpty ?? true ? '-' : p.series!)),
SizedBox(width: 200, child: row('规格', p.spec?.isEmpty ?? true ? '-' : p.spec!)),
SizedBox(width: 200, child: row('品牌', p.brand?.isEmpty ?? true ? '-' : p.brand!)),
SizedBox(width: 200, child: row('单位', p.unit.isEmpty ? '-' : p.unit)),
SizedBox(width: 200, child: row('进价', p.purchasePrice != null ? '¥${p.purchasePrice!.toStringAsFixed(2)}' : '-')),
SizedBox(width: 200, child: row('售价', p.salePrice != null ? '¥${p.salePrice!.toStringAsFixed(2)}' : '-')),
if (p.barcode?.isNotEmpty ?? false)
SizedBox(width: 200, child: row('条码', p.barcode!)),
],
),
),
);
}
Widget _buildDescSection() {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
side: BorderSide(color: AppTheme.border, width: 0.5),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text('描述',
style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600)),
const Spacer(),
if (_descChanged)
_savingDesc
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: ElevatedButton(
onPressed: _saveDesc,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap),
child: const Text('保存', style: TextStyle(fontSize: 13)),
),
],
),
const SizedBox(height: 12),
TextField(
controller: _descCtrl,
maxLines: 6,
minLines: 3,
decoration: InputDecoration(
hintText: '输入商品描述,例如酒的产地、工艺、口感等...',
hintStyle: const TextStyle(
fontSize: 13, color: AppTheme.textSecondary),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: BorderSide(color: AppTheme.border)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: BorderSide(color: AppTheme.border)),
contentPadding: const EdgeInsets.all(12),
),
),
],
),
),
);
}
Widget _buildPublicLinkSection(Product p) {
final publicUrl = 'https://jiu.51yanmei.com/product/${p.publicId}';
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
side: BorderSide(color: AppTheme.border, width: 0.5),
),
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: 12),
Row(
children: [
Expanded(
child: Text(publicUrl,
style: const TextStyle(
fontSize: 12,
color: AppTheme.primary,
fontFamily: 'monospace')),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () {
Clipboard.setData(ClipboardData(text: publicUrl));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('链接已复制')),
);
},
icon: const Icon(Icons.copy, size: 14),
label: const Text('复制'),
),
],
),
],
),
),
);
}
}
class _ImageThumbnail extends StatelessWidget {
final String url;
final VoidCallback onDelete;
const _ImageThumbnail({required this.url, required this.onDelete});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.network(
url,
width: 110,
height: 110,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
width: 110,
height: 110,
color: AppTheme.border,
child: const Icon(Icons.broken_image,
color: AppTheme.textSecondary),
),
),
),
Positioned(
top: 4,
right: 4,
child: GestureDetector(
onTap: onDelete,
child: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(10),
),
child:
const Icon(Icons.close, size: 14, color: Colors.white),
),
),
),
],
),
);
}
}
class _UploadButton extends StatelessWidget {
final bool uploading;
final VoidCallback onTap;
const _UploadButton({required this.uploading, required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: uploading ? null : onTap,
child: Container(
width: 110,
height: 110,
decoration: BoxDecoration(
border: Border.all(color: AppTheme.border, width: 1.5),
borderRadius: BorderRadius.circular(4),
color: AppTheme.background,
),
child: uploading
? const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2)))
: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_photo_alternate_outlined,
size: 28, color: AppTheme.textSecondary),
SizedBox(height: 4),
Text('上传图片',
style: TextStyle(
fontSize: 11, color: AppTheme.textSecondary)),
],
),
),
);
}
}
class _QRCodeDialog extends ConsumerStatefulWidget {
final int productId;
const _QRCodeDialog({required this.productId});
@override
ConsumerState<_QRCodeDialog> createState() => _QRCodeDialogState();
}
class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> {
late Future<Uint8List> _future;
@override
void initState() {
super.initState();
_future = ref
.read(productRepositoryProvider)
.getQRCodeBytes(widget.productId);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('商品二维码'),
content: SizedBox(
width: 200,
height: 200,
child: FutureBuilder<Uint8List>(
future: _future,
builder: (_, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(child: Text('加载失败:${snap.error}'));
}
return Image.memory(snap.data!);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('关闭'),
),
],
);
}
}
+355 -450
View File
@@ -1,12 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart';
import '../../models/product.dart';
import '../../providers/product_provider.dart';
import '../../providers/product_option_provider.dart';
import '../../widgets/data_table_card.dart';
import '../../widgets/multi_select_dropdown.dart' show FilterableColumnHeader;
import '../../widgets/page_scaffold.dart';
import '../../core/utils/export_util.dart';
class ProductsScreen extends ConsumerStatefulWidget {
const ProductsScreen({super.key});
@@ -16,493 +14,400 @@ class ProductsScreen extends ConsumerStatefulWidget {
}
class _ProductsScreenState extends ConsumerState<ProductsScreen> {
final _searchCtrl = TextEditingController();
Timer? _debounce;
Set<String> _filterBrand = {};
final _nameSearchCtrl = TextEditingController();
final _seriesSearchCtrl = TextEditingController();
final _specSearchCtrl = TextEditingController();
int _namePage = 1; int _namePageSize = 20;
int _seriesPage = 1; int _seriesPageSize = 20;
int _specPage = 1; int _specPageSize = 20;
@override
void dispose() {
_searchCtrl.dispose();
_debounce?.cancel();
_nameSearchCtrl.dispose();
_seriesSearchCtrl.dispose();
_specSearchCtrl.dispose();
super.dispose();
}
void _onSearchChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () {
ref.read(productListProvider.notifier).setKeyword(value);
});
}
void _showProductDialog(BuildContext context, {Product? product}) {
showDialog(
context: context,
builder: (ctx) => _ProductFormDialog(
product: product,
onSaved: () {
ref.read(productListProvider.notifier).reload();
},
),
);
}
Future<void> _confirmDelete(BuildContext context, Product product) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认删除'),
content: Text('确认删除商品「${product.name}」?此操作不可恢复。'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.danger,
foregroundColor: Colors.white),
child: const Text('删除'),
),
],
),
);
if (confirmed == true && mounted) {
try {
await ref
.read(productListProvider.notifier)
.deleteProduct(product.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('删除成功'), backgroundColor: AppTheme.success),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('删除失败:$e'),
backgroundColor: AppTheme.danger),
);
}
}
}
}
@override
Widget build(BuildContext context) {
return PageScaffold(
title: '基础数据',
tabs: const [
Tab(text: '商品档案'),
Tab(text: '商品名称'),
Tab(text: '系列'),
Tab(text: '规格'),
],
tabViews: [
_buildProductTab(),
_buildNameTab(),
_buildSeriesTab(),
_buildSpecTab(),
],
);
}
Widget _buildProductTab() {
final asyncProducts = ref.watch(productListProvider);
return asyncProducts.when(
// ── 商品名称 tab ───────────────────────────────────────────
Widget _buildNameTab() {
final async = ref.watch(productNameListProvider);
return async.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(productListProvider.notifier).reload(),
child: const Text('重试'),
error: (e, _) => _buildError(() => ref.read(productNameListProvider.notifier).reload()),
data: (items) {
final keyword = _nameSearchCtrl.text.toLowerCase();
final filtered = keyword.isEmpty
? items
: items.where((it) =>
it.name.toLowerCase().contains(keyword) ||
(it.code?.toLowerCase().contains(keyword) ?? false)).toList();
final paged = filtered.skip((_namePage - 1) * _namePageSize).take(_namePageSize).toList();
return DataTableCard(
totalCount: filtered.length,
page: _namePage,
pageSize: _namePageSize,
onPageChanged: (p) => setState(() => _namePage = p),
onPageSizeChanged: (s) => setState(() { _namePageSize = s; _namePage = 1; }),
toolbar: _buildToolbar(
searchCtrl: _nameSearchCtrl,
hint: '搜索名称/编号',
onSearchChanged: () => setState(() => _namePage = 1),
onAdd: () => _showCreateDialog(
title: '新建商品名称',
hasQuantity: false,
onSave: (data) => ref.read(productNameListProvider.notifier).create(data),
),
onExport: () => exportExcel(
filename: '商品名称',
headers: ['编号', '名称', '备注'],
rows: items.map((it) => [it.code ?? '', it.name, it.remark ?? '']).toList(),
),
),
columns: const [
DataColumn(label: Text('编号')),
DataColumn(label: Text('名称')),
DataColumn(label: Text('备注')),
DataColumn(label: Text('操作')),
],
),
),
data: (result) {
if (result.data.isEmpty) {
return _buildProductList([], result.total, result.page);
}
return _buildProductList(result.data, result.total, result.page);
rows: paged.isEmpty
? [DataRow(cells: [
const DataCell(SizedBox()),
const DataCell(Text('暂无数据', style: TextStyle(color: AppTheme.textSecondary))),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
])]
: paged.map((it) => DataRow(cells: [
DataCell(Text(it.code ?? '-',
style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))),
DataCell(Text(it.name, style: const TextStyle(fontWeight: FontWeight.w500))),
DataCell(Text(it.remark ?? '-')),
DataCell(_deleteButton(() => _confirmDelete(
'删除名称「${it.name}」?',
() => ref.read(productNameListProvider.notifier).delete(it.id),
))),
])).toList(),
);
},
);
}
Widget _buildProductList(
List<Product> products, int totalCount, int page) {
// 品牌选项从当前数据中派生,客户端筛选
final brandOptions = products
.map((p) => p.brand ?? '')
.where((s) => s.isNotEmpty)
.toSet()
.toList()
..sort();
final filteredProducts = _filterBrand.isEmpty
? products
: products.where((p) => _filterBrand.contains(p.brand ?? '')).toList();
// ── 系列 tab ──────────────────────────────────────────────
return DataTableCard(
totalCount: totalCount,
page: page,
onPageChanged: (p) =>
ref.read(productListProvider.notifier).setPage(p),
toolbar: Row(
children: [
ElevatedButton.icon(
onPressed: () => _showProductDialog(context),
icon: const Icon(Icons.add, size: 16),
label: const Text('新建'),
),
const Spacer(),
SizedBox(
width: 220,
child: TextField(
controller: _searchCtrl,
decoration: const InputDecoration(
hintText: '搜索商品名/编码/品牌',
prefixIcon: Icon(Icons.search, size: 16),
hintStyle: TextStyle(fontSize: 13),
),
onChanged: _onSearchChanged,
Widget _buildSeriesTab() {
final async = ref.watch(productSeriesListProvider);
return async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => _buildError(() => ref.read(productSeriesListProvider.notifier).reload()),
data: (items) {
final keyword = _seriesSearchCtrl.text.toLowerCase();
final filtered = keyword.isEmpty
? items
: items.where((it) =>
it.name.toLowerCase().contains(keyword) ||
(it.code?.toLowerCase().contains(keyword) ?? false)).toList();
final paged = filtered.skip((_seriesPage - 1) * _seriesPageSize).take(_seriesPageSize).toList();
return DataTableCard(
totalCount: filtered.length,
page: _seriesPage,
pageSize: _seriesPageSize,
onPageChanged: (p) => setState(() => _seriesPage = p),
onPageSizeChanged: (s) => setState(() { _seriesPageSize = s; _seriesPage = 1; }),
toolbar: _buildToolbar(
searchCtrl: _seriesSearchCtrl,
hint: '搜索系列/编号',
onSearchChanged: () => setState(() => _seriesPage = 1),
onAdd: () => _showCreateDialog(
title: '新建系列',
hasQuantity: false,
onSave: (data) => ref.read(productSeriesListProvider.notifier).create(data),
),
onExport: () => exportExcel(
filename: '商品系列',
headers: ['编号', '系列名称', '备注'],
rows: items.map((it) => [it.code ?? '', it.name, it.remark ?? '']).toList(),
),
),
columns: const [
DataColumn(label: Text('编号')),
DataColumn(label: Text('系列名称')),
DataColumn(label: Text('备注')),
DataColumn(label: Text('操作')),
],
rows: paged.isEmpty
? [DataRow(cells: [
const DataCell(SizedBox()),
const DataCell(Text('暂无数据', style: TextStyle(color: AppTheme.textSecondary))),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
])]
: paged.map((it) => DataRow(cells: [
DataCell(Text(it.code ?? '-',
style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))),
DataCell(Text(it.name, style: const TextStyle(fontWeight: FontWeight.w500))),
DataCell(Text(it.remark ?? '-')),
DataCell(_deleteButton(() => _confirmDelete(
'删除系列「${it.name}」?',
() => ref.read(productSeriesListProvider.notifier).delete(it.id),
))),
])).toList(),
);
},
);
}
// ── 规格 tab ──────────────────────────────────────────────
Widget _buildSpecTab() {
final async = ref.watch(productSpecListProvider);
return async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => _buildError(() => ref.read(productSpecListProvider.notifier).reload()),
data: (items) {
final keyword = _specSearchCtrl.text.toLowerCase();
final filtered = keyword.isEmpty
? items
: items.where((it) =>
it.name.toLowerCase().contains(keyword) ||
(it.code?.toLowerCase().contains(keyword) ?? false)).toList();
final paged = filtered.skip((_specPage - 1) * _specPageSize).take(_specPageSize).toList();
return DataTableCard(
totalCount: filtered.length,
page: _specPage,
pageSize: _specPageSize,
onPageChanged: (p) => setState(() => _specPage = p),
onPageSizeChanged: (s) => setState(() { _specPageSize = s; _specPage = 1; }),
toolbar: _buildToolbar(
searchCtrl: _specSearchCtrl,
hint: '搜索规格/编号',
onSearchChanged: () => setState(() => _specPage = 1),
onAdd: () => _showCreateDialog(
title: '新建规格',
hasQuantity: true,
onSave: (data) => ref.read(productSpecListProvider.notifier).create(data),
),
onExport: () => exportExcel(
filename: '商品规格',
headers: ['编号', '规格名称', '单品数量', '备注'],
rows: items.map((it) => [it.code ?? '', it.name, it.quantity, it.remark ?? '']).toList(),
),
),
columns: const [
DataColumn(label: Text('编号')),
DataColumn(label: Text('规格名称')),
DataColumn(label: Text('单品数量'), numeric: true),
DataColumn(label: Text('备注')),
DataColumn(label: Text('操作')),
],
rows: paged.isEmpty
? [DataRow(cells: [
const DataCell(SizedBox()),
const DataCell(Text('暂无数据', style: TextStyle(color: AppTheme.textSecondary))),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
])]
: paged.map((it) => DataRow(cells: [
DataCell(Text(it.code ?? '-',
style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))),
DataCell(Text(it.name, style: const TextStyle(fontWeight: FontWeight.w500))),
DataCell(Text(it.quantity > 0 ? '${it.quantity}' : '-')),
DataCell(Text(it.remark ?? '-')),
DataCell(_deleteButton(() => _confirmDelete(
'删除规格「${it.name}」?',
() => ref.read(productSpecListProvider.notifier).delete(it.id),
))),
])).toList(),
);
},
);
}
// ── 公共组件 ──────────────────────────────────────────────
Widget _buildToolbar({
required TextEditingController searchCtrl,
required String hint,
required VoidCallback onSearchChanged,
required VoidCallback onAdd,
required VoidCallback onExport,
}) {
return Row(
children: [
ElevatedButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add, size: 16),
label: const Text('新建'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: onExport,
icon: const Icon(Icons.download, size: 16),
label: const Text('导出'),
),
const Spacer(),
SizedBox(
width: 200,
child: TextField(
controller: searchCtrl,
decoration: InputDecoration(
hintText: hint,
prefixIcon: const Icon(Icons.search, size: 16),
hintStyle: const TextStyle(fontSize: 13),
),
onChanged: (_) => onSearchChanged(),
),
),
],
);
}
Widget _buildError(VoidCallback onRetry) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
const SizedBox(height: 12),
const Text('暂无数据,网络不可用', style: TextStyle(color: AppTheme.textSecondary)),
const SizedBox(height: 12),
ElevatedButton(onPressed: onRetry, child: const Text('重试')),
],
),
);
}
Widget _deleteButton(VoidCallback onTap) {
return TextButton(
onPressed: onTap,
child: const Text('删除', style: TextStyle(fontSize: 12, color: AppTheme.danger)),
);
}
Future<void> _confirmDelete(String message, Future<void> Function() onConfirmed) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认删除'),
content: Text(message),
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('删除'),
),
],
),
columns: [
const DataColumn(label: Text('商品编码')),
const DataColumn(label: Text('商品名称')),
DataColumn(
label: FilterableColumnHeader(
text: '品牌',
options: brandOptions,
selected: _filterBrand,
onChanged: (v) => setState(() => _filterBrand = v),
),
),
const DataColumn(label: Text('规格')),
const DataColumn(label: Text('单位')),
const DataColumn(label: Text('进价'), numeric: true),
const DataColumn(label: Text('售价'), numeric: true),
const DataColumn(label: Text('安全库存'), numeric: true),
const DataColumn(label: Text('操作')),
],
rows: filteredProducts.isEmpty
? [
const DataRow(cells: [
DataCell(SizedBox()),
DataCell(Text('暂无商品',
style: TextStyle(color: AppTheme.textSecondary))),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
])
]
: filteredProducts
.map((p) => DataRow(
cells: [
DataCell(Text(p.code,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: AppTheme.textSecondary))),
DataCell(SizedBox(
width: 180,
child: Text(p.name,
overflow: TextOverflow.ellipsis),
)),
DataCell(Text(p.brand ?? '-')),
DataCell(Text(p.spec ?? '-')),
DataCell(Text(p.unit)),
DataCell(Text(p.purchasePrice != null
? '¥${p.purchasePrice!.toStringAsFixed(2)}'
: '-')),
DataCell(Text(
p.salePrice != null
? '¥${p.salePrice!.toStringAsFixed(2)}'
: '-',
style:
const TextStyle(color: AppTheme.primary),
)),
DataCell(Text(
p.minStock != null ? '${p.minStock}' : '-')),
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
key: Key('btn_edit_${p.id}'),
onPressed: () =>
_showProductDialog(context, product: p),
child: const Text('编辑',
style: TextStyle(fontSize: 12)),
),
TextButton(
key: Key('btn_delete_${p.id}'),
onPressed: () =>
_confirmDelete(context, p),
child: const Text('删除',
style: TextStyle(
fontSize: 12,
color: AppTheme.danger)),
),
],
)),
],
))
.toList(),
);
}
}
class _ProductFormDialog extends ConsumerStatefulWidget {
final Product? product;
final VoidCallback onSaved;
const _ProductFormDialog({this.product, required this.onSaved});
@override
ConsumerState<_ProductFormDialog> createState() =>
_ProductFormDialogState();
}
class _ProductFormDialogState extends ConsumerState<_ProductFormDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameCtrl;
late final TextEditingController _codeCtrl;
late final TextEditingController _barcodeCtrl;
late final TextEditingController _brandCtrl;
late final TextEditingController _specCtrl;
late final TextEditingController _purchasePriceCtrl;
late final TextEditingController _salePriceCtrl;
late final TextEditingController _minStockCtrl;
late final TextEditingController _remarkCtrl;
String _unit = '';
bool _saving = false;
@override
void initState() {
super.initState();
final p = widget.product;
_nameCtrl = TextEditingController(text: p?.name ?? '');
_codeCtrl = TextEditingController(text: p?.code ?? '');
_barcodeCtrl = TextEditingController(text: p?.barcode ?? '');
_brandCtrl = TextEditingController(text: p?.brand ?? '');
_specCtrl = TextEditingController(text: p?.spec ?? '');
_purchasePriceCtrl = TextEditingController(
text: p?.purchasePrice?.toStringAsFixed(2) ?? '');
_salePriceCtrl =
TextEditingController(text: p?.salePrice?.toStringAsFixed(2) ?? '');
_minStockCtrl =
TextEditingController(text: p?.minStock?.toString() ?? '');
_remarkCtrl = TextEditingController(text: p?.remark ?? '');
_unit = p?.unit ?? '';
}
@override
void dispose() {
_nameCtrl.dispose();
_codeCtrl.dispose();
_barcodeCtrl.dispose();
_brandCtrl.dispose();
_specCtrl.dispose();
_purchasePriceCtrl.dispose();
_salePriceCtrl.dispose();
_minStockCtrl.dispose();
_remarkCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
final data = {
'name': _nameCtrl.text.trim(),
'code': _codeCtrl.text.trim(),
if (_barcodeCtrl.text.trim().isNotEmpty)
'barcode': _barcodeCtrl.text.trim(),
if (_brandCtrl.text.trim().isNotEmpty) 'brand': _brandCtrl.text.trim(),
if (_specCtrl.text.trim().isNotEmpty) 'spec': _specCtrl.text.trim(),
'unit': _unit,
if (_purchasePriceCtrl.text.trim().isNotEmpty)
'purchase_price': double.tryParse(_purchasePriceCtrl.text.trim()),
if (_salePriceCtrl.text.trim().isNotEmpty)
'sale_price': double.tryParse(_salePriceCtrl.text.trim()),
if (_minStockCtrl.text.trim().isNotEmpty)
'min_stock': int.tryParse(_minStockCtrl.text.trim()),
if (_remarkCtrl.text.trim().isNotEmpty)
'remark': _remarkCtrl.text.trim(),
};
try {
final notifier = ref.read(productListProvider.notifier);
if (widget.product != null) {
await notifier.updateProduct(widget.product!.id, data);
} else {
await notifier.createProduct(data);
if (ok == true && mounted) {
try {
await onConfirmed();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('删除失败:$e'), backgroundColor: AppTheme.danger),
);
}
}
if (mounted) {
Navigator.of(context).pop();
widget.onSaved();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text(widget.product != null ? '商品更新成功' : '商品创建成功'),
backgroundColor: AppTheme.success,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('保存失败:$e'),
backgroundColor: AppTheme.danger),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final isEdit = widget.product != null;
return Dialog(
child: Container(
width: 600,
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(isEdit ? '编辑商品' : '新建商品',
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w600)),
const SizedBox(height: 20),
Row(children: [
Expanded(
child: TextFormField(
controller: _nameCtrl,
decoration: const InputDecoration(labelText: '商品名称'),
validator: (v) =>
(v == null || v.isEmpty) ? '不能为空' : null,
),
Future<void> _showCreateDialog({
required String title,
required bool hasQuantity,
required Future<void> Function(Map<String, dynamic>) onSave,
}) async {
final codeCtrl = TextEditingController();
final nameCtrl = TextEditingController();
final quantityCtrl = TextEditingController();
final remarkCtrl = TextEditingController();
final formKey = GlobalKey<FormState>();
await showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(title),
content: SizedBox(
width: 360,
child: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: codeCtrl,
decoration: const InputDecoration(labelText: '编号(可选)'),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _codeCtrl,
decoration: const InputDecoration(labelText: '商品编码'),
validator: (v) =>
(v == null || v.isEmpty) ? '不能为空' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: nameCtrl,
decoration: const InputDecoration(labelText: '名称 *'),
validator: (v) => (v == null || v.trim().isEmpty) ? '请输入名称' : null,
),
]),
const SizedBox(height: 12),
Row(children: [
Expanded(
child: TextFormField(
controller: _brandCtrl,
decoration: const InputDecoration(labelText: '品牌'),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _specCtrl,
decoration: const InputDecoration(labelText: '规格'),
),
),
const SizedBox(width: 12),
Expanded(
child: DropdownButtonFormField<String>(
value: _unit,
decoration: const InputDecoration(labelText: '单位'),
items: ['', '', '', '', '', '']
.map((u) => DropdownMenuItem(
value: u,
child: Text(u,
style: const TextStyle(fontSize: 13))))
.toList(),
onChanged: (v) => setState(() => _unit = v!),
),
),
]),
const SizedBox(height: 12),
Row(children: [
Expanded(
child: TextFormField(
controller: _purchasePriceCtrl,
decoration: const InputDecoration(
labelText: '进货价', prefixText: '¥'),
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _salePriceCtrl,
decoration: const InputDecoration(
labelText: '销售价', prefixText: '¥'),
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _minStockCtrl,
decoration:
const InputDecoration(labelText: '安全库存'),
if (hasQuantity) ...[
const SizedBox(height: 12),
TextFormField(
controller: quantityCtrl,
decoration: const InputDecoration(labelText: '单品数量'),
keyboardType: TextInputType.number,
),
),
]),
const SizedBox(height: 12),
TextFormField(
controller: _barcodeCtrl,
decoration: const InputDecoration(labelText: '条形码'),
),
const SizedBox(height: 12),
TextFormField(
controller: _remarkCtrl,
decoration: const InputDecoration(labelText: '备注'),
maxLines: 2,
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: const Text('保存'),
),
],
),
],
const SizedBox(height: 12),
TextFormField(
controller: remarkCtrl,
decoration: const InputDecoration(labelText: '备注'),
maxLines: 2,
),
],
),
),
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('取消')),
ElevatedButton(
onPressed: () async {
if (!formKey.currentState!.validate()) return;
final data = <String, dynamic>{
'name': nameCtrl.text.trim(),
if (codeCtrl.text.isNotEmpty) 'code': codeCtrl.text.trim(),
if (hasQuantity && quantityCtrl.text.isNotEmpty)
'quantity': int.tryParse(quantityCtrl.text) ?? 0,
if (remarkCtrl.text.isNotEmpty) 'remark': remarkCtrl.text.trim(),
};
Navigator.of(ctx).pop();
try {
await onSave(data);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('创建失败:$e'), backgroundColor: AppTheme.danger),
);
}
}
},
child: const Text('保存'),
),
],
),
);
}