655c1d366e
- 公开商品页(/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>
483 lines
16 KiB
Dart
483 lines
16 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 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 = data;
|
|
_loading = false;
|
|
});
|
|
} catch (e) {
|
|
setState(() { _error = e.toString(); _loading = false; });
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (_loading) {
|
|
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: 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('重试')),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final d = _data!;
|
|
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? ?? '';
|
|
final brand = d['brand'] as String? ?? '';
|
|
final unit = d['unit'] as String? ?? '';
|
|
final description = d['description'] as String? ?? '';
|
|
|
|
return Scaffold(
|
|
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(
|
|
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 (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))),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|