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:
@@ -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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user