feat(client): 登录/注册页照原型重建,ds 真相源组件族统一全部屏

- 登录/注册(login.html/register.html 1:1):两栏卡片+品牌渐变面板+主题小衣服
  pill(onSurface 变体)+记住我(记录并预填最近账号)+原型式 toast 校验;
  登录 fidelity 1.4–2.1% 三主题全绿;注册暂不入闸(少 门店编号/兑换券 字段,
  已记 CONTRACT,screens.mjs 留存根)
- ds 原子补齐:DsToast(.toast 单例)/DsCheck(.check/.agree)/DsButton lg 档/
  DsSelect 替换全部旧 DropdownButton/DsField label 在上/DsInput 后缀与密码形态
- 全屏统一:对话框按钮全 DsButton、盒式输入主题钉死(visualDensity.standard、
  h38、InputDecorationTheme 渗漏修复)、图标全 lucide、JetBrains Mono 三端同源、
  BrandMark 真相源 logo、只读模式写操作全量守卫(WriteGuard+DsToast)
- 出入库列表:版式对齐原型(卡片对齐+搜索框居中)、KPI 近30天滚动、结清后
  失效财务应收应付表;商品编辑抽屉介绍库改搜索下拉、图片双击全屏预览
- 删除旧 UI 死代码:DataTableCard/FormDialog/PageScaffold/SearchChip/
  SelectProductDialog/tabStateProvider
- golden/fidelity:stock-in/out 补注册(9%)、login 入册(8%)、goldens 全量重打;
  修复 pubspec flutter_web_plugins 非法声明(CI pub get 阻断)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
