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
+32 -7
View File
@@ -9,6 +9,7 @@ class DataTableCard extends StatefulWidget {
final int page;
final int pageSize;
final ValueChanged<int>? onPageChanged;
final ValueChanged<int>? onPageSizeChanged;
const DataTableCard({
super.key,
@@ -19,6 +20,7 @@ class DataTableCard extends StatefulWidget {
this.page = 1,
this.pageSize = 20,
this.onPageChanged,
this.onPageSizeChanged,
});
@override
@@ -91,6 +93,7 @@ class _DataTableCardState extends State<DataTableCard> {
total: widget.totalCount!,
pageSize: widget.pageSize,
onPageChanged: widget.onPageChanged,
onPageSizeChanged: widget.onPageSizeChanged,
),
],
),
@@ -157,19 +160,45 @@ class _PaginationBar extends StatelessWidget {
final int total;
final int pageSize;
final ValueChanged<int>? onPageChanged;
final ValueChanged<int>? onPageSizeChanged;
static const _pageSizeOptions = [10, 20, 50, 100];
const _PaginationBar({
required this.page,
required this.total,
required this.pageSize,
this.onPageChanged,
this.onPageSizeChanged,
});
@override
Widget build(BuildContext context) {
final totalPages = (total / pageSize).ceil().clamp(1, 9999);
// 确保当前 pageSize 在选项中(如默认值 50 不在列表时仍能显示)
final effectiveSize = _pageSizeOptions.contains(pageSize) ? pageSize : _pageSizeOptions.first;
return Row(
children: [
// 每页条数选择器
if (onPageSizeChanged != null) ...[
const Text('每页', style: TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
const SizedBox(width: 4),
DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: effectiveSize,
isDense: true,
style: const TextStyle(fontSize: 13, color: AppTheme.textPrimary),
items: _pageSizeOptions
.map((n) => DropdownMenuItem(value: n, child: Text('$n')))
.toList(),
onChanged: (v) {
if (v != null) onPageSizeChanged!(v);
},
),
),
const SizedBox(width: 16),
],
// 翻页控件
IconButton(
icon: const Icon(Icons.first_page, size: 18),
onPressed: page > 1 ? () => onPageChanged?.call(1) : null,
@@ -182,20 +211,16 @@ class _PaginationBar extends StatelessWidget {
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
),
Text('$page / $totalPages',
style: const TextStyle(fontSize: 13)),
Text('$page / $totalPages', style: const TextStyle(fontSize: 13)),
IconButton(
icon: const Icon(Icons.chevron_right, size: 18),
onPressed:
page < totalPages ? () => onPageChanged?.call(page + 1) : null,
onPressed: page < totalPages ? () => onPageChanged?.call(page + 1) : null,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
),
IconButton(
icon: const Icon(Icons.last_page, size: 18),
onPressed: page < totalPages
? () => onPageChanged?.call(totalPages)
: null,
onPressed: page < totalPages ? () => onPageChanged?.call(totalPages) : null,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
),