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
@@ -1,8 +1,13 @@
import 'package:dio/dio.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/auth/auth_state.dart';
import '../../core/config/app_config.dart';
import '../../core/theme/app_theme.dart';
import '../../models/number_rule.dart';
import '../../models/user.dart';
@@ -33,7 +38,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 5,
length: 6,
child: Column(
children: [
Container(
@@ -51,6 +56,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
Tab(text: '仓库管理'),
Tab(text: '编号规则'),
Tab(text: '系统参数'),
Tab(text: '数据导入'),
Tab(text: '关于'),
],
),
@@ -63,6 +69,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
_buildWarehousesTab(),
_buildNumberRulesTab(),
_buildSystemParamsTab(),
_buildImportTab(),
_buildAboutTab(),
],
),
@@ -586,6 +593,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
);
}
// ── 数据导入 Tab ──────────────────────────────────────────
Widget _buildImportTab() => const _BatchImportWidget();
// ── 关于 Tab ─────────────────────────────────────────────
Widget _buildAboutTab() {
final appVersion = ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0';
@@ -1396,3 +1406,281 @@ class _AboutRow extends StatelessWidget {
);
}
}
// ── 批量数据导入 ───────────────────────────────────────────
class _ImportSlot {
final String title;
final String endpoint;
final String hint;
PlatformFile? file;
// null = 未运行;true = 成功;false = 失败
bool? success;
String? error;
int total = 0;
int imported = 0;
int skipped = 0;
_ImportSlot(this.title, this.endpoint, this.hint);
bool get hasResult => success != null;
}
class _BatchImportWidget extends ConsumerStatefulWidget {
const _BatchImportWidget();
@override
ConsumerState<_BatchImportWidget> createState() => _BatchImportWidgetState();
}
class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
bool _loading = false;
String? _lastDir;
static const _prefKey = 'import_last_dir';
late final List<_ImportSlot> _slots = [
_ImportSlot('往来单位', '/import/partners',
'格式:编号 | 类型 | 状态 | 名称 | 电话 | 卡号 | 初始金额 | 单位 | 地址 | 备注'),
_ImportSlot('商品名称', '/import/product-names',
'格式:选项编号 | 选项名称 | 备注'),
_ImportSlot('商品系列', '/import/product-series',
'格式:选项编号 | 选项名称 | 备注'),
_ImportSlot('商品规格', '/import/product-specs',
'格式:选项编号 | 选项名称 | 单品数量 | 备注'),
];
@override
void initState() {
super.initState();
SharedPreferences.getInstance().then((prefs) {
final dir = prefs.getString(_prefKey);
if (dir != null && mounted) setState(() => _lastDir = dir);
});
}
Future<void> _pickFile(int index) async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['xls', 'xlsx'],
withData: true,
initialDirectory: _lastDir,
);
if (result == null) return;
// 保存目录供下次使用
final path = result.files.first.path;
if (path != null) {
final dir = path.contains('/') ? path.substring(0, path.lastIndexOf('/')) : null;
if (dir != null) {
_lastDir = dir;
SharedPreferences.getInstance().then((p) => p.setString(_prefKey, dir));
}
}
setState(() {
_slots[index].file = result.files.first;
_slots[index].success = null;
_slots[index].error = null;
});
}
Future<void> _runImport() async {
final token = ref.read(authStateProvider).user?.accessToken ?? '';
if (!_slots.any((s) => s.file != null)) return;
setState(() {
_loading = true;
for (final s in _slots) {
if (s.file != null) {
s.success = null;
s.error = null;
}
}
});
final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl));
dio.options.headers['Authorization'] = 'Bearer $token';
for (final slot in _slots) {
if (slot.file == null) continue;
final bytes = slot.file!.bytes;
if (bytes == null) {
if (mounted) setState(() { slot.success = false; slot.error = '无法读取文件'; });
continue;
}
try {
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(bytes, filename: slot.file!.name),
});
final resp = await dio.post(slot.endpoint, data: formData);
final data = resp.data as Map<String, dynamic>;
if (mounted) setState(() {
slot.success = true;
slot.total = (data['total'] ?? 0) as int;
slot.imported = (data['imported'] ?? 0) as int;
slot.skipped = (data['skipped'] ?? 0) as int;
});
} on DioException catch (e) {
final msg = (e.response?.data as Map?)?['error'] ?? e.message ?? '未知错误';
if (mounted) setState(() {
slot.success = false;
slot.error = msg.toString();
});
}
}
if (mounted) setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
final hasAnyFile = _slots.any((s) => s.file != null);
final ran = _slots.where((s) => s.file != null && s.hasResult).toList();
final allDone = !_loading && ran.length == _slots.where((s) => s.file != null).length && ran.isNotEmpty;
final failCount = ran.where((s) => s.success == false).length;
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('数据导入',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
const Text(
'选择对应的 Excel 文件后点击「全部导入」,系统将依次导入,按名称去重(已存在的数据不重复导入)。',
style: TextStyle(fontSize: 13, color: AppTheme.textSecondary),
),
const SizedBox(height: 20),
Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Column(
children: [
for (int i = 0; i < _slots.length; i++) ...[
_buildSlotRow(i),
if (i < _slots.length - 1)
const Divider(height: 1),
],
],
),
),
),
const SizedBox(height: 16),
Row(
children: [
ElevatedButton.icon(
onPressed: (_loading || !hasAnyFile) ? null : _runImport,
icon: _loading
? const SizedBox(
width: 14, height: 14,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: const Icon(Icons.upload_rounded, size: 16),
label: Text(_loading ? '导入中...' : '全部导入'),
),
const SizedBox(width: 16),
if (allDone) ...[
Icon(
failCount == 0
? Icons.check_circle_outline
: Icons.warning_amber_rounded,
size: 16,
color: failCount == 0 ? AppTheme.success : Colors.orange,
),
const SizedBox(width: 4),
Text(
failCount == 0 ? '全部导入完成' : '$failCount 项失败,请检查错误信息',
style: TextStyle(
fontSize: 13,
color: failCount == 0 ? AppTheme.success : Colors.orange,
),
),
],
],
),
],
),
);
}
Widget _buildSlotRow(int index) {
final slot = _slots[index];
Widget? statusWidget;
if (slot.hasResult) {
if (slot.success == true) {
final parts = <String>[
'${slot.total}',
'新增 ${slot.imported}',
if (slot.skipped > 0) '重复跳过 ${slot.skipped}',
];
statusWidget = Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.check_circle_outline, size: 15, color: AppTheme.success),
const SizedBox(width: 4),
Text(
parts.join(''),
style: const TextStyle(fontSize: 13, color: AppTheme.success),
),
],
);
} else {
statusWidget = Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 15, color: AppTheme.danger),
const SizedBox(width: 4),
Text(
slot.error ?? '未知错误',
style: const TextStyle(fontSize: 13, color: AppTheme.danger),
),
],
);
}
} else if (_loading && slot.file != null) {
statusWidget = const SizedBox(
width: 14, height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
SizedBox(
width: 68,
child: Text(slot.title,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
),
const SizedBox(width: 12),
OutlinedButton(
onPressed: _loading ? null : () => _pickFile(index),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('选择文件', style: TextStyle(fontSize: 13)),
),
const SizedBox(width: 12),
Expanded(
child: Text(
slot.file?.name ?? '未选择',
style: TextStyle(
fontSize: 13,
color: slot.file != null ? AppTheme.textPrimary : AppTheme.textSecondary,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 12),
if (statusWidget != null) statusWidget,
],
),
);
}
}