chore: release v1.0.24
Deploy / build-linux-web (push) Successful in 59s
Deploy / build-windows (push) Successful in 1m50s
Deploy / build-macos (push) Successful in 1m22s
Deploy / build-android (push) Successful in 1m26s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m39s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-08 07:31:55 +08:00
parent 22f07ed805
commit 1ec1d4209a
13 changed files with 832 additions and 172 deletions
+12
View File
@@ -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
### 修复
+104 -14
View File
@@ -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,
})
}
+2
View File
@@ -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
+1
View File
@@ -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"`
+1
View File
@@ -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)
+1
View File
@@ -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,
+11 -1
View File
@@ -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<GoRouter>((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(),
+3
View File
@@ -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<String, dynamic> 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? ?? '',
);
}
@@ -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<PublicProductScreen> {
final descriptionKeywords = (d['description_keywords'] as List<dynamic>?)
?.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? ?? '阴凉干燥、避光保存';
@@ -153,7 +155,12 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
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<void>(
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;
@@ -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<PublicShopProductsScreen> createState() =>
_PublicShopProductsScreenState();
}
class _PublicShopProductsScreenState
extends State<PublicShopProductsScreen> {
final _dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
));
final List<Map<String, dynamic>> _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<void> _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<String, dynamic>;
final list = (body['data'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>();
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<String, dynamic> 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<dynamic>? ?? [])
.cast<Map<String, dynamic>>();
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),
),
);
}
}
@@ -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: '填写后顾客扫码可查看',
),
),
],
),
),
+139 -101
View File
@@ -101,9 +101,9 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<h1>酒库管理系统 — 项目 TODO</h1>
<div class="header-meta">生成于 2026-06-08 · 真相源 todo/todo.json</div>
<div class="stats">
<div class="stat-pill"><strong>13</strong>全部</div>
<div class="stat-pill"><strong>11</strong>未完成</div>
<div class="stat-pill"><strong>2</strong>已完成</div>
<div class="stat-pill"><strong>15</strong>全部</div>
<div class="stat-pill"><strong>7</strong>未完成</div>
<div class="stat-pill"><strong>8</strong>已完成</div>
</div>
</div>
</header>
@@ -126,13 +126,13 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="filter-sep"></div>
<div class="filter-group">
<span class="filter-label">平台 / 标签</span>
<button class="filter-chip" data-filter-tag="Android">Android</button><button class="filter-chip" data-filter-tag="Web">Web</button><button class="filter-chip" data-filter-tag="iOS">iOS</button><button class="filter-chip" data-filter-tag="前端">前端</button><button class="filter-chip" data-filter-tag="后端">后端</button>
<button class="filter-chip" data-filter-tag="Android">Android</button><button class="filter-chip" data-filter-tag="Web">Web</button><button class="filter-chip" data-filter-tag="iOS">iOS</button><button class="filter-chip" data-filter-tag="前端">前端</button><button class="filter-chip" data-filter-tag="后端">后端</button><button class="filter-chip" data-filter-tag="数据库">数据库</button>
</div>
</div>
<div class="wrap">
<div class="section-title">📋 未完成(11</div>
<div class="section-title">📋 未完成(7</div>
<ul class="todo-list" id="pendingList">
<li class="todo-card"
@@ -154,101 +154,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
</div>
</li>
<li class="todo-card"
data-id="8"
data-level="mid"
data-tags="前端,iOS,Android"
data-done="0">
<div class="card-header">
<span class="item-title">补全「查看本店其他商品」功能</span>
<span class="tag t-high">重要</span>
</div>
<div class="item-desc">PublicProductScreen _ShopCard 底部按钮 onPressed: () {} 为空,需实现跳转至该门店商品列表页。client/lib/screens/public/public_product_screen.dart:1240</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
</div>
</div>
</li>
<li class="todo-card"
data-id="9"
data-level="mid"
data-tags="前端,iOS,Android"
data-done="0">
<div class="card-header">
<span class="item-title">设计并实现添加门店微信功能</span>
<span class="tag t-high">重要</span>
</div>
<div class="item-desc">PublicProductScreen _ShopCard「添加门店微信」按钮 onPressed: () {} 为空。需设计方案:门店维护微信号字段 or 二维码图片,扫码页展示。涉及后端 shop model 扩展 + 前端展示。client/lib/screens/public/public_product_screen.dart:1255</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
</div>
</div>
</li>
<li class="todo-card"
data-id="10"
data-level="mid"
data-tags="前端,后端,iOS,Android"
data-done="0">
<div class="card-header">
<span class="item-title">公开商品 API 补充 sale_price 并在页面展示价格</span>
<span class="tag t-high">重要</span>
</div>
<div class="item-desc">product model 有 sale_price 字段但 public.go GetProduct 未返回。需:1) 后端 batchData/productData 加 sale_price2) Flutter _TitleBlock 或单独区块展示建议零售价。client: public_product_screen.dart, backend: handler/public.go</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
</div>
</div>
</li>
<li class="todo-card"
data-id="11"
data-level="mid"
data-tags="前端,iOS,Android"
data-done="0">
<div class="card-header">
<span class="item-title">清除 _ParamsCard 硬编码占位数据</span>
<span class="tag t-high">重要</span>
</div>
<div class="item-desc">public_product_screen.dart _ParamsCard 硬编码了「产地:贵州省仁怀市茅台镇」「生产日期:2024-01-01」「保质期:无限期(适饮)」「储存方式:阴凉干燥…」,非茅台商品显示错误信息。无数据时应不显示该行,或从 custom_fields 读取。line:1089-1092</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
</div>
</div>
</li>
<li class="todo-card"
data-id="12"
data-level="mid"
data-tags="前端,iOS,Android"
data-done="0">
<div class="card-header">
<span class="item-title">移除 _DescriptionSection 茅台 mock 文本</span>
<span class="tag t-high">重要</span>
</div>
<div class="item-desc">无商品描述时 fallback 显示飞天茅台硬编码文案和 mock 关键词,其他商品扫码看到茅台介绍。无描述时应隐藏该区块。public_product_screen.dart:819-825</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
</div>
</div>
</li>
<li class="todo-card"
data-id="3"
data-level="low"
@@ -343,13 +248,146 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
</div>
</div>
</li>
<li class="todo-card"
data-id="18"
data-level="low"
data-tags="前端,iOS,Android"
data-done="0">
<div class="card-header">
<span class="item-title">基础数据管理 UI 增加 4 个字典维护 Tab</span>
<span class="tag t-low">一般 / 优化</span>
</div>
<div class="item-desc">products_screen.dart 基础数据管理页增加产地/保质期/储存方式/描述文档 4 个维护 Tab,复用现有 option 维护组件(增删改查);描述文档 Tab 含 title+content 多行输入</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
</div>
</div>
</li>
</ul>
<div class="done-section">
<div class="section-title done-title">✅ 已完成(2</div>
<div class="section-title done-title">✅ 已完成(8</div>
<button class="done-toggle" id="doneToggle">▾ 展开已完成列表</button>
<ul class="todo-list hidden" id="doneList">
<li class="todo-card done"
data-id="10"
data-level="mid"
data-tags="前端,后端,iOS,Android"
data-done="1">
<div class="card-header">
<span class="item-title">公开商品 API 补充 sale_price 并在页面展示价格</span>
<span class="tag t-high">重要</span>
</div>
<div class="item-desc">product model 有 sale_price 字段但 public.go GetProduct 未返回。需:1) 后端 batchData/productData 加 sale_price2) Flutter _TitleBlock 或单独区块展示建议零售价。client: public_product_screen.dart, backend: handler/public.go</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
<span class="meta-date">✅ 完成 2026-06-08 · <span class="ver-badge">v1.0.23</span></span>
</div>
</div>
</li>
<li class="todo-card done"
data-id="9"
data-level="mid"
data-tags="前端,iOS,Android"
data-done="1">
<div class="card-header">
<span class="item-title">设计并实现添加门店微信功能</span>
<span class="tag t-high">重要</span>
</div>
<div class="item-desc">PublicProductScreen _ShopCard「添加门店微信」按钮 onPressed: () {} 为空。需设计方案:门店维护微信号字段 or 二维码图片,扫码页展示。涉及后端 shop model 扩展 + 前端展示。client/lib/screens/public/public_product_screen.dart:1255</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
<span class="meta-date">✅ 完成 2026-06-08 · <span class="ver-badge">v1.0.23</span></span>
</div>
</div>
</li>
<li class="todo-card done"
data-id="8"
data-level="mid"
data-tags="前端,iOS,Android"
data-done="1">
<div class="card-header">
<span class="item-title">补全「查看本店其他商品」功能</span>
<span class="tag t-high">重要</span>
</div>
<div class="item-desc">PublicProductScreen _ShopCard 底部按钮 onPressed: () {} 为空,需实现跳转至该门店商品列表页。client/lib/screens/public/public_product_screen.dart:1240</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
<span class="meta-date">✅ 完成 2026-06-08 · <span class="ver-badge">v1.0.23</span></span>
</div>
</div>
</li>
<li class="todo-card done"
data-id="17"
data-level="mid"
data-tags="前端,iOS,Android"
data-done="1">
<div class="card-header">
<span class="item-title">入库表单关联 4 个商品属性字典</span>
<span class="tag t-high">重要</span>
</div>
<div class="item-desc">stock_in_form_screen.dart 生产日期字段附近增加产地/保质期/储存方式/描述文档 4 个下拉选择(数据来自字典 List 接口);FindOrCreate/Update 接受并写入 4 个 id;前端新增对应 API client 方法</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
<span class="meta-date">✅ 完成 2026-06-08 · <span class="ver-badge">v1.0.23</span></span>
</div>
</div>
</li>
<li class="todo-card done"
data-id="16"
data-level="mid"
data-tags="后端,前端,iOS,Android"
data-done="1">
<div class="card-header">
<span class="item-title">public.go 返回产地/保质期/储存/介绍,公开页去硬编码</span>
<span class="tag t-high">重要</span>
</div>
<div class="item-desc">public.go GetProduct Preload 4 张新表 + 三级回退(描述文档→旧Description→通用中性兜底)+ 默认值常量(保质期/储存方式);Flutter public_product_screen.dart _ParamsCard/_DescriptionSection 改读真实数据,移除茅台 mock</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
<span class="meta-date">✅ 完成 2026-06-08 · <span class="ver-badge">v1.0.23</span></span>
</div>
</div>
</li>
<li class="todo-card done"
data-id="15"
data-level="mid"
data-tags="后端,数据库"
data-done="1">
<div class="card-header">
<span class="item-title">4 张商品属性字典表 + 后端 CRUD</span>
<span class="tag t-high">重要</span>
</div>
<div class="item-desc">新增 product_origin_options/product_shelf_life_options/product_storage_options/product_description_docs 4 张字典表(TenantBase+shop_id 隔离);Product 增加 4 个可空外键;AutoMigrate 注册;handler/product_attr.go CRUD16 个方法);router.go 追加 4 组路由在 /product-options/ 下</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="数据库">数据库</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
<span class="meta-date">✅ 完成 2026-06-08 · <span class="ver-badge">v1.0.23</span></span>
</div>
</div>
</li>
<li class="todo-card done"
data-id="4"
data-level="low"
+71 -41
View File
@@ -1,9 +1,9 @@
{
"meta": {
"title": "酒库管理系统 — 项目 TODO",
"updated_at": "2026-06-07T18:12:50.300Z"
"updated_at": "2026-06-07T23:28:53.001Z"
},
"seq": 14,
"seq": 18,
"items": [
{
"id": 1,
@@ -99,9 +99,9 @@
"Android"
],
"created_at": "2026-06-07T18:08:24.432Z",
"done": false,
"completed_at": null,
"version": null
"done": true,
"completed_at": "2026-06-07T23:18:23.239Z",
"version": "v1.0.23"
},
{
"id": 9,
@@ -114,9 +114,9 @@
"Android"
],
"created_at": "2026-06-07T18:08:32.748Z",
"done": false,
"completed_at": null,
"version": null
"done": true,
"completed_at": "2026-06-07T23:28:52.932Z",
"version": "v1.0.23"
},
{
"id": 10,
@@ -130,39 +130,9 @@
"Android"
],
"created_at": "2026-06-07T18:12:21.217Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 11,
"title": "清除 _ParamsCard 硬编码占位数据",
"desc": "public_product_screen.dart _ParamsCard 硬编码了「产地:贵州省仁怀市茅台镇」「生产日期:2024-01-01」「保质期:无限期(适饮)」「储存方式:阴凉干燥…」,非茅台商品显示错误信息。无数据时应不显示该行,或从 custom_fields 读取。line:1089-1092",
"level": "mid",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-07T18:12:28.829Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 12,
"title": "移除 _DescriptionSection 茅台 mock 文本",
"desc": "无商品描述时 fallback 显示飞天茅台硬编码文案和 mock 关键词,其他商品扫码看到茅台介绍。无描述时应隐藏该区块。public_product_screen.dart:819-825",
"level": "mid",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-07T18:12:36.087Z",
"done": false,
"completed_at": null,
"version": null
"done": true,
"completed_at": "2026-06-07T23:28:53.001Z",
"version": "v1.0.23"
},
{
"id": 13,
@@ -193,6 +163,66 @@
"done": false,
"completed_at": null,
"version": null
},
{
"id": 15,
"title": "4 张商品属性字典表 + 后端 CRUD",
"desc": "新增 product_origin_options/product_shelf_life_options/product_storage_options/product_description_docs 4 张字典表(TenantBase+shop_id 隔离);Product 增加 4 个可空外键;AutoMigrate 注册;handler/product_attr.go CRUD16 个方法);router.go 追加 4 组路由在 /product-options/ 下",
"level": "mid",
"tags": [
"后端",
"数据库"
],
"created_at": "2026-06-07T22:51:51.881Z",
"done": true,
"completed_at": "2026-06-07T22:56:24.807Z",
"version": "v1.0.23"
},
{
"id": 16,
"title": "public.go 返回产地/保质期/储存/介绍,公开页去硬编码",
"desc": "public.go GetProduct Preload 4 张新表 + 三级回退(描述文档→旧Description→通用中性兜底)+ 默认值常量(保质期/储存方式);Flutter public_product_screen.dart _ParamsCard/_DescriptionSection 改读真实数据,移除茅台 mock",
"level": "mid",
"tags": [
"后端",
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-07T22:51:59.988Z",
"done": true,
"completed_at": "2026-06-07T22:58:32.882Z",
"version": "v1.0.23"
},
{
"id": 17,
"title": "入库表单关联 4 个商品属性字典",
"desc": "stock_in_form_screen.dart 生产日期字段附近增加产地/保质期/储存方式/描述文档 4 个下拉选择(数据来自字典 List 接口);FindOrCreate/Update 接受并写入 4 个 id;前端新增对应 API client 方法",
"level": "mid",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-07T22:52:07.331Z",
"done": true,
"completed_at": "2026-06-07T23:03:13.631Z",
"version": "v1.0.23"
},
{
"id": 18,
"title": "基础数据管理 UI 增加 4 个字典维护 Tab",
"desc": "products_screen.dart 基础数据管理页增加产地/保质期/储存方式/描述文档 4 个维护 Tab,复用现有 option 维护组件(增删改查);描述文档 Tab 含 title+content 多行输入",
"level": "low",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-07T22:52:13.283Z",
"done": false,
"completed_at": null,
"version": null
}
]
}