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:
@@ -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('关闭'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user