From 1ec1d4209a6f5566c05370418615ec1af3da0a31 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 8 Jun 2026 07:31:55 +0800 Subject: [PATCH] chore: release v1.0.24 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 12 + backend/internal/handler/public.go | 118 +++++- backend/internal/handler/shop.go | 2 + backend/internal/model/shop.go | 1 + backend/internal/router/router.go | 1 + backend/testutil/setup.go | 1 + client/lib/core/router/app_router.dart | 12 +- client/lib/models/shop.dart | 3 + .../screens/public/public_product_screen.dart | 109 ++++- .../public/public_shop_products_screen.dart | 381 ++++++++++++++++++ .../lib/screens/settings/settings_screen.dart | 12 + todo/todo.html | 240 ++++++----- todo/todo.json | 112 +++-- 13 files changed, 832 insertions(+), 172 deletions(-) create mode 100644 client/lib/screens/public/public_shop_products_screen.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index f0f998d..ec94fa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.24] - 2026-06-08 + +### 新功能 +- 扫码商品页新增「查看本店其他商品」功能,点击即可浏览该门店全部上架商品 +- 门店可在设置中填写微信号,顾客扫码后可直接查看并添加门店微信 +- 商品公开页展示建议零售价(有填写时显示) +- 入库时可为商品关联产地、保质期、储存方式、描述文档,关联后扫码页自动呈现真实信息 +- 扫码商品页产地、保质期、储存方式、商品介绍改读数据库真实数据,不再显示占位内容 + +### 改进 +- 新增产地/保质期/储存方式/描述文档四类基础数据字典,支持增删改查,可在入库时按需关联商品 + ## [1.0.23] - 2026-06-07 ### 修复 diff --git a/backend/internal/handler/public.go b/backend/internal/handler/public.go index 0f81d88..9abd79d 100644 --- a/backend/internal/handler/public.go +++ b/backend/internal/handler/public.go @@ -1,8 +1,10 @@ package handler import ( + "errors" "fmt" "net/http" + "strconv" "strings" "github.com/gin-gonic/gin" @@ -51,6 +53,7 @@ func (h *PublicHandler) GetProduct(c *gin.Context) { "address": shop.Address, "phone": shop.Phone, "business_hours": shop.BusinessHours, + "wechat_id": shop.WechatID, } } @@ -95,21 +98,22 @@ func (h *PublicHandler) GetProduct(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "data": gin.H{ - "id": product.ID, - "name": product.Name, - "series": product.Series, - "spec": product.Spec, - "brand": product.Brand, - "unit": product.Unit, - "description": descBody, - "description_title": descTitle, + "id": product.ID, + "name": product.Name, + "series": product.Series, + "spec": product.Spec, + "brand": product.Brand, + "unit": product.Unit, + "sale_price": product.SalePrice, + "description": descBody, + "description_title": descTitle, "description_keywords": descKeywords, - "origin": origin, - "shelf_life": shelfLife, - "storage": storage, - "images": product.Images, - "shop": shopData, - "batch": batchData, + "origin": origin, + "shelf_life": shelfLife, + "storage": storage, + "images": product.Images, + "shop": shopData, + "batch": batchData, }, }) } @@ -181,3 +185,89 @@ func (h *PublicHandler) GetRelease(c *gin.Context) { "download_urls": cfg.DownloadURLs, }) } + +type publicProductImage struct { + URL string `json:"url"` +} + +type publicProductResp struct { + ID uint64 `json:"id"` + PublicID string `json:"public_id"` + Name string `json:"name"` + Series string `json:"series"` + Spec string `json:"spec"` + Brand string `json:"brand"` + Unit string `json:"unit"` + SalePrice float64 `json:"sale_price"` + Images []publicProductImage `json:"images"` +} + +// ListShopProducts GET /api/v1/public/shops/:shop_code/products (no auth) +func (h *PublicHandler) ListShopProducts(c *gin.Context) { + shopCode := c.Param("shop_code") + + var shop model.Shop + if err := h.db.Where("code = ? AND deleted_at IS NULL", shopCode).First(&shop).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "shop not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 50 { + pageSize = 20 + } + + query := h.db.Model(&model.Product{}). + Where("shop_id = ? AND public_id IS NOT NULL AND public_id != '' AND deleted_at IS NULL", shop.ID) + + var total int64 + if err := query.Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var products []model.Product + offset := (page - 1) * pageSize + if err := query.Preload("Images"). + Offset(offset). + Limit(pageSize). + Order("id DESC"). + Find(&products).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + listData := make([]publicProductResp, len(products)) + for i, p := range products { + imgs := make([]publicProductImage, len(p.Images)) + for j, img := range p.Images { + imgs[j] = publicProductImage{URL: img.URL} + } + listData[i] = publicProductResp{ + ID: p.ID, + PublicID: p.PublicID, + Name: p.Name, + Series: p.Series, + Spec: p.Spec, + Brand: p.Brand, + Unit: p.Unit, + SalePrice: p.SalePrice, + Images: imgs, + } + } + + c.JSON(http.StatusOK, gin.H{ + "data": listData, + "total": total, + "page": page, + "page_size": pageSize, + }) +} diff --git a/backend/internal/handler/shop.go b/backend/internal/handler/shop.go index a9fc071..1f0947f 100644 --- a/backend/internal/handler/shop.go +++ b/backend/internal/handler/shop.go @@ -47,6 +47,7 @@ func (h *ShopHandler) UpdateInfo(c *gin.Context) { Phone string `json:"phone"` ManagerName string `json:"manager_name"` LogoURL string `json:"logo_url"` + WechatID string `json:"wechat_id"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -58,6 +59,7 @@ func (h *ShopHandler) UpdateInfo(c *gin.Context) { "address": req.Address, "phone": req.Phone, "manager_name": req.ManagerName, + "wechat_id": req.WechatID, } if req.LogoURL != "" { updates["logo_url"] = req.LogoURL diff --git a/backend/internal/model/shop.go b/backend/internal/model/shop.go index b7c157c..5c290c4 100644 --- a/backend/internal/model/shop.go +++ b/backend/internal/model/shop.go @@ -10,6 +10,7 @@ type Shop struct { BusinessHours string `gorm:"size:100" json:"business_hours"` ManagerName string `gorm:"size:50" json:"manager_name"` LogoURL string `gorm:"column:logo_url;size:500" json:"logo_url"` + WechatID string `gorm:"size:100" json:"wechat_id"` BusinessLicense string `gorm:"size:500" json:"business_license"` ShopPhotos JSON `gorm:"type:json" json:"shop_photos,omitempty"` CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index dc67c4b..615c798 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -58,6 +58,7 @@ func Setup(r *gin.Engine, db *gorm.DB) { public := v1.Group("/public") { public.GET("/products/:public_id", publicH.GetProduct) + public.GET("/shops/:shop_code/products", publicH.ListShopProducts) public.GET("/release", publicH.GetRelease) public.POST("/errors", errorReportH.Submit) public.POST("/register", authH.Register) diff --git a/backend/testutil/setup.go b/backend/testutil/setup.go index 40aec2a..289698d 100644 --- a/backend/testutil/setup.go +++ b/backend/testutil/setup.go @@ -58,6 +58,7 @@ func SetupTestDB() *gorm.DB { phone TEXT, manager_name TEXT, logo_url TEXT DEFAULT '', + wechat_id TEXT DEFAULT '', business_license TEXT, business_hours TEXT, shop_photos TEXT, diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index a5d50f8..a756de2 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -14,6 +14,7 @@ import '../../screens/finance/finance_screen.dart'; import '../../screens/products/products_screen.dart'; import '../../screens/products/product_detail_screen.dart'; import '../../screens/public/public_product_screen.dart'; +import '../../screens/public/public_shop_products_screen.dart'; import '../../screens/settings/settings_screen.dart'; import '../../screens/about/about_screen.dart'; import '../auth/auth_state.dart'; @@ -40,7 +41,8 @@ class _RouterNotifier extends ChangeNotifier { final isLoggedIn = authState.isLoggedIn; final loc = state.matchedLocation; final isPublicRoute = loc == '/login' || - loc.startsWith('/product/'); + loc.startsWith('/product/') || + loc.startsWith('/shop/'); final result = !authState.initialized ? null : (!isLoggedIn && !isPublicRoute) @@ -79,6 +81,14 @@ final appRouterProvider = Provider((ref) { builder: (context, state) => PublicProductScreen(publicId: state.pathParameters['public_id']!), ), + // Public shop product list — no auth, no shell nav bar + GoRoute( + path: '/shop/:shop_code', + builder: (context, state) => PublicShopProductsScreen( + shopCode: state.pathParameters['shop_code']!, + shopName: state.uri.queryParameters['shopName'] ?? '', + ), + ), GoRoute( path: '/login', builder: (context, state) => const LoginScreen(), diff --git a/client/lib/models/shop.dart b/client/lib/models/shop.dart index 6de9f1d..3725ef6 100644 --- a/client/lib/models/shop.dart +++ b/client/lib/models/shop.dart @@ -6,6 +6,7 @@ class ShopInfo { final String phone; final String managerName; final String logoUrl; + final String wechatId; const ShopInfo({ required this.id, @@ -15,6 +16,7 @@ class ShopInfo { required this.phone, required this.managerName, this.logoUrl = '', + this.wechatId = '', }); factory ShopInfo.fromJson(Map json) => ShopInfo( @@ -25,5 +27,6 @@ class ShopInfo { phone: json['phone'] as String? ?? '', managerName: json['manager_name'] as String? ?? '', logoUrl: json['logo_url'] as String? ?? '', + wechatId: json['wechat_id'] as String? ?? '', ); } diff --git a/client/lib/screens/public/public_product_screen.dart b/client/lib/screens/public/public_product_screen.dart index 805040e..57be073 100644 --- a/client/lib/screens/public/public_product_screen.dart +++ b/client/lib/screens/public/public_product_screen.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'package:dio/dio.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../core/config/app_config.dart'; @@ -119,6 +120,7 @@ class _PublicProductScreenState extends State { final descriptionKeywords = (d['description_keywords'] as List?) ?.map((e) => e as String) .toList() ?? const []; + 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? ?? '阴凉干燥、避光保存'; @@ -153,7 +155,12 @@ class _PublicProductScreenState extends State { unit: unit, ), ), - // 4. Quick specs (only if we could parse any) + // 4a. Sale price badge (only when price > 0) + if (salePrice > 0) + SliverToBoxAdapter( + child: _PriceBadge(price: salePrice), + ), + // 4b. Quick specs (only if we could parse any) if (quickSpecs.isNotEmpty) SliverToBoxAdapter( child: _QuickSpecs(items: quickSpecs), @@ -607,6 +614,43 @@ class _VerifiedRibbon extends StatelessWidget { } } +// ── Price Badge ─────────────────────────────────────────────────────── + +class _PriceBadge extends StatelessWidget { + final double price; + const _PriceBadge({required this.price}); + + @override + Widget build(BuildContext context) { + final priceStr = price % 1 == 0 + ? price.toInt().toString() + : price.toStringAsFixed(2); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: const BoxDecoration( + color: _kPaper, + border: Border(bottom: BorderSide(color: _kBorder)), + ), + child: Row( + children: [ + const Text('建议零售价', + style: TextStyle(fontSize: 13, color: _kTextMid)), + const Spacer(), + Text( + '¥$priceStr', + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: _kBurgundy, + letterSpacing: 0.04, + ), + ), + ], + ), + ); + } +} + // ── Title Block ─────────────────────────────────────────────────────── class _TitleBlock extends StatelessWidget { @@ -1187,6 +1231,7 @@ class _ShopCard extends StatelessWidget { final address = shop['address'] as String? ?? ''; final phone = shop['phone'] as String? ?? ''; final hours = shop['business_hours'] as String? ?? ''; + final wechatId = shop['wechat_id'] as String? ?? ''; // Avatar text: first 2 chars of shop name final avatarText = name.length >= 2 ? name.substring(0, 2) : name; @@ -1249,7 +1294,10 @@ class _ShopCard extends StatelessWidget { SizedBox( width: double.infinity, height: 44, child: ElevatedButton( - onPressed: () {}, + onPressed: code.isNotEmpty + ? () => context.push( + '/shop/$code?shopName=${Uri.encodeComponent(name)}') + : null, style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF0A1F3B), foregroundColor: Colors.white, @@ -1260,27 +1308,58 @@ class _ShopCard extends StatelessWidget { style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, letterSpacing: 0.04)), ), ), - const SizedBox(height: 10), - SizedBox( - width: double.infinity, height: 40, - child: OutlinedButton.icon( - onPressed: () {}, - style: OutlinedButton.styleFrom( - foregroundColor: _kInkDeep, - side: const BorderSide(color: _kBorder, width: 1.5), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + if (wechatId.isNotEmpty) ...[ + const SizedBox(height: 10), + SizedBox( + 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)), + ), + icon: const Icon(Icons.person_outline, size: 16), + label: const Text('添加门店微信', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), - icon: const Icon(Icons.person_outline, size: 16), - label: const Text('添加门店微信', - style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), - ), + ], ], ), ); } } +void _showWechatDialog(BuildContext context, String wechatId) { + showDialog( + context: context, + builder: (_) => AlertDialog( + 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 SizedBox(height: 12), + SelectableText( + wechatId, + 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)), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('关闭'), + ), + ], + ), + ); +} + class _ShopInfoRow extends StatelessWidget { final IconData icon; final String text; diff --git a/client/lib/screens/public/public_shop_products_screen.dart b/client/lib/screens/public/public_shop_products_screen.dart new file mode 100644 index 0000000..a3a6d72 --- /dev/null +++ b/client/lib/screens/public/public_shop_products_screen.dart @@ -0,0 +1,381 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../../core/config/app_config.dart'; + +const _kPaper = Color(0xFFFAF8F5); +const _kPaperDeep = Color(0xFFF4F1EB); +const _kInkDeep = Color(0xFF161412); +const _kTextMid = Color(0xFF6E7888); +const _kBurgundy = Color(0xFF8B2331); +const _kBorder = Color(0xFFE8E4DC); + +class PublicShopProductsScreen extends StatefulWidget { + final String shopCode; + final String shopName; + + const PublicShopProductsScreen({ + super.key, + required this.shopCode, + this.shopName = '', + }); + + @override + State createState() => + _PublicShopProductsScreenState(); +} + +class _PublicShopProductsScreenState + extends State { + final _dio = Dio(BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 15), + )); + + final List> _items = []; + bool _loading = true; + bool _loadingMore = false; + String? _error; + int _page = 1; + bool _hasMore = true; + int _total = 0; + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _load(refresh: true); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 200 && + !_loadingMore && + _hasMore) { + _load(); + } + } + + Future _load({bool refresh = false}) async { + if (refresh) { + setState(() { + _loading = true; + _error = null; + _page = 1; + _hasMore = true; + _items.clear(); + }); + } else { + if (_loadingMore) return; + setState(() => _loadingMore = true); + } + + try { + final resp = await _dio.get( + '${AppConfig.apiBaseUrl}/public/shops/${widget.shopCode}/products', + queryParameters: {'page': _page, 'page_size': 20}, + ); + final body = resp.data as Map; + final list = (body['data'] as List? ?? []) + .cast>(); + final total = (body['total'] as num?)?.toInt() ?? 0; + + if (!mounted) return; + setState(() { + _total = total; + _items.addAll(list); + _hasMore = _items.length < total; + _page++; + _loading = false; + _loadingMore = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + _loadingMore = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final title = + widget.shopName.isNotEmpty ? widget.shopName : '本店商品'; + + return Scaffold( + backgroundColor: _kPaperDeep, + appBar: AppBar( + backgroundColor: _kPaper, + elevation: 0, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, size: 18), + color: _kInkDeep, + onPressed: () => context.pop(), + ), + title: Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: _kInkDeep), + ), + centerTitle: true, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Container(height: 1, color: _kBorder), + ), + ), + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (_loading) { + return const Center( + child: CircularProgressIndicator( + color: _kBurgundy, strokeWidth: 2)); + } + if (_error != null && _items.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 56, + height: 56, + decoration: const BoxDecoration( + color: Color(0xFFFDECEC), shape: BoxShape.circle), + child: const Icon(Icons.error_outline, + size: 28, color: Color(0xFFD14343)), + ), + const SizedBox(height: 16), + const Text('加载失败,请稍后重试', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: _kInkDeep)), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () => _load(refresh: true), + style: ElevatedButton.styleFrom( + backgroundColor: _kBurgundy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), + ), + child: const Text('重试'), + ), + ], + ), + ), + ); + } + if (_items.isEmpty) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.inventory_2_outlined, size: 48, color: _kTextMid), + SizedBox(height: 12), + Text('暂无上架商品', + style: TextStyle(fontSize: 15, color: _kTextMid)), + ], + ), + ); + } + + return RefreshIndicator( + color: _kBurgundy, + onRefresh: () => _load(refresh: true), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: CustomScrollView( + controller: _scrollController, + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), + sliver: SliverToBoxAdapter( + child: Text( + '共 $_total 件商品', + style: const TextStyle( + fontSize: 12, color: _kTextMid), + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.all(12), + sliver: SliverGrid( + delegate: SliverChildBuilderDelegate( + (context, index) => + _ProductCard(item: _items[index]), + childCount: _items.length, + ), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: 0.72, + ), + ), + ), + if (_loadingMore) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(16), + child: Center( + child: CircularProgressIndicator( + color: _kBurgundy, strokeWidth: 2)), + ), + ), + if (!_hasMore && _items.isNotEmpty) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(20), + child: Center( + child: Text('已展示全部商品', + style: TextStyle( + fontSize: 12, color: _kTextMid))), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ProductCard extends StatelessWidget { + final Map item; + const _ProductCard({required this.item}); + + @override + Widget build(BuildContext context) { + final publicId = item['public_id'] as String? ?? ''; + final name = item['name'] as String? ?? ''; + final series = item['series'] as String? ?? ''; + final spec = item['spec'] as String? ?? ''; + final images = (item['images'] as List? ?? []) + .cast>(); + final imageUrl = images.isNotEmpty + ? AppConfig.baseUrl + (images.first['url'] as String) + : null; + + final subtitle = [series, spec].where((s) => s.isNotEmpty).join(' · '); + + return GestureDetector( + onTap: () { + if (publicId.isNotEmpty) { + context.push('/product/$publicId'); + } + }, + child: Container( + decoration: BoxDecoration( + color: _kPaper, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: _kBorder, width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 商品图片 + ClipRRect( + borderRadius: + const BorderRadius.vertical(top: Radius.circular(10)), + child: AspectRatio( + aspectRatio: 1, + child: imageUrl != null + ? Image.network( + imageUrl, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + _PlaceholderImg(), + ) + : _PlaceholderImg(), + ), + ), + // 商品信息 + Expanded( + child: Padding( + padding: + const EdgeInsets.fromLTRB(8, 6, 8, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: _kInkDeep, + height: 1.3), + ), + if (subtitle.isNotEmpty) ...[ + const SizedBox(height: 3), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 11, color: _kTextMid), + ), + ], + const Spacer(), + Row( + children: [ + const Spacer(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: _kBurgundy, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + '查看详情', + style: TextStyle( + fontSize: 10, + color: Colors.white, + fontWeight: FontWeight.w500), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class _PlaceholderImg extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + color: _kPaperDeep, + child: const Center( + child: Icon(Icons.wine_bar_outlined, size: 40, color: _kBorder), + ), + ); + } +} diff --git a/client/lib/screens/settings/settings_screen.dart b/client/lib/screens/settings/settings_screen.dart index 348e8f5..779cc42 100644 --- a/client/lib/screens/settings/settings_screen.dart +++ b/client/lib/screens/settings/settings_screen.dart @@ -1822,6 +1822,7 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> { late final TextEditingController _addressCtrl; late final TextEditingController _phoneCtrl; late final TextEditingController _managerCtrl; + late final TextEditingController _wechatCtrl; bool _saving = false; bool _uploadingLogo = false; @@ -1832,6 +1833,7 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> { _addressCtrl = TextEditingController(text: widget.shop.address); _phoneCtrl = TextEditingController(text: widget.shop.phone); _managerCtrl = TextEditingController(text: widget.shop.managerName); + _wechatCtrl = TextEditingController(text: widget.shop.wechatId); } @override @@ -1840,6 +1842,7 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> { _addressCtrl.dispose(); _phoneCtrl.dispose(); _managerCtrl.dispose(); + _wechatCtrl.dispose(); super.dispose(); } @@ -1882,6 +1885,7 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> { 'address': _addressCtrl.text.trim(), 'phone': _phoneCtrl.text.trim(), 'manager_name': _managerCtrl.text.trim(), + 'wechat_id': _wechatCtrl.text.trim(), }); if (mounted) { Navigator.of(context).pop(); @@ -1950,6 +1954,14 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> { controller: _managerCtrl, decoration: const InputDecoration(labelText: '负责人'), ), + const SizedBox(height: 12), + TextField( + controller: _wechatCtrl, + decoration: const InputDecoration( + labelText: '微信号(可选)', + hintText: '填写后顾客扫码可查看', + ), + ), ], ), ), diff --git a/todo/todo.html b/todo/todo.html index 2dc4b6a..25fda0f 100644 --- a/todo/todo.html +++ b/todo/todo.html @@ -101,9 +101,9 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }

