Files
jiu/client/lib/screens/public/public_product_screen.dart
T
wangjia 393e227de5 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>
2026-04-27 00:29:51 +08:00

165 lines
5.6 KiB
Dart

import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import '../../core/config/app_config.dart';
import '../../core/theme/app_theme.dart';
class PublicProductScreen extends StatefulWidget {
final String publicId;
const PublicProductScreen({super.key, required this.publicId});
@override
State<PublicProductScreen> createState() => _PublicProductScreenState();
}
class _PublicProductScreenState extends State<PublicProductScreen> {
Map<String, dynamic>? _data;
String? _error;
bool _loading = true;
final _dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
));
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() { _loading = true; _error = null; });
try {
final url = '${AppConfig.apiBaseUrl}/public/products/${widget.publicId}';
final resp = await _dio.get(url);
setState(() {
_data = (resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>;
_loading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
if (_error != null || _data == null) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 40, color: AppTheme.danger),
const SizedBox(height: 12),
const Text('商品不存在或已下架'),
const SizedBox(height: 12),
ElevatedButton(onPressed: _load, child: const Text('重试')),
],
),
),
);
}
final d = _data!;
final images = (d['images'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>();
final name = d['name'] as String? ?? '';
final series = d['series'] as String? ?? '';
final spec = d['spec'] as String? ?? '';
final brand = d['brand'] as String? ?? '';
final unit = d['unit'] as String? ?? '';
final description = d['description'] as String? ?? '';
return Scaffold(
body: CustomScrollView(
slivers: [
// Image carousel or placeholder
SliverToBoxAdapter(
child: images.isEmpty
? Container(
height: 240,
color: const Color(0xFFF5F5F5),
child: const Center(
child: Icon(Icons.wine_bar, size: 80, color: Color(0xFFCCCCCC)),
),
)
: SizedBox(
height: 300,
child: PageView(
children: images.map((img) {
final url = AppConfig.baseUrl + (img['url'] as String);
return Image.network(
url,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: const Color(0xFFF5F5F5),
child: const Icon(Icons.broken_image,
size: 48, color: Color(0xFFCCCCCC)),
),
);
}).toList(),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name,
style: const TextStyle(
fontSize: 22, fontWeight: FontWeight.w700)),
const SizedBox(height: 6),
if (series.isNotEmpty || spec.isNotEmpty)
Text(
[if (series.isNotEmpty) series, if (spec.isNotEmpty) spec]
.join(' · '),
style: const TextStyle(
fontSize: 15, color: AppTheme.textSecondary),
),
if (brand.isNotEmpty) ...[
const SizedBox(height: 4),
Text('品牌:$brand',
style: const TextStyle(
fontSize: 13, color: AppTheme.textSecondary)),
],
if (unit.isNotEmpty) ...[
const SizedBox(height: 4),
Text('单位:$unit',
style: const TextStyle(
fontSize: 13, color: AppTheme.textSecondary)),
],
if (description.isNotEmpty) ...[
const SizedBox(height: 20),
const Text('关于这款酒',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Text(description,
style: const TextStyle(
fontSize: 14, height: 1.7,
color: AppTheme.textPrimary)),
],
const SizedBox(height: 40),
const Center(
child: Text('酒库管理系统',
style: TextStyle(
fontSize: 12, color: AppTheme.textSecondary)),
),
],
),
),
),
],
),
);
}
}