Compare commits

..

4 Commits

Author SHA1 Message Date
wangjia b15e422953 fix(nginx): /product/ 路径转发给 Flutter Web,修复扫码跳转营销页问题
Deploy / deploy (push) Successful in 1m16s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:34:28 +08:00
wangjia 634162e0d9 fix(nginx): index.html 禁用缓存,确保发版后立即生效
Deploy / deploy (push) Successful in 1m17s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:28:48 +08:00
wangjia fbb4f90ebd fix(test): shops 测试表补加 logo_url 列
Deploy / deploy (push) Successful in 1m15s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:15:39 +08:00
wangjia a05f9bd4ec feat(shop): 门店 logo 上传 + 侧边栏品牌替换
Deploy / deploy (push) Failing after 8s
- shops 表新增 logo_url 字段,AutoMigrate 自动建列
- 后端新增 POST /api/v1/shop/logo 接口(管理员),图片裁剪为 256×256 JPEG 存储
- UpdateInfo 支持传入 logo_url
- 侧边栏「岩美」替换为门店真实名称 + 自定义 logo
- 无 logo 时显示店名首字文字头像(深蓝底白字)
- 设置页「酒行信息」展示 logo 预览,编辑弹窗新增「更换 Logo」上传按钮
- 修复库存导入计数逻辑:total/imported/updated/errors 四项分别统计
- 库存导入去重改为双路索引(编号 + 名称|系列|规格),避免 key 不一致误判新增
- settings 页导入结果统一显示「重复 X 条」,兼容 skipped 和 updated 两个字段

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:03:43 +08:00
12 changed files with 335 additions and 93 deletions
+42 -13
View File
@@ -3,6 +3,7 @@ package handler
import (
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
@@ -512,9 +513,9 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
}
type importResult struct {
total int
imported int
updated int
skipped int
errors []string
}
var res importResult
@@ -550,22 +551,37 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
}
// 预加载已有导入库存(1 次查询)
// 同时建两套索引:编号索引 + 名称|系列|规格索引,供后续双路查找
var allInvs []model.Inventory
h.db.Where("shop_id = ? AND stock_in_item_id IS NULL AND deleted_at IS NULL", shopID).Find(&allInvs)
invByKey := make(map[string]*model.Inventory, len(allInvs))
invByCode := make(map[string]*model.Inventory, len(allInvs)) // key: productCode|warehouseID
invByNSS := make(map[string]*model.Inventory, len(allInvs)) // key: name|series|spec|warehouseID
for i := range allInvs {
inv := &allInvs[i]
whID := uint64(0)
if inv.WarehouseID != nil {
whID = *inv.WarehouseID
}
invByKey[fmt.Sprintf("%s|%d", inv.ProductCode, whID)] = inv
if inv.ProductCode != "" {
invByCode[fmt.Sprintf("%s|%d", inv.ProductCode, whID)] = inv
}
nssKey := fmt.Sprintf("%s|%s|%s|%d", inv.ProductName, inv.Series, inv.Spec, whID)
invByNSS[nssKey] = inv
}
lookupInv := func(productCode, name, series, spec string, whID uint64) *model.Inventory {
if productCode != "" {
if inv, ok := invByCode[fmt.Sprintf("%s|%d", productCode, whID)]; ok {
return inv
}
}
return invByNSS[fmt.Sprintf("%s|%s|%s|%d", name, series, spec, whID)]
}
// Dynamic column detection from header row
colProductCode, colProductName, colSeries, colSpec, colUnit := 0, 1, 2, 3, 4
colQty, colPrice, colProductionDate, colBatchNo, colWarehouse, colSupplier, colRemark := 5, 6, 8, 9, 11, 13, 15
if len(rows) > 0 {
log.Printf("[import-inv] header row (%d cols): %v", len(rows[0]), rows[0])
for j, h := range rows[0] {
switch strings.TrimSpace(h) {
case "商品编号", "商品编码", "编号", "编码", "商品条码":
@@ -595,6 +611,8 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
}
}
}
log.Printf("[import-inv] total rows=%d, colProductName=%d, colProductCode=%d, colQty=%d",
len(rows), colProductName, colProductCode, colQty)
// 如果没有匹配到任何列头,返回诊断信息
detectedHeader := strings.Join(rows[0], " | ")
@@ -603,9 +621,12 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
for i, row := range rows[1:] {
productName := cell(row, colProductName)
if i < 5 {
log.Printf("[import-inv] row[%d] len=%d | productName=%q productCode=%q qty=%q",
i+2, len(row), productName, cell(row, colProductCode), cell(row, colQty))
}
if productName == "" {
res.skipped++
continue
continue // 空行(文件末尾填充行),不计入 total
}
productCode := cell(row, colProductCode)
@@ -626,6 +647,8 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
}
price, _ := strconv.ParseFloat(priceStr, 64)
res.total++ // 有商品名称的行才计入总数
// 从缓存查商品,找不到才创建
var prod *model.Product
if productCode != "" {
@@ -666,13 +689,12 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
unitPricePtr = &price
}
// 从缓存查库存记录
whIDVal := uint64(0)
if whIDPtr != nil {
whIDVal = *whIDPtr
}
invKey := fmt.Sprintf("%s|%d", prod.Code, whIDVal)
existing := invByKey[invKey]
existing := lookupInv(productCode, prod.Name, prod.Series, prod.Spec, whIDVal)
if existing != nil {
updates := map[string]interface{}{
@@ -729,7 +751,11 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存写入失败: %s", i+2, err.Error()))
continue
}
invByKey[invKey] = &inv
// 写入两套缓存,防止同文件后续行重复插入
if inv.ProductCode != "" {
invByCode[fmt.Sprintf("%s|%d", inv.ProductCode, whIDVal)] = &inv
}
invByNSS[fmt.Sprintf("%s|%s|%s|%d", inv.ProductName, inv.Series, inv.Spec, whIDVal)] = &inv
logsToCreate = append(logsToCreate, model.InventoryLog{
ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID,
Direction: "in", Quantity: qty, QtyBefore: 0, QtyAfter: qty,
@@ -744,16 +770,19 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
h.db.CreateInBatches(&logsToCreate, 100)
}
// 全部跳过说明列格式不匹配,返回诊断信息
if res.imported == 0 && res.updated == 0 && res.skipped > 0 && len(res.errors) == 0 {
// total=0 说明列格式不匹配,没有解析到任何有效行
if res.total == 0 {
res.errors = append(res.errors,
fmt.Sprintf("所有行商品名称列为空,可能列格式不匹配。识别到的表头:%s", detectedHeader))
fmt.Sprintf("未解析到任何有效行,可能列格式不匹配。识别到的表头:%s", detectedHeader))
}
log.Printf("[import-inv] RESULT: total=%d imported=%d updated=%d errors=%d",
res.total, res.imported, res.updated, len(res.errors))
c.JSON(http.StatusOK, gin.H{
"total": res.total,
"imported": res.imported,
"updated": res.updated,
"skipped": res.skipped,
"errors": res.errors,
})
}
+57
View File
@@ -1,11 +1,19 @@
package handler
import (
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"net/http"
"os"
"path/filepath"
"github.com/disintegration/imaging"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
)
@@ -38,6 +46,7 @@ func (h *ShopHandler) UpdateInfo(c *gin.Context) {
Address string `json:"address"`
Phone string `json:"phone"`
ManagerName string `json:"manager_name"`
LogoURL string `json:"logo_url"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -50,6 +59,9 @@ func (h *ShopHandler) UpdateInfo(c *gin.Context) {
"phone": req.Phone,
"manager_name": req.ManagerName,
}
if req.LogoURL != "" {
updates["logo_url"] = req.LogoURL
}
if err := h.db.Model(&model.Shop{}).Where("id = ?", shopID).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -59,3 +71,48 @@ func (h *ShopHandler) UpdateInfo(c *gin.Context) {
h.db.First(&shop, shopID)
c.JSON(http.StatusOK, shop)
}
// UploadLogo POST /api/v1/shop/logo (admin only)
func (h *ShopHandler) UploadLogo(c *gin.Context) {
shopID := middleware.GetShopID(c)
if err := c.Request.ParseMultipartForm(2 << 20); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "文件超过 2MB 限制"})
return
}
file, _, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请上传文件(field: file"})
return
}
defer file.Close()
img, _, err := image.Decode(file)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 JPEG/PNG 图片"})
return
}
// 裁剪为正方形后缩放到 256×256
resized := imaging.Fill(img, 256, 256, imaging.Center, imaging.Lanczos)
subdir := filepath.Join(config.C.Storage.UploadDir, "shops", fmt.Sprintf("%d", shopID))
if err := os.MkdirAll(subdir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "存储目录创建失败"})
return
}
fullPath := filepath.Join(subdir, "logo.jpg")
if err := imaging.Save(resized, fullPath, imaging.JPEGQuality(90)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "图片保存失败"})
return
}
logoURL := fmt.Sprintf("/images/shops/%d/logo.jpg", shopID)
if err := h.db.Model(&model.Shop{}).Where("id = ?", shopID).Update("logo_url", logoURL).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"logo_url": logoURL})
}
+1
View File
@@ -8,6 +8,7 @@ type Shop struct {
Phone string `gorm:"size:30" json:"phone"`
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"`
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
@@ -165,6 +165,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
{
shop.GET("/info", shopH.GetInfo)
shop.PUT("/info", middleware.AdminOnly(), shopH.UpdateInfo)
shop.POST("/logo", middleware.AdminOnly(), shopH.UploadLogo)
}
// 编号规则
+1
View File
@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `shops` (
`address` VARCHAR(255) DEFAULT NULL,
`phone` VARCHAR(30) DEFAULT NULL,
`manager_name` VARCHAR(50) DEFAULT NULL COMMENT '负责人',
`logo_url` VARCHAR(500) DEFAULT '' COMMENT '门店 logo URL',
`business_license` VARCHAR(500) DEFAULT NULL COMMENT '营业执照照片URL',
`shop_photos` JSON DEFAULT NULL COMMENT '门店照片URL数组',
`custom_fields` JSON DEFAULT NULL COMMENT '扩展字段',
+1
View File
@@ -56,6 +56,7 @@ func SetupTestDB() *gorm.DB {
address TEXT,
phone TEXT,
manager_name TEXT,
logo_url TEXT DEFAULT '',
business_license TEXT,
business_hours TEXT,
shop_photos TEXT,
+3
View File
@@ -5,6 +5,7 @@ class ShopInfo {
final String address;
final String phone;
final String managerName;
final String logoUrl;
const ShopInfo({
required this.id,
@@ -13,6 +14,7 @@ class ShopInfo {
required this.address,
required this.phone,
required this.managerName,
this.logoUrl = '',
});
factory ShopInfo.fromJson(Map<String, dynamic> json) => ShopInfo(
@@ -22,5 +24,6 @@ class ShopInfo {
address: json['address'] as String? ?? '',
phone: json['phone'] as String? ?? '',
managerName: json['manager_name'] as String? ?? '',
logoUrl: json['logo_url'] as String? ?? '',
);
}
@@ -1,4 +1,5 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../core/api/api_client.dart';
import '../core/exceptions.dart';
import '../models/shop.dart';
@@ -30,4 +31,19 @@ class ShopRepository {
);
}
}
Future<String> uploadLogo(Uint8List bytes, String filename) async {
try {
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(bytes, filename: filename),
});
final resp = await _client.post('/shop/logo', data: formData);
return (resp.data as Map<String, dynamic>)['logo_url'] as String? ?? '';
} on DioException catch (e) {
throw AppException(
e.response?.data?['error'] as String? ?? 'Logo 上传失败',
statusCode: e.response?.statusCode,
);
}
}
}
@@ -171,14 +171,17 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
stateNotifier.value = _ImportState(
stage: _ImportStage.done,
uploadPercent: 100,
total: data['total'] ?? 0,
imported: data['imported'] ?? 0,
updated: data['updated'] ?? 0,
skipped: data['skipped'] ?? 0,
errors: (data['errors'] as List?)?.cast<String>() ?? [],
);
await Future.delayed(const Duration(seconds: 2));
closeDialog();
final hasErrors = (data['errors'] as List?)?.isNotEmpty ?? false;
if (!hasErrors) {
await Future.delayed(const Duration(seconds: 2));
closeDialog();
}
if (context.mounted) ref.read(inventoryListProvider.notifier).reload();
} on DioException catch (e) {
processingTimer?.cancel();
@@ -926,9 +929,9 @@ class _ImportState {
final _ImportStage stage;
final int uploadPercent;
final int processingSeconds;
final int total;
final int imported;
final int updated;
final int skipped;
final List<String> errors;
final String? errorMsg;
@@ -936,9 +939,9 @@ class _ImportState {
required this.stage,
required this.uploadPercent,
this.processingSeconds = 0,
this.total = 0,
this.imported = 0,
this.updated = 0,
this.skipped = 0,
this.errors = const [],
this.errorMsg,
});
@@ -1024,25 +1027,39 @@ class _ImportProgressDialog extends StatelessWidget {
);
case _ImportStage.done:
final allFailed = state.total == 0 && state.errors.isNotEmpty;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(Icons.check_circle, color: AppTheme.success, size: 20),
Icon(
allFailed ? Icons.warning_amber_rounded : Icons.check_circle,
color: allFailed ? AppTheme.danger : AppTheme.success,
size: 20,
),
const SizedBox(width: 8),
const Text('导入成功', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
Text(
allFailed ? '导入失败' : '导入完成',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
]),
const SizedBox(height: 10),
Text('新增:${state.imported} ', style: const TextStyle(fontSize: 13)),
if (state.updated > 0)
Text('更新:${state.updated} ', style: const TextStyle(fontSize: 13)),
if (state.skipped > 0)
Text('跳过:${state.skipped}',
style: TextStyle(fontSize: 13, color: Colors.grey[600])),
Text('${state.total} ', style: const TextStyle(fontSize: 13)),
Text('成功插入:${state.imported}', style: const TextStyle(fontSize: 13)),
Text('重复更新:${state.updated} ',
style: TextStyle(fontSize: 13, color: Colors.grey[600])),
if (state.errors.isNotEmpty)
Text('失败:${state.errors.length} ',
Text('失败:${state.errors.length} ',
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
if (state.errors.isNotEmpty) ...[
const SizedBox(height: 6),
...state.errors.map((e) => Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(e,
style: TextStyle(fontSize: 12, color: AppTheme.danger)),
)),
],
],
);
+122 -10
View File
@@ -127,14 +127,24 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ShopInfoRow(
label: '门店编号',
value: shop.code.isNotEmpty ? shop.code : ''),
const Divider(height: 16),
_ShopInfoRow(
label: '门店名称',
value: shop.name.isNotEmpty ? shop.name : ''),
const Divider(height: 16),
// Logo 预览行
Row(
children: [
_ShopLogoPreview(logoUrl: shop.logoUrl, shopName: shop.name, size: 64),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(shop.name.isNotEmpty ? shop.name : '',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text(shop.code.isNotEmpty ? shop.code : '',
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
],
),
],
),
const Divider(height: 24),
_ShopInfoRow(
label: '门店地址',
value: shop.address.isNotEmpty ? shop.address : ''),
@@ -1525,6 +1535,7 @@ class _ImportSlot {
int total = 0;
int imported = 0;
int skipped = 0;
int updated = 0;
// 进度
int uploadPercent = 0;
bool isProcessing = false;
@@ -1718,9 +1729,10 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
final data = (resp.data is Map) ? resp.data as Map<String, dynamic> : <String, dynamic>{};
if (mounted) setState(() {
slot.success = true;
slot.total = (data['total'] ?? data['imported'] ?? 0) as int;
slot.imported = (data['imported'] ?? 0) as int;
slot.skipped = (data['skipped'] ?? 0) as int;
slot.updated = (data['updated'] ?? 0) as int;
slot.total = (data['total'] ?? slot.imported + slot.skipped + slot.updated) as int;
slot.isProcessing = false;
});
} on DioException catch (e) {
@@ -2058,10 +2070,11 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
Widget? statusWidget;
if (slot.hasResult) {
if (slot.success == true) {
final duplicate = slot.skipped + slot.updated;
final parts = <String>[
'${slot.total}',
'新增 ${slot.imported}',
if (slot.skipped > 0) '重复跳过 ${slot.skipped}',
if (duplicate > 0) '重复 $duplicate',
];
statusWidget = Row(
mainAxisSize: MainAxisSize.min,
@@ -2177,6 +2190,54 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
}
}
// ── 门店 Logo 预览 ────────────────────────────────────────
class _ShopLogoPreview extends StatelessWidget {
final String logoUrl;
final String shopName;
final double size;
const _ShopLogoPreview({required this.logoUrl, required this.shopName, this.size = 64});
@override
Widget build(BuildContext context) {
final radius = BorderRadius.circular(size * 0.19);
if (logoUrl.isNotEmpty) {
final fullUrl = logoUrl.startsWith('http') ? logoUrl : '${AppConfig.apiBaseUrl.replaceAll('/api/v1', '')}$logoUrl';
return ClipRRect(
borderRadius: radius,
child: Image.network(
fullUrl,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _initial(radius),
),
);
}
return _initial(radius);
}
Widget _initial(BorderRadius radius) {
final initial = shopName.isNotEmpty ? shopName.characters.first : '';
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: const Color(0xFF0F3057),
borderRadius: radius,
),
alignment: Alignment.center,
child: Text(
initial,
style: TextStyle(
color: Colors.white,
fontSize: size * 0.5,
fontWeight: FontWeight.w600,
),
),
);
}
}
// ── 酒行信息行 ────────────────────────────────────────────
class _ShopInfoRow extends StatelessWidget {
final String label;
@@ -2222,6 +2283,7 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> {
late final TextEditingController _phoneCtrl;
late final TextEditingController _managerCtrl;
bool _saving = false;
bool _uploadingLogo = false;
@override
void initState() {
@@ -2241,6 +2303,37 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> {
super.dispose();
}
Future<void> _uploadLogo() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png'],
withData: true,
);
if (result == null || result.files.isEmpty) return;
final file = result.files.first;
if (file.bytes == null) return;
setState(() => _uploadingLogo = true);
try {
await ref.read(shopRepositoryProvider).uploadLogo(file.bytes!, file.name);
ref.invalidate(shopInfoProvider);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Logo 已更新'),
backgroundColor: AppTheme.success,
));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('上传失败:$e'),
backgroundColor: AppTheme.danger,
));
}
} finally {
if (mounted) setState(() => _uploadingLogo = false);
}
}
Future<void> _save() async {
setState(() => _saving = true);
try {
@@ -2279,6 +2372,25 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Logo 上传行
Row(
children: [
_ShopLogoPreview(
logoUrl: ref.watch(shopInfoProvider).valueOrNull?.logoUrl ?? widget.shop.logoUrl,
shopName: _nameCtrl.text,
size: 56,
),
const SizedBox(width: 12),
OutlinedButton.icon(
onPressed: _uploadingLogo ? null : _uploadLogo,
icon: _uploadingLogo
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.upload_outlined, size: 16),
label: const Text('更换 Logo'),
),
],
),
const SizedBox(height: 16),
TextField(
controller: _nameCtrl,
decoration: const InputDecoration(labelText: '门店名称'),
+46 -56
View File
@@ -5,8 +5,10 @@ import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'dart:async';
import '../../core/auth/auth_state.dart';
import '../../core/config/app_config.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/connectivity_provider.dart';
import '../../providers/shop_provider.dart';
import '../../providers/update_provider.dart';
class AppShell extends ConsumerStatefulWidget {
@@ -575,27 +577,31 @@ void _showShopPanel(BuildContext context, AuthUser u, {String version = 'v1.0.0'
);
}
class _ShopButton extends StatelessWidget {
class _ShopButton extends ConsumerWidget {
final AuthUser? user;
final String version;
const _ShopButton({this.user, this.version = 'v1.0.0'});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final shopAsync = ref.watch(shopInfoProvider);
final shopName = shopAsync.valueOrNull?.name ?? user?.shopNo ?? '';
final logoUrl = shopAsync.valueOrNull?.logoUrl ?? '';
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () {
if (user != null) _showShopPanel(context, user!, version: version);
},
child: const Row(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_YanmeiMark(size: 28),
SizedBox(width: 10),
_ShopLogo(logoUrl: logoUrl, shopName: shopName, size: 28),
const SizedBox(width: 10),
Text(
'岩美',
style: TextStyle(
shopName.isEmpty ? '' : shopName,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
@@ -608,70 +614,54 @@ class _ShopButton extends StatelessWidget {
}
}
/// Brand mark widget — approximates the 岩美 logo SVG without flutter_svg.
/// Dark blue rounded rect, white mountain/wave strokes, bordeaux dot.
class _YanmeiMark extends StatelessWidget {
/// 门店 Logo:有图片显示网络图片,无图片显示店名首字文字头像。
class _ShopLogo extends StatelessWidget {
final String logoUrl;
final String shopName;
final double size;
const _YanmeiMark({this.size = 32});
const _ShopLogo({required this.logoUrl, required this.shopName, this.size = 32});
@override
Widget build(BuildContext context) {
final radius = BorderRadius.circular(size * 0.19);
if (logoUrl.isNotEmpty) {
final fullUrl = logoUrl.startsWith('http') ? logoUrl : '${AppConfig.apiBaseUrl.replaceAll('/api/v1', '')}$logoUrl';
return ClipRRect(
borderRadius: radius,
child: Image.network(
fullUrl,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _initial(radius),
),
);
}
return _initial(radius);
}
Widget _initial(BorderRadius radius) {
final initial = shopName.isNotEmpty ? shopName.characters.first : '';
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: const Color(0xFF0F3057),
borderRadius: BorderRadius.circular(size * 0.19),
borderRadius: radius,
),
child: CustomPaint(
painter: _YanmeiMarkPainter(),
alignment: Alignment.center,
child: Text(
initial,
style: TextStyle(
color: Colors.white,
fontSize: size * 0.5,
fontWeight: FontWeight.w600,
),
),
);
}
}
class _YanmeiMarkPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final w = size.width;
final h = size.height;
final paint = Paint()
..color = Colors.white
..strokeWidth = w * 0.055
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
// Mountain/wave path: M14 38 L22 22 L32 32 L42 22 L50 38 (on 64px grid)
final path = Path();
path.moveTo(w * 0.219, h * 0.594);
path.lineTo(w * 0.344, h * 0.344);
path.lineTo(w * 0.500, h * 0.500);
path.lineTo(w * 0.656, h * 0.344);
path.lineTo(w * 0.781, h * 0.594);
canvas.drawPath(path, paint);
// Horizontal baseline: M12 46 L52 46 (on 64px grid)
canvas.drawLine(
Offset(w * 0.1875, h * 0.719),
Offset(w * 0.8125, h * 0.719),
paint,
);
// Bordeaux dot: circle cx=32 cy=52 r=2.4 (on 64px grid)
canvas.drawCircle(
Offset(w * 0.500, h * 0.859),
w * 0.042,
Paint()
..color = const Color(0xFFC97B86)
..style = PaintingStyle.fill,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class _InfoRow extends StatelessWidget {
final IconData icon;
final String label;
+14
View File
@@ -39,6 +39,20 @@ server {
try_files $uri $uri/ /app/index.html;
}
# index.html 不缓存,确保每次发版后浏览器加载最新版
location = /app/index.html {
alias /opt/jiu/web/index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires 0;
}
# 公开商品详情页(扫码跳转)→ 交给 Flutter Web 路由处理
location ~ ^/product/ {
root /opt/jiu/web;
try_files $uri /app/index.html;
}
# 扫码页(/scan/<id> → scan.htmlJS 从 URL 读取 id
location /scan/ {
root /opt/jiu/marketing;