This commit is contained in:
wangjia
2026-07-03 09:58:14 +08:00
parent ca7595b113
commit 6238b86dcb
286 changed files with 16831 additions and 11136 deletions
@@ -1,3 +1,4 @@
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'dart:math' as math;
import 'package:dio/dio.dart';
import 'package:flutter/gestures.dart';
@@ -49,13 +50,20 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
}
Future<void> _load() async {
setState(() { _loading = true; _error = null; });
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>;
setState(() { _data = data; _loading = false; });
final data =
(resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>;
setState(() {
_data = data;
_loading = false;
});
// 更新浏览器标签标题(Flutter Web 会写 document.title
final name = data['name'] as String? ?? '';
if (name.isNotEmpty) {
@@ -64,7 +72,10 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
);
}
} catch (e) {
setState(() { _error = e.toString(); _loading = false; });
setState(() {
_error = e.toString();
_loading = false;
});
}
}
@@ -79,7 +90,9 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
title: title,
hint: hint,
onSubmit: (msg) async {
final shopNo = (_data?['shop'] as Map<String, dynamic>?)?['code'] as String? ?? '';
final shopNo =
(_data?['shop'] as Map<String, dynamic>?)?['code'] as String? ??
'';
final productName = _data?['name'] as String? ?? '';
final productCode = _data?['code'] as String? ?? '';
final productInfo = [
@@ -107,7 +120,9 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
if (_loading) {
return const Scaffold(
backgroundColor: _kPaper,
body: Center(child: CircularProgressIndicator(color: _kBurgundy, strokeWidth: 2)),
body: Center(
child:
CircularProgressIndicator(color: _kBurgundy, strokeWidth: 2)),
);
}
if (_error != null || _data == null) {
@@ -120,26 +135,33 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 56, height: 56,
width: 56,
height: 56,
decoration: BoxDecoration(
color: const Color(0xFFFDECEC),
shape: BoxShape.circle,
),
child: const Icon(Icons.error_outline, size: 28, color: Color(0xFFD14343)),
child: const Icon(LucideIcons.circleAlert,
size: 28, color: Color(0xFFD14343)),
),
const SizedBox(height: 16),
const Text('商品不存在或已下架',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: _kInkDeep)),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: _kInkDeep)),
const SizedBox(height: 8),
const Text('请检查二维码是否完整,或联系出售方确认',
style: TextStyle(fontSize: 13, color: _kTextMid), textAlign: TextAlign.center),
style: TextStyle(fontSize: 13, color: _kTextMid),
textAlign: TextAlign.center),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _load,
style: ElevatedButton.styleFrom(
backgroundColor: _kBurgundy,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
child: const Text('重试'),
),
@@ -151,8 +173,11 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
}
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 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? ?? '';
@@ -161,15 +186,17 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
final description = d['description'] as String? ?? '';
final descriptionTitle = d['description_title'] as String? ?? '商品介绍';
final descriptionKeywords = (d['description_keywords'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ?? const <String>[];
?.map((e) => e as String)
.toList() ??
const <String>[];
final salePrice = (d['sale_price'] as num?)?.toDouble() ?? 0.0;
final origin = d['origin'] as String? ?? '';
final shelfLife = d['shelf_life'] as String? ?? '无限期(适饮)';
final storage = d['storage'] as String? ?? '阴凉干燥、避光保存';
final shop = d['shop'] as Map<String, dynamic>?;
final batch = d['batch'] as Map<String, dynamic>?;
final quickSpecs = _parseQuickSpecs(spec, batch?['production_date'] as String?);
final quickSpecs =
_parseQuickSpecs(spec, batch?['production_date'] as String?);
final productionDate = batch?['production_date'] as String?;
return Scaffold(
@@ -182,7 +209,9 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
slivers: [
// 1. Gallery
SliverToBoxAdapter(
child: _HeroGallery(imageUrls: imageUrls, shopName: shop?['name'] as String? ?? ''),
child: _HeroGallery(
imageUrls: imageUrls,
shopName: shop?['name'] as String? ?? ''),
),
// 2. Verified ribbon
SliverToBoxAdapter(
@@ -268,7 +297,8 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
final items = <({String value, String unit, String label})>[];
// Alcohol degree: 53度 / 53° / 53%vol
final degReg = RegExp(r'(\d+(?:\.\d+)?)\s*(?:度|°|%vol)', caseSensitive: false);
final degReg =
RegExp(r'(\d+(?:\.\d+)?)\s*(?:度|°|%vol)', caseSensitive: false);
final degM = degReg.firstMatch(spec);
if (degM != null) {
items.add((value: degM.group(1)!, unit: '°', label: '酒精度'));
@@ -344,7 +374,8 @@ class _HeroGalleryState extends State<_HeroGallery> {
// Golden halo glow (behind product)
Center(
child: Container(
width: 240, height: 240,
width: 240,
height: 240,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
@@ -369,18 +400,26 @@ class _HeroGalleryState extends State<_HeroGallery> {
child: Image.network(
urls[i],
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => _EmptyGalleryContent(shopName: widget.shopName),
errorBuilder: (_, __, ___) =>
_EmptyGalleryContent(shopName: widget.shopName),
),
),
),
// Top gradient scrim
Positioned(
top: 0, left: 0, right: 0, height: 64,
top: 0,
left: 0,
right: 0,
height: 64,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter, end: Alignment.bottomCenter,
colors: [Colors.black.withValues(alpha: 0.45), Colors.transparent],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.45),
Colors.transparent
],
),
),
),
@@ -388,43 +427,50 @@ class _HeroGalleryState extends State<_HeroGallery> {
// "点击放大" hint (top right)
if (urls.isNotEmpty)
Positioned(
top: 52, right: 14,
top: 52,
right: 14,
child: _GlassChip(
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.zoom_in, size: 12, color: Colors.white),
Icon(LucideIcons.zoomIn, size: 12, color: Colors.white),
SizedBox(width: 4),
Text('点击放大', style: TextStyle(fontSize: 11, color: Colors.white)),
Text('点击放大',
style: TextStyle(fontSize: 11, color: Colors.white)),
],
),
),
),
// Bottom: dots + page counter
Positioned(
bottom: 16, left: 0, right: 0,
bottom: 16,
left: 0,
right: 0,
child: Row(
children: [
const SizedBox(width: 16),
// Dots
...List.generate(math.max(urls.length, 1), (i) => AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: i == _current ? 18 : 6,
height: 6,
margin: const EdgeInsets.only(right: 5),
decoration: BoxDecoration(
color: i == _current
? Colors.white.withValues(alpha: 0.95)
: Colors.white.withValues(alpha: 0.30),
borderRadius: BorderRadius.circular(999),
),
)),
...List.generate(
math.max(urls.length, 1),
(i) => AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: i == _current ? 18 : 6,
height: 6,
margin: const EdgeInsets.only(right: 5),
decoration: BoxDecoration(
color: i == _current
? Colors.white.withValues(alpha: 0.95)
: Colors.white.withValues(alpha: 0.30),
borderRadius: BorderRadius.circular(999),
),
)),
const Spacer(),
if (urls.length > 1)
Text(
'${_current + 1} / ${urls.length}',
style: const TextStyle(
fontSize: 11, color: Color(0x88FFFFFF),
fontSize: 11,
color: Color(0x88FFFFFF),
fontFeatures: [FontFeature.tabularFigures()],
letterSpacing: 0.5,
),
@@ -436,7 +482,9 @@ class _HeroGalleryState extends State<_HeroGallery> {
// Thumbnail strip
if (urls.length > 1)
Positioned(
bottom: 44, left: 0, right: 0,
bottom: 44,
left: 0,
right: 0,
child: Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 14),
@@ -451,7 +499,8 @@ class _HeroGalleryState extends State<_HeroGallery> {
curve: Curves.easeInOut),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: 44, height: 44,
width: 44,
height: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
border: Border.all(
@@ -463,7 +512,8 @@ class _HeroGalleryState extends State<_HeroGallery> {
),
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: Image.network(urls[i], fit: BoxFit.cover,
child: Image.network(urls[i],
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox()),
),
),
@@ -493,14 +543,18 @@ class _EmptyGalleryContent extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 64, height: 64,
width: 64,
height: 64,
decoration: BoxDecoration(
color: const Color(0xFF0F3057),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(initial,
style: const TextStyle(color: Colors.white70, fontSize: 28, fontWeight: FontWeight.w700)),
style: const TextStyle(
color: Colors.white70,
fontSize: 28,
fontWeight: FontWeight.w700)),
),
),
const SizedBox(height: 12),
@@ -554,13 +608,14 @@ class _VerifiedRibbon extends StatelessWidget {
child: Row(
children: [
Container(
width: 28, height: 28,
width: 28,
height: 28,
decoration: BoxDecoration(
color: _kSuccessLight,
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFFB4E2C5)),
),
child: const Icon(Icons.check, size: 15, color: _kSuccess),
child: const Icon(LucideIcons.check, size: 15, color: _kSuccess),
),
const SizedBox(width: 12),
Expanded(
@@ -572,7 +627,8 @@ class _VerifiedRibbon extends StatelessWidget {
TextSpan(
text: detail,
style: const TextStyle(
fontWeight: FontWeight.w600, color: Color(0xFF353C48)),
fontWeight: FontWeight.w600,
color: Color(0xFF353C48)),
),
],
))
@@ -598,7 +654,7 @@ class _VerifiedRibbon extends StatelessWidget {
),
),
),
const Icon(Icons.chevron_right, size: 18, color: _kTextMid),
const Icon(LucideIcons.chevronRight, size: 18, color: _kTextMid),
],
),
);
@@ -613,9 +669,8 @@ class _PriceBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
final priceStr = price % 1 == 0
? price.toInt().toString()
: price.toStringAsFixed(2);
final priceStr =
price % 1 == 0 ? price.toInt().toString() : price.toStringAsFixed(2);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: const BoxDecoration(
@@ -624,8 +679,7 @@ class _PriceBadge extends StatelessWidget {
),
child: Row(
children: [
const Text('建议零售价',
style: TextStyle(fontSize: 13, color: _kTextMid)),
const Text('建议零售价', style: TextStyle(fontSize: 13, color: _kTextMid)),
const Spacer(),
Text(
'¥$priceStr',
@@ -647,8 +701,11 @@ class _PriceBadge extends StatelessWidget {
class _TitleBlock extends StatelessWidget {
final String name, brand, series, spec, unit;
const _TitleBlock({
required this.name, required this.brand, required this.series,
required this.spec, required this.unit,
required this.name,
required this.brand,
required this.series,
required this.spec,
required this.unit,
});
@override
@@ -675,17 +732,25 @@ class _TitleBlock extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(width: 16, height: 1, color: _kBurgundy.withValues(alpha: 0.7)),
Container(
width: 16,
height: 1,
color: _kBurgundy.withValues(alpha: 0.7)),
const SizedBox(width: 10),
Text(
separatorText,
style: const TextStyle(
fontSize: 11, letterSpacing: 0.28,
color: _kBurgundy, fontWeight: FontWeight.w600,
fontSize: 11,
letterSpacing: 0.28,
color: _kBurgundy,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 10),
Container(width: 16, height: 1, color: _kBurgundy.withValues(alpha: 0.7)),
Container(
width: 16,
height: 1,
color: _kBurgundy.withValues(alpha: 0.7)),
],
),
if (separatorText.isNotEmpty) const SizedBox(height: 14),
@@ -694,8 +759,10 @@ class _TitleBlock extends StatelessWidget {
name,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 38, fontWeight: FontWeight.w700,
letterSpacing: 0.08, color: _kInkDeep,
fontSize: 38,
fontWeight: FontWeight.w700,
letterSpacing: 0.08,
color: _kInkDeep,
height: 1.15,
),
),
@@ -706,8 +773,10 @@ class _TitleBlock extends StatelessWidget {
_keySpec(spec),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 17, fontWeight: FontWeight.w500,
color: _kBurgundy, letterSpacing: 0.04,
fontSize: 17,
fontWeight: FontWeight.w500,
color: _kBurgundy,
letterSpacing: 0.04,
),
),
],
@@ -718,7 +787,9 @@ class _TitleBlock extends StatelessWidget {
specLine,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13, color: _kTextMid, letterSpacing: 0.03,
fontSize: 13,
color: _kTextMid,
letterSpacing: 0.03,
),
),
],
@@ -726,7 +797,8 @@ class _TitleBlock extends StatelessWidget {
const SizedBox(height: 16),
Wrap(
alignment: WrapAlignment.center,
spacing: 8, runSpacing: 6,
spacing: 8,
runSpacing: 6,
children: [
if (series.isNotEmpty)
_TitleTag(label: series, style: _TitleTagStyle.burgundy),
@@ -743,7 +815,8 @@ class _TitleBlock extends StatelessWidget {
// Extract "53° · 酱香型白酒" from spec
String _keySpec(String spec) {
final parts = <String>[];
final degM = RegExp(r'(\d+(?:\.\d+)?)\s*(?:度|°|%vol)', caseSensitive: false).firstMatch(spec);
final degM = RegExp(r'(\d+(?:\.\d+)?)\s*(?:度|°|%vol)', caseSensitive: false)
.firstMatch(spec);
if (degM != null) parts.add('${degM.group(1)!}°');
final xiangM = RegExp(r'([清浓酱兼馥凤]香(?:型(?:白酒)?)?)').firstMatch(spec);
if (xiangM != null) parts.add(xiangM.group(1)!);
@@ -753,7 +826,8 @@ class _TitleBlock extends StatelessWidget {
String _buildSpecLine(String spec, String unit, String series) {
// Remove parts already captured in _keySpec
var s = spec
.replaceAll(RegExp(r'\d+(?:\.\d+)?\s*(?:度|°|%vol)', caseSensitive: false), '')
.replaceAll(
RegExp(r'\d+(?:\.\d+)?\s*(?:度|°|%vol)', caseSensitive: false), '')
.replaceAll(RegExp(r'[清浓酱兼馥凤]香(?:型(?:白酒)?)?'), '')
.replaceAll(RegExp(r'^[·\s]+|[·\s]+$'), '')
.trim();
@@ -787,10 +861,14 @@ class _TitleTag extends StatelessWidget {
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(4)),
decoration:
BoxDecoration(color: bg, borderRadius: BorderRadius.circular(4)),
child: Text(label,
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600,
color: fg, letterSpacing: 0.06)),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: fg,
letterSpacing: 0.06)),
);
}
}
@@ -827,8 +905,10 @@ class _QuickSpecs extends StatelessWidget {
text: TextSpan(
text: it.value,
style: const TextStyle(
fontSize: 22, fontWeight: FontWeight.w700,
color: _kInkDeep, height: 1,
fontSize: 22,
fontWeight: FontWeight.w700,
color: _kInkDeep,
height: 1,
fontFeatures: [FontFeature.tabularFigures()],
),
children: it.unit.isNotEmpty
@@ -836,8 +916,9 @@ class _QuickSpecs extends StatelessWidget {
TextSpan(
text: it.unit,
style: const TextStyle(
fontSize: 10, fontWeight: FontWeight.w500,
color: _kTextMid),
fontSize: 10,
fontWeight: FontWeight.w500,
color: _kTextMid),
)
]
: null,
@@ -887,28 +968,35 @@ class _DescriptionSection extends StatelessWidget {
Text(
body,
style: const TextStyle(
fontSize: 14, color: _kInkSoft, height: 1.85, letterSpacing: 0.02),
fontSize: 14,
color: _kInkSoft,
height: 1.85,
letterSpacing: 0.02),
textAlign: TextAlign.justify,
),
const SizedBox(height: 16),
Wrap(
spacing: 8, runSpacing: 6,
children: keywords.map((kw) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFF5F3EF),
border: Border.all(color: _kBorderSub),
borderRadius: BorderRadius.circular(999),
),
child: Text(kw, style: const TextStyle(
fontSize: 11, color: _kTextMid)),
)).toList(),
spacing: 8,
runSpacing: 6,
children: keywords
.map((kw) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFF5F3EF),
border: Border.all(color: _kBorderSub),
borderRadius: BorderRadius.circular(999),
),
child: Text(kw,
style:
const TextStyle(fontSize: 11, color: _kTextMid)),
))
.toList(),
),
],
),
);
}
}
// ── Authenticity Card ─────────────────────────────────────────────────
@@ -962,8 +1050,7 @@ class _AuthenticityCard extends StatelessWidget {
_AuthRow(label: '入库日期', value: inStockDate),
const SizedBox(height: 14),
],
if (qty > 0)
_AuthRow(label: '批次数量', value: '$qty'),
if (qty > 0) _AuthRow(label: '批次数量', value: '$qty'),
],
),
),
@@ -997,11 +1084,14 @@ class _AuthRow extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(fontSize: 10, color: _kTextMid, letterSpacing: 0.05)),
style: const TextStyle(
fontSize: 10, color: _kTextMid, letterSpacing: 0.05)),
const SizedBox(height: 3),
Text(value,
style: TextStyle(
fontSize: 13, color: _kInkSoft, fontWeight: FontWeight.w500,
fontSize: 13,
color: _kInkSoft,
fontWeight: FontWeight.w500,
fontFamily: mono ? 'monospace' : null,
letterSpacing: mono ? 0.5 : 0,
)),
@@ -1016,7 +1106,8 @@ class _AuthStamp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(
width: 84, height: 84,
width: 84,
height: 84,
child: CustomPaint(painter: _StampPainter()),
);
}
@@ -1040,7 +1131,8 @@ class _StampPainter extends CustomPainter {
canvas.drawCircle(center, innerR, ringPaint);
// Circular text "防伪验证 · VERIFIED · "
_drawCircularText(canvas, '防 伪 验 证 · V E R I F I E D · ', center, innerR - 7);
_drawCircularText(
canvas, '防 伪 验 证 · V E R I F I E D · ', center, innerR - 7);
// Center: checkmark
final checkPaint = Paint()
@@ -1060,8 +1152,10 @@ class _StampPainter extends CustomPainter {
text: const TextSpan(
text: '已验证',
style: TextStyle(
fontSize: 8.5, fontWeight: FontWeight.w700,
color: _kBurgundy, letterSpacing: 1.0,
fontSize: 8.5,
fontWeight: FontWeight.w700,
color: _kBurgundy,
letterSpacing: 1.0,
),
),
textDirection: TextDirection.ltr,
@@ -1069,7 +1163,8 @@ class _StampPainter extends CustomPainter {
tp.paint(canvas, Offset(cx - tp.width / 2, cy + 10));
}
void _drawCircularText(Canvas canvas, String text, Offset center, double radius) {
void _drawCircularText(
Canvas canvas, String text, Offset center, double radius) {
final charCount = text.length;
final angleStep = (2 * math.pi) / charCount;
const startAngle = -math.pi / 2;
@@ -1082,8 +1177,10 @@ class _StampPainter extends CustomPainter {
text: TextSpan(
text: text[i],
style: const TextStyle(
fontSize: 6.5, color: _kBurgundy,
fontWeight: FontWeight.w600, letterSpacing: 0,
fontSize: 6.5,
color: _kBurgundy,
fontWeight: FontWeight.w600,
letterSpacing: 0,
),
),
textDirection: TextDirection.ltr,
@@ -1102,9 +1199,9 @@ class _StampPainter extends CustomPainter {
class _ParamsCard extends StatelessWidget {
final String publicId, spec, brand, series, unit;
final String? productionDate;
final String origin; // 空串表示无产地数据,不显示该行
final String origin; // 空串表示无产地数据,不显示该行
final String shelfLife; // 后端已回填默认值
final String storage; // 后端已回填默认值
final String storage; // 后端已回填默认值
const _ParamsCard({
required this.publicId,
required this.spec,
@@ -1120,8 +1217,10 @@ class _ParamsCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Parse alcohol and volume from spec
final degM = RegExp(r'(\d+(?:\.\d+)?)\s*(?:度|°|%vol)', caseSensitive: false).firstMatch(spec);
final mlM = RegExp(r'(\d+(?:\.\d+)?)\s*ml', caseSensitive: false).firstMatch(spec);
final degM = RegExp(r'(\d+(?:\.\d+)?)\s*(?:度|°|%vol)', caseSensitive: false)
.firstMatch(spec);
final mlM =
RegExp(r'(\d+(?:\.\d+)?)\s*ml', caseSensitive: false).firstMatch(spec);
final xiangM = RegExp(r'([清浓酱兼馥凤]香(?:型(?:白酒)?)?)').firstMatch(spec);
final rows = <({String label, String value, bool mono})>[
@@ -1129,11 +1228,14 @@ class _ParamsCard extends StatelessWidget {
if (brand.isNotEmpty) (label: '品牌', value: brand, mono: false),
if (series.isNotEmpty) (label: '系列', value: series, mono: false),
if (xiangM != null) (label: '香型', value: xiangM.group(1)!, mono: false),
if (degM != null) (label: '酒精度', value: '${degM.group(1)!}% vol', mono: false),
if (mlM != null) (label: '净含量', value: '${mlM.group(1)!} ml', mono: false),
if (degM != null)
(label: '酒精度', value: '${degM.group(1)!}% vol', mono: false),
if (mlM != null)
(label: '净含量', value: '${mlM.group(1)!} ml', mono: false),
if (spec.isNotEmpty) (label: '规格', value: spec, mono: false),
if (origin.isNotEmpty) (label: '产地', value: origin, mono: false),
if (productionDate != null) (label: '生产日期', value: productionDate!, mono: false),
if (productionDate != null)
(label: '生产日期', value: productionDate!, mono: false),
(label: '保质期', value: shelfLife, mono: false),
(label: '储存方式', value: storage, mono: false),
];
@@ -1165,13 +1267,18 @@ class _ParamsCard extends StatelessWidget {
SizedBox(
width: 72,
child: Text(r.label,
style: const TextStyle(fontSize: 12, color: _kTextMid, letterSpacing: 0.02)),
style: const TextStyle(
fontSize: 12,
color: _kTextMid,
letterSpacing: 0.02)),
),
const SizedBox(width: 16),
Expanded(
child: Text(r.value,
style: TextStyle(
fontSize: 13, color: _kInkSoft, fontWeight: FontWeight.w500,
fontSize: 13,
color: _kInkSoft,
fontWeight: FontWeight.w500,
fontFamily: r.mono ? 'monospace' : null,
letterSpacing: r.mono ? 0.5 : 0.01,
)),
@@ -1198,12 +1305,17 @@ class _SectionHeader extends StatelessWidget {
Widget build(BuildContext context) {
return Row(
children: [
Container(width: 3, height: 16, color: _kBurgundy,
Container(
width: 3,
height: 16,
color: _kBurgundy,
margin: const EdgeInsets.only(right: 10)),
Text(title,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w700,
color: _kInkDeep, letterSpacing: 0.06)),
fontSize: 16,
fontWeight: FontWeight.w700,
color: _kInkDeep,
letterSpacing: 0.06)),
],
);
}
@@ -1242,7 +1354,8 @@ class _ShopCard extends StatelessWidget {
Row(
children: [
Container(
width: 52, height: 52,
width: 52,
height: 52,
decoration: BoxDecoration(
color: const Color(0xFF0A1F3B),
borderRadius: BorderRadius.circular(10),
@@ -1250,7 +1363,9 @@ class _ShopCard extends StatelessWidget {
child: Center(
child: Text(avatarText,
style: const TextStyle(
color: Colors.white, fontSize: 16, fontWeight: FontWeight.w700,
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: 0.04)),
),
),
@@ -1259,13 +1374,17 @@ class _ShopCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w600, color: _kInkDeep,
letterSpacing: 0.04)),
Text(name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: _kInkDeep,
letterSpacing: 0.04)),
if (code.isNotEmpty) ...[
const SizedBox(height: 2),
Text('门店编号 $code',
style: const TextStyle(fontSize: 12, color: _kTextMid)),
style:
const TextStyle(fontSize: 12, color: _kTextMid)),
],
],
),
@@ -1275,44 +1394,54 @@ class _ShopCard extends StatelessWidget {
const SizedBox(height: 16),
Container(height: 1, color: _kBorderSub),
const SizedBox(height: 14),
if (address.isNotEmpty) _ShopInfoRow(icon: Icons.location_on_outlined, text: address),
if (phone.isNotEmpty) _ShopInfoRow(icon: Icons.phone_outlined, text: phone),
if (address.isNotEmpty)
_ShopInfoRow(icon: LucideIcons.mapPin, text: address),
if (phone.isNotEmpty)
_ShopInfoRow(icon: LucideIcons.phone, text: phone),
_ShopInfoRow(
icon: Icons.access_time_outlined,
icon: LucideIcons.clock,
text: '营业时间 ${hours.isNotEmpty ? hours : "09:30 22:00"}',
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity, height: 44,
width: double.infinity,
height: 44,
child: ElevatedButton(
onPressed: code.isNotEmpty
? () => context.push(
'/shop/$code?shopName=${Uri.encodeComponent(name)}')
? () => context
.push('/shop/$code?shopName=${Uri.encodeComponent(name)}')
: null,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0A1F3B),
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
child: const Text('查看本店其他商品 →',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, letterSpacing: 0.04)),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.04)),
),
),
if (wechatId.isNotEmpty) ...[
const SizedBox(height: 10),
SizedBox(
width: double.infinity, height: 40,
width: double.infinity,
height: 40,
child: OutlinedButton.icon(
onPressed: () => _showWechatDialog(context, wechatId),
style: OutlinedButton.styleFrom(
foregroundColor: _kInkDeep,
side: const BorderSide(color: _kBorder, width: 1.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
icon: const Icon(Icons.person_outline, size: 16),
icon: const Icon(LucideIcons.user, size: 16),
label: const Text('添加门店微信',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
style:
TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
),
),
],
@@ -1326,19 +1455,23 @@ void _showWechatDialog(BuildContext context, String wechatId) {
showDialog<void>(
context: context,
builder: (_) => AlertDialog(
title: const Text('门店微信号', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
title: const Text('门店微信号',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wechat, size: 48, color: Color(0xFF07C160)),
const Icon(LucideIcons.messageCircle,
size: 48, color: Color(0xFF07C160)),
const SizedBox(height: 12),
SelectableText(
wechatId,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, letterSpacing: 0.5),
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w600, letterSpacing: 0.5),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const Text('长按上方微信号可复制', style: TextStyle(fontSize: 12, color: _kTextMid)),
const Text('长按上方微信号可复制',
style: TextStyle(fontSize: 12, color: _kTextMid)),
],
),
actions: [
@@ -1367,7 +1500,8 @@ class _ShopInfoRow extends StatelessWidget {
const SizedBox(width: 10),
Expanded(
child: Text(text,
style: const TextStyle(fontSize: 13, color: _kInkSoft, height: 1.5)),
style: const TextStyle(
fontSize: 13, color: _kInkSoft, height: 1.5)),
),
],
),
@@ -1393,7 +1527,8 @@ class _Footer extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 20, height: 20,
width: 20,
height: 20,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
@@ -1403,14 +1538,18 @@ class _Footer extends StatelessWidget {
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: const Color(0xFF2563AC).withValues(alpha: 0.30),
blurRadius: 4, offset: const Offset(0, 2)),
color: const Color(0xFF2563AC).withValues(alpha: 0.30),
blurRadius: 4,
offset: const Offset(0, 2)),
],
),
child: const Center(
child: Text('',
style: TextStyle(color: Colors.white, fontSize: 11,
fontWeight: FontWeight.w800, letterSpacing: -0.5)),
style: TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w800,
letterSpacing: -0.5)),
),
),
const SizedBox(width: 8),
@@ -1421,7 +1560,9 @@ class _Footer extends StatelessWidget {
children: [
TextSpan(
text: '岩美酒库管理系统',
style: TextStyle(fontWeight: FontWeight.w600, color: Color(0xFF353C48)),
style: TextStyle(
fontWeight: FontWeight.w600,
color: Color(0xFF353C48)),
),
TextSpan(text: ' 提供'),
],
@@ -1456,9 +1597,11 @@ class _FooterTextLink extends StatelessWidget {
Widget build(BuildContext context) {
final active = url != null || onTap != null;
return GestureDetector(
onTap: onTap ?? (url != null
? () => launchUrl(Uri.parse(url!), mode: LaunchMode.externalApplication)
: null),
onTap: onTap ??
(url != null
? () => launchUrl(Uri.parse(url!),
mode: LaunchMode.externalApplication)
: null),
child: Text(text,
style: TextStyle(
fontSize: 11,
@@ -1489,7 +1632,8 @@ class _FeedbackDialog extends StatefulWidget {
final String title;
final String hint;
final Future<void> Function(String) onSubmit;
const _FeedbackDialog({required this.title, required this.hint, required this.onSubmit});
const _FeedbackDialog(
{required this.title, required this.hint, required this.onSubmit});
@override
State<_FeedbackDialog> createState() => _FeedbackDialogState();
@@ -1511,7 +1655,11 @@ class _FeedbackDialogState extends State<_FeedbackDialog> {
setState(() => _submitting = true);
try {
await widget.onSubmit(_ctrl.text.trim());
if (mounted) setState(() { _done = true; _submitting = false; });
if (mounted)
setState(() {
_done = true;
_submitting = false;
});
} catch (_) {
if (mounted) setState(() => _submitting = false);
}
@@ -1525,7 +1673,7 @@ class _FeedbackDialogState extends State<_FeedbackDialog> {
content: const Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_outline, size: 48, color: _kSuccess),
Icon(LucideIcons.circleCheck, size: 48, color: _kSuccess),
SizedBox(height: 12),
Text('提交成功,感谢您的反馈!',
style: TextStyle(fontSize: 14, color: _kInkDeep)),
@@ -1563,10 +1711,13 @@ class _FeedbackDialogState extends State<_FeedbackDialog> {
ElevatedButton(
onPressed: _submitting ? null : _submit,
style: ElevatedButton.styleFrom(
backgroundColor: _kBurgundy, foregroundColor: Colors.white),
backgroundColor: _kBurgundy, foregroundColor: Colors.white),
child: _submitting
? const SizedBox(width: 16, height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: const Text('提交'),
),
],
@@ -1,3 +1,4 @@
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
@@ -25,8 +26,7 @@ class PublicShopProductsScreen extends StatefulWidget {
_PublicShopProductsScreenState();
}
class _PublicShopProductsScreenState
extends State<PublicShopProductsScreen> {
class _PublicShopProductsScreenState extends State<PublicShopProductsScreen> {
final _dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
@@ -83,8 +83,8 @@ class _PublicShopProductsScreenState
queryParameters: {'page': _page, 'page_size': 20},
);
final body = resp.data as Map<String, dynamic>;
final list = (body['data'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>();
final list =
(body['data'] as List<dynamic>? ?? []).cast<Map<String, dynamic>>();
final total = (body['total'] as num?)?.toInt() ?? 0;
if (!mounted) return;
@@ -108,8 +108,7 @@ class _PublicShopProductsScreenState
@override
Widget build(BuildContext context) {
final title =
widget.shopName.isNotEmpty ? widget.shopName : '本店商品';
final title = widget.shopName.isNotEmpty ? widget.shopName : '本店商品';
return Scaffold(
backgroundColor: _kPaperDeep,
@@ -118,16 +117,14 @@ class _PublicShopProductsScreenState
elevation: 0,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
icon: const Icon(LucideIcons.arrowLeft, size: 18),
color: _kInkDeep,
onPressed: () => context.pop(),
),
title: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: _kInkDeep),
fontSize: 16, fontWeight: FontWeight.w600, color: _kInkDeep),
),
centerTitle: true,
bottom: PreferredSize(
@@ -142,8 +139,7 @@ class _PublicShopProductsScreenState
Widget _buildBody() {
if (_loading) {
return const Center(
child: CircularProgressIndicator(
color: _kBurgundy, strokeWidth: 2));
child: CircularProgressIndicator(color: _kBurgundy, strokeWidth: 2));
}
if (_error != null && _items.isEmpty) {
return Center(
@@ -157,7 +153,7 @@ class _PublicShopProductsScreenState
height: 56,
decoration: const BoxDecoration(
color: Color(0xFFFDECEC), shape: BoxShape.circle),
child: const Icon(Icons.error_outline,
child: const Icon(LucideIcons.circleAlert,
size: 28, color: Color(0xFFD14343)),
),
const SizedBox(height: 16),
@@ -187,10 +183,9 @@ class _PublicShopProductsScreenState
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inventory_2_outlined, size: 48, color: _kTextMid),
Icon(LucideIcons.box, size: 48, color: _kTextMid),
SizedBox(height: 12),
Text('暂无上架商品',
style: TextStyle(fontSize: 15, color: _kTextMid)),
Text('暂无上架商品', style: TextStyle(fontSize: 15, color: _kTextMid)),
],
),
);
@@ -210,8 +205,7 @@ class _PublicShopProductsScreenState
sliver: SliverToBoxAdapter(
child: Text(
'$_total 件商品',
style: const TextStyle(
fontSize: 12, color: _kTextMid),
style: const TextStyle(fontSize: 12, color: _kTextMid),
),
),
),
@@ -219,12 +213,10 @@ class _PublicShopProductsScreenState
padding: const EdgeInsets.all(12),
sliver: SliverGrid(
delegate: SliverChildBuilderDelegate(
(context, index) =>
_ProductCard(item: _items[index]),
(context, index) => _ProductCard(item: _items[index]),
childCount: _items.length,
),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
@@ -247,8 +239,7 @@ class _PublicShopProductsScreenState
padding: EdgeInsets.all(20),
child: Center(
child: Text('已展示全部商品',
style: TextStyle(
fontSize: 12, color: _kTextMid))),
style: TextStyle(fontSize: 12, color: _kTextMid))),
),
),
],
@@ -275,8 +266,8 @@ class _ProductCard extends StatelessWidget {
final qtyStr = qtyNum == qtyNum.roundToDouble()
? qtyNum.toInt().toString()
: qtyNum.toString();
final images = (item['images'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>();
final images =
(item['images'] as List<dynamic>? ?? []).cast<Map<String, dynamic>>();
final imageUrl = images.isNotEmpty
? AppConfig.baseUrl + (images.first['url'] as String)
: null;
@@ -308,8 +299,7 @@ class _ProductCard extends StatelessWidget {
? Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
_PlaceholderImg(),
errorBuilder: (_, __, ___) => _PlaceholderImg(),
)
: _PlaceholderImg(),
),
@@ -317,8 +307,7 @@ class _ProductCard extends StatelessWidget {
// 商品信息
Expanded(
child: Padding(
padding:
const EdgeInsets.fromLTRB(8, 6, 8, 8),
padding: const EdgeInsets.fromLTRB(8, 6, 8, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -338,8 +327,7 @@ class _ProductCard extends StatelessWidget {
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11, color: _kTextMid),
style: const TextStyle(fontSize: 11, color: _kTextMid),
),
],
const Spacer(),
@@ -391,7 +379,7 @@ class _PlaceholderImg extends StatelessWidget {
return Container(
color: _kPaperDeep,
child: const Center(
child: Icon(Icons.wine_bar_outlined, size: 40, color: _kBorder),
child: Icon(LucideIcons.wine, size: 40, color: _kBorder),
),
);
}