酒库管理系统 — 项目 TODO

生成于 2026-06-08 · 真相源 todo/todo.json
-
13全部
-
11未完成
-
2已完成
+
15全部
+
7未完成
+
8已完成
@@ -126,13 +126,13 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
平台 / 标签 - +
-
📋 未完成(11)
+
📋 未完成(7)
  • -
  • -
    - 补全「查看本店其他商品」功能 - 重要 -
    -
    PublicProductScreen _ShopCard 底部按钮 onPressed: () {} 为空,需实现跳转至该门店商品列表页。client/lib/screens/public/public_product_screen.dart:1240
    - -
  • - -
  • -
    - 设计并实现添加门店微信功能 - 重要 -
    -
    PublicProductScreen _ShopCard「添加门店微信」按钮 onPressed: () {} 为空。需设计方案:门店维护微信号字段 or 二维码图片,扫码页展示。涉及后端 shop model 扩展 + 前端展示。client/lib/screens/public/public_product_screen.dart:1255
    - -
  • - -
  • -
    - 公开商品 API 补充 sale_price 并在页面展示价格 - 重要 -
    -
    product model 有 sale_price 字段但 public.go GetProduct 未返回。需:1) 后端 batchData/productData 加 sale_price;2) Flutter _TitleBlock 或单独区块展示建议零售价。client: public_product_screen.dart, backend: handler/public.go
    - -
  • - -
  • -
    - 清除 _ParamsCard 硬编码占位数据 - 重要 -
    -
    public_product_screen.dart _ParamsCard 硬编码了「产地:贵州省仁怀市茅台镇」「生产日期:2024-01-01」「保质期:无限期(适饮)」「储存方式:阴凉干燥…」,非茅台商品显示错误信息。无数据时应不显示该行,或从 custom_fields 读取。line:1089-1092
    - -
  • - -
  • -
    - 移除 _DescriptionSection 茅台 mock 文本 - 重要 -
    -
    无商品描述时 fallback 显示飞天茅台硬编码文案和 mock 关键词,其他商品扫码看到茅台介绍。无描述时应隐藏该区块。public_product_screen.dart:819-825
    - -
  • -
+ +
  • +
    + 基础数据管理 UI 增加 4 个字典维护 Tab + 一般 / 优化 +
    +
    products_screen.dart 基础数据管理页增加产地/保质期/储存方式/描述文档 4 个维护 Tab,复用现有 option 维护组件(增删改查);描述文档 Tab 含 title+content 多行输入
    + +
  • -
    ✅ 已完成(2)
    +
    ✅ 已完成(8)