393e227de5
后端: - 新增 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>
165 lines
5.6 KiB
Dart
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)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|