feat: 公开商品页重设计 + web 上传修复 + 二维码配置

- 公开商品页(/product/:uuid)全面重设计:全宽正方形图片轮播、
  左右滑动切图、点击放大全屏查看、商品参数含描述、页脚贴底
- 修复 Flutter web 文件上传无反应(path→bytes)
- 修复 web 路由空白页(usePathUrlStrategy + 单层 MaterialApp.router)
- 二维码 URL 改为从 STORAGE_PUBLIC_URL 环境变量读取
- 新增 PUBLIC_URL dart-define → AppConfig.publicBaseUrl
- 新增 CI/CD workflows + NAS runner compose 配置
- seed S001 补充商品描述字段

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-04-30 00:31:11 +08:00
parent 393e227de5
commit 655c1d366e
17 changed files with 738 additions and 157 deletions
+6
View File
@@ -10,8 +10,14 @@ class AppConfig {
defaultValue: 'http://localhost:8080',
);
static const _publicUrl = String.fromEnvironment(
'PUBLIC_URL',
defaultValue: 'http://localhost:8081',
);
static String get baseUrl => _baseUrl;
static String get apiBaseUrl => '$_baseUrl/api/v1';
static String get healthUrl => '$_baseUrl/health';
static String get versionUrl => '$_baseUrl/version';
static String get publicBaseUrl => _publicUrl;
}
+12 -42
View File
@@ -1,13 +1,14 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
import 'core/auth/auth_state.dart';
import 'core/router/app_router.dart';
import 'core/theme/app_theme.dart';
import 'providers/connectivity_provider.dart';
void main() {
usePathUrlStrategy();
FlutterError.onError = (details) {
FlutterError.presentError(details);
debugPrint('═══ FlutterError ════════════════════════════');
@@ -25,65 +26,34 @@ void main() {
);
}
class JiuApp extends ConsumerWidget {
class JiuApp extends ConsumerStatefulWidget {
const JiuApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return MaterialApp(
title: '酒库管理系统',
theme: AppTheme.light(),
debugShowCheckedModeBanner: false,
home: const _AppBootstrap(),
);
}
ConsumerState<JiuApp> createState() => _JiuAppState();
}
/// Restores persisted auth before handing off to the router.
class _AppBootstrap extends ConsumerStatefulWidget {
const _AppBootstrap();
@override
ConsumerState<_AppBootstrap> createState() => _AppBootstrapState();
}
class _AppBootstrapState extends ConsumerState<_AppBootstrap> {
bool _ready = false;
class _JiuAppState extends ConsumerState<JiuApp> {
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
await ref.read(authStateProvider.notifier).restore();
// 启动时立即做一次连通性检测,确保 connectivityProvider 状态在路由跳转前已就绪
await ref.read(connectivityProvider.notifier).forceCheck();
if (mounted) setState(() => _ready = true);
// 异步初始化:恢复 auth + 连通性检测
// 路由 redirect 在 initialized=false 时返回 null(不重定向),
// 初始化完成后 authState 变化触发 router refreshredirect 重新执行
WidgetsBinding.instance.addPostFrameCallback((_) async {
await ref.read(authStateProvider.notifier).restore();
await ref.read(connectivityProvider.notifier).forceCheck();
});
}
@override
Widget build(BuildContext context) {
if (!_ready) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return _RouterApp();
}
}
class _RouterApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(appRouterProvider);
return MaterialApp.router(
title: '酒库管理系统',
theme: AppTheme.light(),
routerConfig: router,
debugShowCheckedModeBanner: false,
builder: (context, child) => SelectionArea(child: child!),
);
}
}
@@ -87,11 +87,16 @@ class ProductRepository {
}
}
Future<ProductImage> uploadImage(int productId, String filePath) async {
Future<ProductImage> uploadImage(int productId, String? filePath,
{Uint8List? bytes, String? fileName}) async {
try {
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(filePath),
});
final MultipartFile file;
if (bytes != null) {
file = MultipartFile.fromBytes(bytes, filename: fileName ?? 'image.jpg');
} else {
file = await MultipartFile.fromFile(filePath!);
}
final formData = FormData.fromMap({'file': file});
final resp = await _client.post('/products/$productId/images', data: formData);
return ProductImage.fromJson(
(resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>);
@@ -1,5 +1,6 @@
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -91,16 +92,20 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
final result = await FilePicker.platform.pickFiles(
type: FileType.image,
allowMultiple: false,
withData: true,
);
if (result == null || result.files.isEmpty) return;
final path = result.files.first.path;
if (path == null) return;
final file = result.files.first;
final String? filePath = kIsWeb ? null : file.path;
final fileBytes = file.bytes;
if (filePath == null && fileBytes == null) return;
setState(() => _uploading = true);
try {
final img = await ref
.read(productRepositoryProvider)
.uploadImage(_product!.id, path);
.uploadImage(_product!.id, filePath,
bytes: fileBytes, fileName: file.name);
_updateImages([..._product!.images, img]);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -357,7 +362,7 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
}
Widget _buildPublicLinkSection(Product p) {
final publicUrl = 'https://jiu.51yanmei.com/product/${p.publicId}';
final publicUrl = '${AppConfig.publicBaseUrl}/product/${p.publicId}';
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
@@ -30,35 +30,40 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
Future<void> _load() async {
setState(() { _loading = true; _error = null; });
try {
final url = '${AppConfig.apiBaseUrl}/public/products/${widget.publicId}';
final resp = await _dio.get(url);
final resp = await _dio.get(
'${AppConfig.apiBaseUrl}/public/products/${widget.publicId}',
);
final data = (resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>;
debugPrint('[public] description=${data['description']}');
setState(() {
_data = (resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>;
_data = data;
_loading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_loading = false;
});
setState(() { _error = e.toString(); _loading = false; });
}
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
return const Scaffold(
backgroundColor: Color(0xFFF5F5F5),
body: Center(child: CircularProgressIndicator()),
);
}
if (_error != null || _data == null) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
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),
const Icon(Icons.error_outline, size: 48, color: AppTheme.danger),
const SizedBox(height: 16),
const Text('商品不存在或已下架',
style: TextStyle(fontSize: 16, color: Color(0xFF333333))),
const SizedBox(height: 16),
ElevatedButton(onPressed: _load, child: const Text('重试')),
],
),
@@ -67,8 +72,8 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
}
final d = _data!;
final images = (d['images'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>();
final images = (d['images'] as List<dynamic>? ?? []).cast<Map<String, dynamic>>();
final imageUrls = images.map((img) => AppConfig.baseUrl + (img['url'] as String)).toList();
final name = d['name'] as String? ?? '';
final series = d['series'] as String? ?? '';
final spec = d['spec'] as String? ?? '';
@@ -77,87 +82,400 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
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),
backgroundColor: const Color(0xFFF5F5F5),
body: SafeArea(
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _ImageGallery(imageUrls: imageUrls),
),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
SliverToBoxAdapter(
child: _TitleCard(name: name, series: series, spec: spec, brand: brand),
),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
SliverToBoxAdapter(
child: _ParamsCard(spec: spec, brand: brand, unit: unit, description: description),
),
const SliverFillRemaining(
hasScrollBody: false,
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),
mainAxisAlignment: MainAxisAlignment.end,
children: [_FooterBrand()],
),
),
],
),
),
);
}
}
// ── 图片画廊 ─────────────────────────────────────────────
class _ImageGallery extends StatefulWidget {
final List<String> imageUrls;
const _ImageGallery({required this.imageUrls});
@override
State<_ImageGallery> createState() => _ImageGalleryState();
}
class _ImageGalleryState extends State<_ImageGallery> {
int _current = 0;
final PageController _ctrl = PageController();
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
void _openFullscreen(int index) {
Navigator.of(context).push(PageRouteBuilder(
opaque: false,
barrierColor: Colors.black87,
pageBuilder: (_, __, ___) => _FullscreenViewer(
urls: widget.imageUrls,
initialIndex: index,
),
));
}
@override
Widget build(BuildContext context) {
final urls = widget.imageUrls;
return LayoutBuilder(builder: (context, constraints) {
final size = constraints.maxWidth;
if (urls.isEmpty) {
return Container(
width: size,
height: size,
color: const Color(0xFFEEEEEE),
child: const Center(
child: Icon(Icons.wine_bar, size: 96, color: Color(0xFFCCCCCC)),
),
);
}
return Column(
children: [
// 主图区(正方形)
SizedBox(
width: size,
height: size,
child: Stack(
children: [
PageView.builder(
controller: _ctrl,
itemCount: urls.length,
onPageChanged: (i) => setState(() => _current = i),
itemBuilder: (_, i) => GestureDetector(
onTap: () => _openFullscreen(i),
child: Image.network(
urls[i],
fit: BoxFit.cover,
width: double.infinity,
errorBuilder: (_, __, ___) => Container(
color: const Color(0xFFEEEEEE),
child: const Center(
child: Icon(Icons.broken_image, size: 64, color: Color(0xFFCCCCCC)),
),
),
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)),
),
],
),
),
if (urls.length > 1)
Positioned(
right: 12,
bottom: 12,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${_current + 1} / ${urls.length}',
style: const TextStyle(fontSize: 12, color: Colors.white),
),
),
),
Positioned(
right: 12,
top: 12,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: Colors.black38,
borderRadius: BorderRadius.circular(4),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.zoom_in, size: 14, color: Colors.white),
SizedBox(width: 3),
Text('点击放大', style: TextStyle(fontSize: 11, color: Colors.white)),
],
),
),
),
],
),
),
// 缩略图条
if (urls.length > 1)
Container(
color: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: SizedBox(
height: 60,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: urls.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) {
final selected = i == _current;
return GestureDetector(
onTap: () => _ctrl.animateToPage(i,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 60,
height: 60,
decoration: BoxDecoration(
border: Border.all(
color: selected ? AppTheme.primary : const Color(0xFFDDDDDD),
width: selected ? 2 : 1,
),
borderRadius: BorderRadius.circular(4),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: Image.network(
urls[i],
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Icon(
Icons.broken_image,
size: 24,
color: Color(0xFFCCCCCC),
),
),
),
),
);
},
),
),
),
],
);
});
}
}
// ── 全屏查看器 ────────────────────────────────────────────
class _FullscreenViewer extends StatefulWidget {
final List<String> urls;
final int initialIndex;
const _FullscreenViewer({required this.urls, required this.initialIndex});
@override
State<_FullscreenViewer> createState() => _FullscreenViewerState();
}
class _FullscreenViewerState extends State<_FullscreenViewer> {
late int _current;
late PageController _ctrl;
@override
void initState() {
super.initState();
_current = widget.initialIndex;
_ctrl = PageController(initialPage: widget.initialIndex);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Scaffold(
backgroundColor: Colors.transparent,
body: Stack(
children: [
PageView.builder(
controller: _ctrl,
itemCount: widget.urls.length,
onPageChanged: (i) => setState(() => _current = i),
itemBuilder: (_, i) => Center(
child: InteractiveViewer(
child: Image.network(
widget.urls[i],
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const Icon(
Icons.broken_image,
size: 64,
color: Colors.white54,
),
),
),
),
),
Positioned(
top: 40,
right: 16,
child: GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(20),
),
child: const Icon(Icons.close, color: Colors.white, size: 20),
),
),
),
if (widget.urls.length > 1)
Positioned(
bottom: 40,
left: 0,
right: 0,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(widget.urls.length, (i) => AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: i == _current ? 16 : 6,
height: 6,
decoration: BoxDecoration(
color: i == _current ? Colors.white : Colors.white38,
borderRadius: BorderRadius.circular(3),
),
)),
),
),
],
),
),
);
}
}
// ── 标题卡 ────────────────────────────────────────────────
class _TitleCard extends StatelessWidget {
final String name, series, spec, brand;
const _TitleCard({required this.name, required this.series, required this.spec, required this.brand});
@override
Widget build(BuildContext context) {
final subtitle = [if (series.isNotEmpty) series, if (spec.isNotEmpty) spec].join(' · ');
return Container(
color: Colors.white,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Color(0xFF212121), height: 1.4)),
if (subtitle.isNotEmpty) ...[
const SizedBox(height: 6),
Text(subtitle, style: const TextStyle(fontSize: 13, color: Color(0xFF888888))),
],
if (brand.isNotEmpty) ...[
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFFE8F0FE),
borderRadius: BorderRadius.circular(4),
),
child: Text('品牌:$brand',
style: const TextStyle(fontSize: 12, color: AppTheme.primary, fontWeight: FontWeight.w500)),
),
],
],
),
);
}
}
// ── 商品参数卡 ────────────────────────────────────────────
class _ParamsCard extends StatelessWidget {
final String spec, brand, unit, description;
const _ParamsCard({required this.spec, required this.brand, required this.unit, required this.description});
@override
Widget build(BuildContext context) {
final rows = <({String label, String value})>[];
if (spec.isNotEmpty) rows.add((label: '规格', value: spec));
if (brand.isNotEmpty) rows.add((label: '品牌', value: brand));
if (unit.isNotEmpty) rows.add((label: '单位', value: unit));
if (description.isNotEmpty) rows.add((label: '描述', value: description));
if (rows.isEmpty) return const SizedBox.shrink();
return Container(
color: Colors.white,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 14, 16, 10),
child: Text('商品参数',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Color(0xFF212121))),
),
const Divider(height: 1, color: Color(0xFFF0F0F0)),
...rows.map((r) => Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11),
child: Row(
children: [
SizedBox(
width: 72,
child: Text(r.label,
style: const TextStyle(fontSize: 13, color: Color(0xFF888888))),
),
Expanded(
child: Text(r.value,
style: const TextStyle(fontSize: 13, color: Color(0xFF212121))),
),
],
),
),
if (r != rows.last) const Divider(height: 1, indent: 16, color: Color(0xFFF5F5F5)),
],
)),
const SizedBox(height: 4),
],
),
);
}
}
// ── 页脚 ──────────────────────────────────────────────────
class _FooterBrand extends StatelessWidget {
const _FooterBrand();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 20),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.wine_bar, size: 14, color: Color(0xFFBBBBBB)),
SizedBox(width: 6),
Text('酒库管理系统提供', style: TextStyle(fontSize: 12, color: Color(0xFFBBBBBB))),
],
),
);
}