Compare commits

...

12 Commits

Author SHA1 Message Date
wangjia 626efa9c34 fix(ci): flutter analyze 忽略 warning/info,只在 error 时失败
Deploy / deploy (push) Successful in 1m16s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:20:02 +08:00
wangjia b69c23c88a feat(deploy): 部署完成后发送 Telegram 通知
Deploy / deploy (push) Failing after 48s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:09:09 +08:00
wangjia 53f56ce086 feat(deploy): SemVer 版本管理 + 自动化发版流水线
- deploy.yml: tag 触发(v*.*.*),四阶段调脚本(compile/test/release/deploy)
- manual.yml: workflow_dispatch 支持手动回滚,checkout 指定 tag
- scripts/ci/compile.sh: 构建 Go 二进制 + Flutter Web,打包 dist/
- scripts/ci/test.sh: go test + flutter analyze
- scripts/ci/release.sh: 解析 CHANGELOG.md → 更新 version.yaml → 创建 Forgejo Release
- scripts/ci/deploy.sh: 从 Release 下载产物(自动/手动均可)→ 部署到 EC2
- CHANGELOG.md: Keep a Changelog 格式,初始 v1.0.0 条目
- backend: GetRelease 改读 version.yaml,移除 ENV 变量依赖
- backend/config/version.yaml: 重置为 v1.0.0
- web/download.html: 动态拉取 /api/v1/public/release 展示版本号和更新内容

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:41:08 +08:00
wangjia d48f9f4bd0 fix(client): 商品公开页暂无图片时显示门店名首字,替换硬编码岩美
Deploy / deploy (push) Successful in 1m20s
_EmptyGalleryContent 接收 shopName 参数,无图片时展示
门店名第一个字符的文字头像,不再显示品牌名。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:59:17 +08:00
wangjia b591bc522d fix(web): 禁用深色模式自动适配,修复渲染叠层问题
Deploy / deploy (push) Successful in 2m9s
Flutter Web canvas 在系统深色模式下背景透明,导致底层 HTML
内容透出形成叠层。营销站点未声明 color-scheme,浏览器自动调色
也会破坏白色背景设计。

- client/web/index.html: 添加 color-scheme=light meta 及 body 白色背景
- web/index.html: 添加 color-scheme=light meta
- web/assets/colors_and_type.css: :root 声明 color-scheme: light

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:40:03 +08:00
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
wangjia 831dbc5959 fix(import): 库存列头动态识别 + 格式不匹配诊断信息
Deploy / deploy (push) Failing after 59s
商品名称/编号/系列/规格/单位列索引从固定 0-4 改为从表头动态识别,
支持更多列名变体(品名/名称/货品名称/编码等)。

全部跳过时在 errors 中返回识别到的表头,便于诊断列格式问题。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:45:39 +08:00
wangjia d5dc499c6b fix(import): 修复导入 504 超时 + 添加上传进度显示
Deploy / deploy (push) Successful in 1m15s
nginx:
- 新增 /api/v1/import/ 专属 location,proxy_read_timeout 延长至 300s

后端:
- ImportInventory 预加载商品/仓库/库存(3 次 bulk query 替代 N+1)
- 批量写 inventory_log(CreateInBatches 替代逐行 Create)

前端:
- settings 导入行:显示"上传 XX%"进度条和"导入数据(Ns)"计时
- Dio 超时设置为 send=120s / receive=300s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:12:59 +08:00
wangjia 5725e84971 fix(nginx): 增加上传文件大小限制至 20MB
Deploy / deploy (push) Successful in 1m16s
默认 1MB 导致 Excel 文件上传返回 413。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:03:21 +08:00
27 changed files with 1194 additions and 292 deletions
+21 -81
View File
@@ -2,7 +2,8 @@ name: Deploy
on:
push:
branches: [main]
tags:
- 'v[0-9]*.[0-9]*.[0-9]*'
jobs:
deploy:
@@ -11,97 +12,36 @@ jobs:
GOPROXY: https://goproxy.cn,direct
PUB_HOSTED_URL: https://pub.flutter-io.cn
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
steps:
- uses: actions/checkout@v4
- name: Go tests
working-directory: backend
run: go test ./...
- name: Compile
run: sh scripts/ci/compile.sh "${{ gitea.ref_name }}"
- name: Flutter tests
working-directory: client
run: flutter test
- name: Test
run: sh scripts/ci/test.sh
- name: Build backend (linux/amd64)
working-directory: backend
run: GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o jiu-server .
- name: Build Flutter Web
working-directory: client
run: flutter build web --release --base-href=/app/ --dart-define=BASE_URL=https://jiu.51yanmei.com --dart-define=PUBLIC_URL=https://jiu.51yanmei.com
- name: Package & Create Forgejo Release
- name: Release
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
run: |
tar -czf web.tar.gz -C client/build web
tar -czf configs.tar.gz \
deploy/nginx-jiu.conf \
deploy/jiu.service \
deploy/production.env.template \
deploy/setup-ec2.sh \
deploy/docker-compose.yml \
deploy/docker-compose.jiu.yml
VERSION="v$(date +%Y%m%d.%H%M)"
COMMIT="${{ gitea.sha }}"
COMMIT_MSG=$(git log -1 --pretty="%s" HEAD)
RECENT_LOGS=$(git log -5 --pretty="- %s (%h)" HEAD)
BODY=$(printf '## 构建信息\n- **版本**: %s\n- **Commit**: %s\n- **时间**: %s\n\n## 最近提交\n%s' \
"${VERSION}" "${COMMIT}" "$(date '+%Y-%m-%d %H:%M:%S %Z')" "${RECENT_LOGS}")
GITEA_REPOSITORY: ${{ gitea.repository }}
run: sh scripts/ci/release.sh "${{ gitea.ref_name }}"
RESP=$(curl -s -X POST "${FORGEJO_URL}/api/v1/repos/${{ gitea.repository }}/releases" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${VERSION}\",\"name\":\"Release ${VERSION}\",\"body\":$(echo "$BODY" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))'),\"draft\":false,\"prerelease\":false}")
echo "API response: ${RESP}"
RELEASE_ID=$(echo "${RESP}" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets" \
-H "Authorization: token ${FORGEJO_TOKEN}" -F "attachment=@backend/jiu-server"
curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets" \
-H "Authorization: token ${FORGEJO_TOKEN}" -F "attachment=@web.tar.gz"
curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets" \
-H "Authorization: token ${FORGEJO_TOKEN}" -F "attachment=@configs.tar.gz"
echo "Release ${VERSION} created"
- name: Setup SSH
run: |
mkdir -p ~/.ssh
printf '%s' "${{ secrets.EC2_SSH_KEY }}" > ~/.ssh/ec2_deploy.pem
chmod 600 ~/.ssh/ec2_deploy.pem
ssh-keyscan -H ${{ secrets.EC2_HOST }} >> ~/.ssh/known_hosts
- name: Deploy to EC2
- name: Deploy
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
GITEA_REPOSITORY: ${{ gitea.repository }}
EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }}
EC2_HOST: ${{ secrets.EC2_HOST }}
EC2_USER: ${{ secrets.EC2_USER }}
run: |
scp -O -i ~/.ssh/ec2_deploy.pem backend/jiu-server ${EC2_USER}@${EC2_HOST}:/tmp/jiu-server
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem" \
client/build/web/ ${EC2_USER}@${EC2_HOST}:/tmp/jiu-web-new/
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem" \
web/ ${EC2_USER}@${EC2_HOST}:/tmp/jiu-marketing-new/
scp -O -i ~/.ssh/ec2_deploy.pem deploy/nginx-jiu.conf \
${EC2_USER}@${EC2_HOST}:/tmp/nginx-jiu.conf
ssh -i ~/.ssh/ec2_deploy.pem ${EC2_USER}@${EC2_HOST} << 'ENDSSH'
sudo systemctl stop jiu
cp /tmp/jiu-server /opt/jiu/backend/jiu-server
chmod +x /opt/jiu/backend/jiu-server
sudo systemctl start jiu
for i in $(seq 1 30); do
curl -sf http://localhost:8080/health && break
sleep 2
done
rm -rf /opt/jiu/web-old
mv /opt/jiu/web /opt/jiu/web-old 2>/dev/null || true
mv /tmp/jiu-web-new /opt/jiu/web
mkdir -p /opt/jiu/marketing
rsync -a --delete /tmp/jiu-marketing-new/ /opt/jiu/marketing/
rm -rf /tmp/jiu-marketing-new
sudo cp /tmp/nginx-jiu.conf /etc/nginx/conf.d/jiu.conf
sudo nginx -t && sudo nginx -s reload
ENDSSH
run: sh scripts/ci/deploy.sh "${{ gitea.ref_name }}"
- name: Cleanup SSH key
- name: Notify
if: always()
run: rm -f ~/.ssh/ec2_deploy.pem
env:
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: sh scripts/ci/notify.sh "${{ gitea.ref_name }}" "${{ job.status }}"
+28
View File
@@ -0,0 +1,28 @@
name: Manual Deploy
on:
workflow_dispatch:
inputs:
version:
description: 'Tag to deploy (e.g. v1.0.0)'
required: true
type: string
jobs:
deploy:
runs-on: mac
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.version }}
- name: Deploy
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
GITEA_REPOSITORY: ${{ gitea.repository }}
EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }}
EC2_HOST: ${{ secrets.EC2_HOST }}
EC2_USER: ${{ secrets.EC2_USER }}
run: sh scripts/ci/deploy.sh "${{ inputs.version }}"
+20
View File
@@ -0,0 +1,20 @@
# Changelog
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.0] - 2026-05-24
初始版本发布,完成核心库存管理功能。
### 新功能
- 商品管理:多规格商品档案、图片、公开二维码扫码展示
- 库存管理:入库审核、出库审核、库存盘点、流水记录
- 财务管理:往来账目、对账单
- 用户权限:管理员 / 成员 / 只读三级权限控制
- 多租户隔离:门店数据完全独立
- Flutter Web 管理端(支持 PC 浏览器)
- 商品扫码公开展示页
- Excel 批量导入商品与库存
+4 -4
View File
@@ -1,10 +1,10 @@
version: "1.1.1"
build_number: 2
version: "1.0.0"
build_number: 1
force_update: false
release_notes: "修复了离线模式问题,优化状态栏显示"
release_notes: "初始版本发布,完成核心库存管理功能。"
download_urls:
macos: ""
windows: ""
ios: ""
android: ""
web: ""
web: "https://jiu.51yanmei.com/app"
+151 -78
View File
@@ -3,6 +3,7 @@ package handler
import (
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
@@ -512,66 +513,126 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
}
type importResult struct {
total int
imported int
updated int
skipped int
errors []string
}
var res importResult
// 仓库缓存,只查找不创建
warehouseCache := map[string]*uint64{}
// 预加载仓库(1 次查询)
var warehouses []model.Warehouse
h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).Find(&warehouses)
warehouseByName := make(map[string]uint64, len(warehouses))
for _, wh := range warehouses {
warehouseByName[wh.Name] = wh.ID
}
findWarehouse := func(name string) *uint64 {
if name == "" {
return nil
}
if idPtr, ok := warehouseCache[name]; ok {
return idPtr
if id, ok := warehouseByName[name]; ok {
return &id
}
var wh model.Warehouse
if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&wh).Error != nil {
warehouseCache[name] = nil
return nil
return nil
}
// 预加载商品(1 次查询)
var allProducts []model.Product
h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).Find(&allProducts)
productByCode := make(map[string]*model.Product, len(allProducts))
productByNSS := make(map[string]*model.Product, len(allProducts))
for i := range allProducts {
p := &allProducts[i]
if p.Code != "" {
productByCode[p.Code] = p
}
id := wh.ID
warehouseCache[name] = &id
return &id
productByNSS[p.Name+"|"+p.Series+"|"+p.Spec] = p
}
// 预加载已有导入库存(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)
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
}
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 "库存数量", "数量":
case "商品编号", "商品编码", "编号", "编码", "商品条码":
colProductCode = j
case "商品名称", "品名", "名称", "货品名称":
colProductName = j
case "系列", "品牌系列":
colSeries = j
case "规格", "规格型号":
colSpec = j
case "单位":
colUnit = j
case "库存数量", "数量", "库存":
colQty = j
case "单价":
case "单价", "进价", "采购单价":
colPrice = j
case "生产日期":
case "生产日期", "生产年月":
colProductionDate = j
case "批次", "批次号":
colBatchNo = j
case "所在仓库", "仓库":
case "所在仓库", "仓库", "库位":
colWarehouse = j
case "供应商":
case "供应商", "供应商名称":
colSupplier = j
case "备注":
colRemark = j
}
}
}
log.Printf("[import-inv] total rows=%d, colProductName=%d, colProductCode=%d, colQty=%d",
len(rows), colProductName, colProductCode, colQty)
// 如果没有匹配到任何列头,返回诊断信息
detectedHeader := strings.Join(rows[0], " | ")
var logsToCreate []model.InventoryLog
for i, row := range rows[1:] {
productName := cell(row, 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, 0)
series := cell(row, 2)
spec := cell(row, 3)
unit := cell(row, 4)
productCode := cell(row, colProductCode)
series := cell(row, colSeries)
spec := cell(row, colSpec)
unit := cell(row, colUnit)
qtyStr := cell(row, colQty)
priceStr := cell(row, colPrice)
productionDateStr := cell(row, colProductionDate)
@@ -586,20 +647,32 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
}
price, _ := strconv.ParseFloat(priceStr, 64)
// 只查找商品,不强制创建
var prod model.Product
if h.db.Where("shop_id = ? AND deleted_at IS NULL AND (code = ? OR (name = ? AND series = ? AND spec = ?))",
shopID, productCode, productName, series, spec).First(&prod).Error != nil {
// 若找不到则创建
res.total++ // 有商品名称的行才计入总数
// 从缓存查商品,找不到才创建
var prod *model.Product
if productCode != "" {
prod = productByCode[productCode]
}
if prod == nil {
prod = productByNSS[productName+"|"+series+"|"+spec]
}
if prod == nil {
newProd, createErr := findOrCreateProductFn(h.db, shopID, productCode, productName, series, spec)
if createErr != nil {
res.errors = append(res.errors, fmt.Sprintf("行%d: 商品创建失败: %s", i+2, createErr.Error()))
continue
}
prod = newProd
allProducts = append(allProducts, newProd)
prod = &allProducts[len(allProducts)-1]
if prod.Code != "" {
productByCode[prod.Code] = prod
}
productByNSS[prod.Name+"|"+prod.Series+"|"+prod.Spec] = prod
}
if unit != "" && prod.Unit == "" {
h.db.Model(&prod).Update("unit", unit)
h.db.Model(prod).Update("unit", unit)
prod.Unit = unit
}
// 解析生产日期
@@ -609,7 +682,6 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
productionDate = &d
}
// 查找仓库(只查,不创建)
whIDPtr := findWarehouse(warehouseName)
var unitPricePtr *float64
@@ -617,30 +689,24 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
unitPricePtr = &price
}
productIDCopy := prod.ID
// Upsert:按商品编号 + 仓库查找已有导入记录,存在则更新,不存在则新建
var existing model.Inventory
q := h.db.Where("shop_id = ? AND product_code = ? AND stock_in_item_id IS NULL AND deleted_at IS NULL",
shopID, prod.Code)
whIDVal := uint64(0)
if whIDPtr != nil {
q = q.Where("warehouse_id = ?", *whIDPtr)
} else {
q = q.Where("warehouse_id IS NULL")
whIDVal = *whIDPtr
}
found := q.First(&existing).Error == nil
if found {
existing := lookupInv(productCode, prod.Name, prod.Series, prod.Spec, whIDVal)
if existing != nil {
updates := map[string]interface{}{
"quantity": qty,
"product_name": prod.Name,
"series": prod.Series,
"spec": prod.Spec,
"unit": prod.Unit,
"warehouse_name": warehouseName,
"supplier_name": supplierName,
"remark": remark,
"deleted_at": nil,
"quantity": qty,
"product_name": prod.Name,
"series": prod.Series,
"spec": prod.Spec,
"unit": prod.Unit,
"warehouse_name": warehouseName,
"supplier_name": supplierName,
"remark": remark,
"deleted_at": nil,
}
if unitPricePtr != nil {
updates["unit_price"] = *unitPricePtr
@@ -651,11 +717,18 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
if batchNo != "" {
updates["batch_no"] = batchNo
}
if err := h.db.Model(&existing).Updates(updates).Error; err != nil {
if err := h.db.Model(existing).Updates(updates).Error; err != nil {
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存更新失败: %s", i+2, err.Error()))
continue
}
logsToCreate = append(logsToCreate, model.InventoryLog{
ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID,
Direction: "in", Quantity: qty, QtyBefore: existing.Quantity, QtyAfter: qty,
RefType: "import", RefID: 0,
})
res.updated++
} else {
productIDCopy := prod.ID
inv := model.Inventory{
ShopID: shopID,
WarehouseID: whIDPtr,
@@ -678,38 +751,38 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存写入失败: %s", i+2, err.Error()))
continue
}
}
// 写流水
warehouseID := uint64(0)
if whIDPtr != nil {
warehouseID = *whIDPtr
}
qtyBefore := 0.0
if found {
qtyBefore = existing.Quantity
res.updated++
} else {
// 写入两套缓存,防止同文件后续行重复插入
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,
RefType: "import", RefID: 0,
})
res.imported++
}
log := model.InventoryLog{
ShopID: shopID,
WarehouseID: warehouseID,
ProductID: prod.ID,
Direction: "in",
Quantity: qty,
QtyBefore: qtyBefore,
QtyAfter: qty,
RefType: "import",
RefID: 0,
}
h.db.Create(&log)
}
// 批量写流水
if len(logsToCreate) > 0 {
h.db.CreateInBatches(&logsToCreate, 100)
}
// total=0 说明列格式不匹配,没有解析到任何有效行
if res.total == 0 {
res.errors = append(res.errors,
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,
})
}
+11 -18
View File
@@ -2,8 +2,6 @@ package handler
import (
"net/http"
"os"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -80,23 +78,18 @@ func (h *PublicHandler) GetProduct(c *gin.Context) {
// GetRelease GET /api/v1/public/release (no auth)
func (h *PublicHandler) GetRelease(c *gin.Context) {
version := os.Getenv("APP_VERSION")
if version == "" {
version = "1.0.0"
cfg, err := loadVersionConfig()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"version": "1.0.0",
"release_notes": "",
"download_urls": gin.H{"web": "https://jiu.51yanmei.com/app"},
})
return
}
buildDate := os.Getenv("BUILD_DATE")
if buildDate == "" {
buildDate = time.Now().Format("2006-01-02")
}
c.JSON(http.StatusOK, gin.H{
"version": version,
"build_date": buildDate,
"download_urls": gin.H{
"macos": os.Getenv("DOWNLOAD_URL_MACOS"),
"windows": os.Getenv("DOWNLOAD_URL_WINDOWS"),
"web": "https://jiu.51yanmei.com/app",
},
"changelog": []gin.H{},
"version": cfg.Version,
"release_notes": cfg.ReleaseNotes,
"download_urls": cfg.DownloadURLs,
})
}
+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,
);
}
}
}
@@ -99,42 +99,97 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
final file = result.files.first;
final bytes = file.bytes;
if (bytes == null) return;
if (!context.mounted) return;
final token = ref.read(authStateProvider).user?.accessToken ?? '';
final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl));
final dio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
sendTimeout: const Duration(seconds: 120),
receiveTimeout: const Duration(seconds: 300),
));
dio.options.headers['Authorization'] = 'Bearer $token';
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('导入中,请稍候...'), duration: Duration(seconds: 60)),
final stateNotifier = ValueNotifier<_ImportState>(
const _ImportState(stage: _ImportStage.uploading, uploadPercent: 0),
);
BuildContext? dialogCtx;
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) {
dialogCtx = ctx;
return PopScope(
canPop: false,
child: _ImportProgressDialog(stateNotifier: stateNotifier),
);
},
);
Timer? processingTimer;
bool uploadDone = false;
int elapsed = 0;
void closeDialog() {
processingTimer?.cancel();
if (dialogCtx != null && dialogCtx!.mounted) {
Navigator.of(dialogCtx!).pop();
}
}
try {
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(bytes, filename: file.name),
});
final resp = await dio.post('/import/inventory', data: formData);
final data = resp.data as Map<String, dynamic>;
final imported = data['imported'] ?? 0;
final skipped = data['skipped'] ?? 0;
final errors = (data['errors'] as List?)?.cast<String>() ?? [];
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('导入完成:$imported 条成功,$skipped 条跳过${errors.isNotEmpty ? '${errors.length} 条失败' : ''}'),
backgroundColor: errors.isEmpty ? Colors.green : AppTheme.accent,
duration: const Duration(seconds: 4),
));
ref.read(inventoryListProvider.notifier).reload();
final resp = await dio.post(
'/import/inventory',
data: formData,
onSendProgress: (sent, total) {
if (total <= 0) return;
if (sent >= total && !uploadDone) {
uploadDone = true;
elapsed = 0;
stateNotifier.value = const _ImportState(
stage: _ImportStage.processing, uploadPercent: 100, processingSeconds: 0,
);
processingTimer = Timer.periodic(const Duration(seconds: 1), (_) {
elapsed++;
stateNotifier.value = _ImportState(
stage: _ImportStage.processing, uploadPercent: 100, processingSeconds: elapsed,
);
});
} else if (!uploadDone) {
final pct = (sent / total * 100).round().clamp(0, 99);
stateNotifier.value = _ImportState(stage: _ImportStage.uploading, uploadPercent: pct);
}
},
);
processingTimer?.cancel();
final data = resp.data as Map<String, dynamic>;
stateNotifier.value = _ImportState(
stage: _ImportStage.done,
uploadPercent: 100,
total: data['total'] ?? 0,
imported: data['imported'] ?? 0,
updated: data['updated'] ?? 0,
errors: (data['errors'] as List?)?.cast<String>() ?? [],
);
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) {
final msg = (e.response?.data is Map ? e.response!.data['error'] : null) ?? e.message ?? '未知错误';
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('导入失败:$msg'),
backgroundColor: AppTheme.danger,
));
processingTimer?.cancel();
final msg = (e.response?.data is Map ? e.response!.data['error'] : null)
?? e.message ?? '未知错误';
stateNotifier.value = _ImportState(
stage: _ImportStage.error, uploadPercent: 0, errorMsg: msg,
);
}
}
@@ -865,3 +920,163 @@ class _DirectionBadge extends StatelessWidget {
);
}
}
// ── 导入进度对话框 ──────────────────────────────────────────
enum _ImportStage { uploading, processing, done, error }
class _ImportState {
final _ImportStage stage;
final int uploadPercent;
final int processingSeconds;
final int total;
final int imported;
final int updated;
final List<String> errors;
final String? errorMsg;
const _ImportState({
required this.stage,
required this.uploadPercent,
this.processingSeconds = 0,
this.total = 0,
this.imported = 0,
this.updated = 0,
this.errors = const [],
this.errorMsg,
});
}
class _ImportProgressDialog extends StatelessWidget {
final ValueNotifier<_ImportState> stateNotifier;
const _ImportProgressDialog({required this.stateNotifier});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('导入库存'),
content: ValueListenableBuilder<_ImportState>(
valueListenable: stateNotifier,
builder: (_, state, __) => SizedBox(
width: 320,
child: _buildContent(context, state),
),
),
actions: [
ValueListenableBuilder<_ImportState>(
valueListenable: stateNotifier,
builder: (ctx, state, __) {
if (state.stage == _ImportStage.done || state.stage == _ImportStage.error) {
return TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('关闭'),
);
}
return const SizedBox.shrink();
},
),
],
);
}
Widget _buildContent(BuildContext context, _ImportState state) {
switch (state.stage) {
case _ImportStage.uploading:
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('上传文件...', style: TextStyle(fontSize: 14)),
const SizedBox(height: 12),
LinearProgressIndicator(
value: state.uploadPercent / 100,
backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primary),
),
const SizedBox(height: 6),
Text('${state.uploadPercent}%',
style: TextStyle(fontSize: 12, color: Colors.grey[600])),
],
);
case _ImportStage.processing:
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
SizedBox(
width: 16, height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primary),
),
),
const SizedBox(width: 10),
Text(
'导入数据中...${state.processingSeconds > 0 ? "${state.processingSeconds}秒)" : ""}',
style: const TextStyle(fontSize: 14),
),
]),
const SizedBox(height: 8),
LinearProgressIndicator(
backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primary),
),
],
);
case _ImportStage.done:
final allFailed = state.total == 0 && state.errors.isNotEmpty;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(
allFailed ? Icons.warning_amber_rounded : Icons.check_circle,
color: allFailed ? AppTheme.danger : AppTheme.success,
size: 20,
),
const SizedBox(width: 8),
Text(
allFailed ? '导入失败' : '导入完成',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
]),
const SizedBox(height: 10),
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}',
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)),
)),
],
],
);
case _ImportStage.error:
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.error_outline, color: AppTheme.danger, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'导入失败:${state.errorMsg}',
style: TextStyle(fontSize: 13, color: AppTheme.danger),
),
),
],
);
}
}
}
@@ -130,7 +130,7 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
slivers: [
// 1. Gallery
SliverToBoxAdapter(
child: _HeroGallery(imageUrls: imageUrls),
child: _HeroGallery(imageUrls: imageUrls, shopName: shop?['name'] as String? ?? ''),
),
// 2. Verified ribbon
SliverToBoxAdapter(
@@ -227,7 +227,8 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
class _HeroGallery extends StatefulWidget {
final List<String> imageUrls;
const _HeroGallery({required this.imageUrls});
final String shopName;
const _HeroGallery({required this.imageUrls, this.shopName = ''});
@override
State<_HeroGallery> createState() => _HeroGalleryState();
@@ -280,7 +281,7 @@ class _HeroGalleryState extends State<_HeroGallery> {
),
// Image or placeholder
if (urls.isEmpty)
_EmptyGalleryContent()
_EmptyGalleryContent(shopName: widget.shopName)
else
PageView.builder(
controller: _ctrl,
@@ -291,7 +292,7 @@ class _HeroGalleryState extends State<_HeroGallery> {
child: Image.network(
urls[i],
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => _EmptyGalleryContent(),
errorBuilder: (_, __, ___) => _EmptyGalleryContent(shopName: widget.shopName),
),
),
),
@@ -409,8 +410,12 @@ class _HeroGalleryState extends State<_HeroGallery> {
}
class _EmptyGalleryContent extends StatelessWidget {
final String shopName;
const _EmptyGalleryContent({this.shopName = ''});
@override
Widget build(BuildContext context) {
final initial = shopName.isNotEmpty ? shopName.characters.first : '';
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -421,9 +426,9 @@ class _EmptyGalleryContent extends StatelessWidget {
color: const Color(0xFF0F3057),
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: Text('岩美',
style: TextStyle(color: Colors.white70, fontSize: 18, fontWeight: FontWeight.w700)),
child: Center(
child: Text(initial,
style: const TextStyle(color: Colors.white70, fontSize: 28, fontWeight: FontWeight.w700)),
),
),
const SizedBox(height: 12),
+205 -20
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import '../../core/utils/dialog_util.dart';
import 'package:dio/dio.dart';
import 'package:file_picker/file_picker.dart';
@@ -126,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 : ''),
@@ -1524,10 +1535,23 @@ class _ImportSlot {
int total = 0;
int imported = 0;
int skipped = 0;
int updated = 0;
// 进度
int uploadPercent = 0;
bool isProcessing = false;
int processingSeconds = 0;
_ImportSlot(this.title, this.endpoint, this.hint);
bool get hasResult => success != null;
void resetProgress() {
uploadPercent = 0;
isProcessing = false;
processingSeconds = 0;
success = null;
error = null;
}
}
class _BatchImportWidget extends ConsumerStatefulWidget {
@@ -1657,14 +1681,15 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
setState(() {
_loading = true;
for (final s in _slots) {
if (s.file != null) {
s.success = null;
s.error = null;
}
if (s.file != null) s.resetProgress();
}
});
final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl));
final dio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
sendTimeout: const Duration(seconds: 120),
receiveTimeout: const Duration(seconds: 300),
));
dio.options.headers['Authorization'] = 'Bearer $token';
for (final slot in _slots) {
@@ -1674,29 +1699,57 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
if (mounted) setState(() { slot.success = false; slot.error = '无法读取文件'; });
continue;
}
Timer? processingTimer;
bool uploadDone = false;
try {
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(bytes, filename: slot.file!.name),
});
final resp = await dio.post(slot.endpoint, data: formData);
final resp = await dio.post(
slot.endpoint,
data: formData,
onSendProgress: (sent, total) {
if (total <= 0 || !mounted) return;
if (sent >= total && !uploadDone) {
uploadDone = true;
setState(() { slot.isProcessing = true; slot.processingSeconds = 0; });
processingTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() => slot.processingSeconds++);
});
} else if (!uploadDone) {
final pct = (sent / total * 100).round().clamp(0, 99);
if (mounted) setState(() => slot.uploadPercent = pct);
}
},
);
processingTimer?.cancel();
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) {
processingTimer?.cancel();
final raw = e.response?.data;
final msg = (raw is Map ? raw['error'] : null) ?? e.message ?? '未知错误';
if (mounted) setState(() {
slot.success = false;
slot.error = msg.toString();
slot.isProcessing = false;
});
} catch (e) {
processingTimer?.cancel();
if (mounted) setState(() {
slot.success = false;
slot.error = e.toString();
slot.isProcessing = false;
});
}
}
@@ -2017,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,
@@ -2047,10 +2101,42 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
);
}
} else if (_loading && slot.file != null) {
statusWidget = const SizedBox(
width: 14, height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
);
if (slot.isProcessing) {
statusWidget = Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 12, height: 12,
child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primary),
),
const SizedBox(width: 6),
Text(
'导入数据${slot.processingSeconds > 0 ? "${slot.processingSeconds}秒)" : ""}',
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary),
),
],
);
} else {
statusWidget = Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 80,
child: LinearProgressIndicator(
value: slot.uploadPercent / 100,
backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primary),
minHeight: 6,
),
),
const SizedBox(width: 6),
Text(
'上传 ${slot.uploadPercent}%',
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
],
);
}
}
return Padding(
@@ -2104,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;
@@ -2149,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() {
@@ -2168,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 {
@@ -2206,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;
+7
View File
@@ -19,6 +19,13 @@
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="岩美酒库管理系统 — 酒店饮品库存与采购管理平台">
<meta name="color-scheme" content="light">
<style>
html, body {
background-color: #ffffff;
color-scheme: light;
}
</style>
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
+24
View File
@@ -7,6 +7,8 @@ server {
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
client_max_body_size 20m;
# 商品图片静态文件
location ^~ /images/ {
alias /opt/jiu/images/;
@@ -14,6 +16,14 @@ server {
add_header Cache-Control "public, immutable";
}
# 文件导入接口(超时更长)
location ~ ^/api/v1/import/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 300s;
}
# API 反向代理
location ~ ^/(api|health|version) {
proxy_pass http://127.0.0.1:8080;
@@ -29,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;
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# compile.sh <tag> — build backend + Flutter web, package into dist/
set -euo pipefail
export PATH="/opt/homebrew/bin:$PATH"
export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
export PUB_HOSTED_URL="${PUB_HOSTED_URL:-https://pub.flutter-io.cn}"
export FLUTTER_STORAGE_BASE_URL="${FLUTTER_STORAGE_BASE_URL:-https://storage.flutter-io.cn}"
TAG="$1"
VER="${TAG#v}"
echo "==> compile: version=$VER"
# Sync Flutter pubspec version
sed -i '' "s/^version:.*/version: ${VER}+1/" client/pubspec.yaml
# Build Go backend (linux/amd64 for EC2)
cd backend
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o jiu-server .
cd ..
# Build Flutter Web
cd client
flutter build web --release \
--base-href=/app/ \
"--dart-define=BASE_URL=https://jiu.51yanmei.com" \
"--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \
"--dart-define=APP_VERSION=${TAG}"
cd ..
# Package artifacts
mkdir -p dist
mv backend/jiu-server dist/jiu-server
tar -czf dist/web.tar.gz -C client/build web
echo "==> compile: done — dist/ contents:"
ls -lh dist/
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# deploy.sh <tag> — deploy release to EC2
# Works in two modes:
# 1. Automated (after compile.sh): uses dist/ built in the same job
# 2. Manual/rollback: downloads assets from Forgejo Release by tag
set -euo pipefail
TAG="$1"
echo "==> deploy: tag=${TAG}"
# Download from Forgejo if dist/ was not built in this job
if [ ! -f dist/jiu-server ] || [ ! -f dist/web.tar.gz ] || [ ! -f dist/configs.tar.gz ]; then
echo "==> deploy: dist/ incomplete — downloading assets for ${TAG} from Forgejo"
mkdir -p dist
curl -sf \
-H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/tags/${TAG}" \
-o /tmp/release-info.json
python3 - <<'PYEOF'
import json, urllib.request, os, sys
with open('/tmp/release-info.json') as f:
release = json.load(f)
token = os.environ['FORGEJO_TOKEN']
forgejo_url = os.environ['FORGEJO_URL']
repo = os.environ['GITEA_REPOSITORY']
release_id = release['id']
for asset in release.get('assets', []):
url = f"{forgejo_url}/api/v1/repos/{repo}/releases/{release_id}/assets/{asset['id']}"
req = urllib.request.Request(url, headers={'Authorization': f'token {token}'})
with urllib.request.urlopen(req) as resp:
with open(f"dist/{asset['name']}", 'wb') as out:
out.write(resp.read())
print(f"Downloaded: {asset['name']}")
PYEOF
fi
echo "==> deploy: dist/ contents:"
ls -lh dist/
# Extract configs (nginx, version.yaml, etc.)
rm -rf /tmp/jiu-configs
mkdir -p /tmp/jiu-configs
tar -xzf dist/configs.tar.gz -C /tmp/jiu-configs
# Extract Flutter web build
rm -rf /tmp/jiu-web-new
mkdir -p /tmp/jiu-web-new
tar -xzf dist/web.tar.gz -C /tmp/jiu-web-new --strip-components=1
# Setup SSH
mkdir -p ~/.ssh
printf '%s' "${EC2_SSH_KEY}" > ~/.ssh/ec2_deploy.pem
chmod 600 ~/.ssh/ec2_deploy.pem
ssh-keyscan -H "${EC2_HOST}" >> ~/.ssh/known_hosts
SSH="ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no"
SCP="scp -O -i ~/.ssh/ec2_deploy.pem"
echo "==> deploy: uploading files to EC2"
# Upload backend binary
${SCP} dist/jiu-server "${EC2_USER}@${EC2_HOST}:/tmp/jiu-server"
# Upload version.yaml
${SCP} /tmp/jiu-configs/backend/config/version.yaml "${EC2_USER}@${EC2_HOST}:/tmp/version.yaml"
# Upload nginx config
${SCP} /tmp/jiu-configs/deploy/nginx-jiu.conf "${EC2_USER}@${EC2_HOST}:/tmp/nginx-jiu.conf"
# Upload Flutter web
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \
/tmp/jiu-web-new/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-web-new/"
# Upload marketing site (from repo checkout)
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \
web/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-marketing-new/"
echo "==> deploy: running remote deploy"
${SSH} "${EC2_USER}@${EC2_HOST}" << 'ENDSSH'
set -e
# Replace backend binary
sudo systemctl stop jiu
cp /tmp/jiu-server /opt/jiu/backend/jiu-server
chmod +x /opt/jiu/backend/jiu-server
# Update version config
mkdir -p /opt/jiu/backend/config
cp /tmp/version.yaml /opt/jiu/backend/config/version.yaml
# Start and health check
sudo systemctl start jiu
echo "Waiting for health check..."
for i in $(seq 1 30); do
if curl -sf http://localhost:8080/health > /dev/null 2>&1; then
echo "Health check passed"
break
fi
sleep 2
done
curl -sf http://localhost:8080/health > /dev/null || { echo "Health check failed!"; exit 1; }
# Swap Flutter web app (atomic)
rm -rf /opt/jiu/web-old
mv /opt/jiu/web /opt/jiu/web-old 2>/dev/null || true
mv /tmp/jiu-web-new /opt/jiu/web
# Update marketing site
mkdir -p /opt/jiu/marketing
rsync -a --delete /tmp/jiu-marketing-new/ /opt/jiu/marketing/
rm -rf /tmp/jiu-marketing-new
# Update nginx config and reload
sudo cp /tmp/nginx-jiu.conf /etc/nginx/conf.d/jiu.conf
sudo nginx -t && sudo nginx -s reload
echo "Deploy complete!"
ENDSSH
# Cleanup SSH key
rm -f ~/.ssh/ec2_deploy.pem
rm -rf /tmp/jiu-configs /tmp/jiu-web-new
echo "==> deploy: done"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# notify.sh <tag> <status> — send Telegram deploy notification
set -euo pipefail
TAG="$1"
STATUS="$2"
if [ "$STATUS" = "success" ]; then
ICON="✅"
LABEL="部署成功"
else
ICON="❌"
LABEL="部署失败"
fi
MSG="${ICON} 岩美 ${TAG} ${LABEL}
https://jiu.51yanmei.com"
curl -sf -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}" \
--data-urlencode "text=${MSG}" \
> /dev/null
echo "==> notify: sent to Telegram (${STATUS})"
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# release.sh <tag> — update version.yaml, package configs, create Forgejo Release
set -euo pipefail
TAG="$1"
VER="${TAG#v}"
echo "==> release: tag=${TAG} version=${VER}"
# Extract release notes from CHANGELOG.md (first summary line of the latest section)
RELEASE_NOTES=$(python3 - <<'PYEOF'
import re, sys
try:
with open('CHANGELOG.md') as f:
content = f.read()
parts = re.split(r'\n## ', '\n' + content)
if len(parts) > 1:
section = parts[1]
lines = section.strip().split('\n')
notes = []
for line in lines[1:]:
stripped = line.strip()
if stripped.startswith('#'):
break
if stripped:
notes.append(stripped)
print('\n'.join(notes) if notes else lines[0])
else:
print('')
except Exception as e:
print('', file=sys.stderr)
print('')
PYEOF
)
echo "==> release: release_notes=${RELEASE_NOTES}"
# Update backend/config/version.yaml
python3 - "${VER}" "${RELEASE_NOTES}" <<'PYEOF'
import sys
ver = sys.argv[1]
notes = sys.argv[2]
lines = []
with open('backend/config/version.yaml') as f:
for line in f:
if line.startswith('version:'):
lines.append('version: "' + ver + '"\n')
elif line.startswith('build_number:'):
try:
build = int(line.split(':', 1)[1].strip()) + 1
except Exception:
build = 1
lines.append('build_number: ' + str(build) + '\n')
elif line.startswith('release_notes:'):
lines.append('release_notes: "' + notes.replace('\\', '\\\\').replace('"', '\\"') + '"\n')
else:
lines.append(line)
with open('backend/config/version.yaml', 'w') as f:
f.writelines(lines)
print('version.yaml updated to', ver)
PYEOF
# Package configs.tar.gz (includes updated version.yaml)
tar -czf dist/configs.tar.gz \
deploy/nginx-jiu.conf \
deploy/jiu.service \
deploy/production.env.template \
deploy/setup-ec2.sh \
deploy/docker-compose.yml \
deploy/docker-compose.jiu.yml \
backend/config/version.yaml
echo "==> release: creating Forgejo Release ${TAG}"
# Build release body from CHANGELOG excerpt
BODY=$(python3 - "${TAG}" "${RELEASE_NOTES}" <<'PYEOF'
import sys, json
tag = sys.argv[1]
notes = sys.argv[2]
body = '## ' + tag + '\n\n' + notes
print(json.dumps(body))
PYEOF
)
RESP=$(curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"Release ${TAG}\",\"body\":${BODY},\"draft\":false,\"prerelease\":false}")
echo "==> release: API response: ${RESP}"
RELEASE_ID=$(python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" <<< "${RESP}")
echo "==> release: release_id=${RELEASE_ID}"
# Upload assets
curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-F "attachment=@dist/jiu-server"
echo "==> release: uploaded jiu-server"
curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-F "attachment=@dist/web.tar.gz"
echo "==> release: uploaded web.tar.gz"
curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-F "attachment=@dist/configs.tar.gz"
echo "==> release: uploaded configs.tar.gz"
echo "==> release: done — Release ${TAG} created"
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# test.sh — run backend and frontend checks
set -euo pipefail
export PATH="/opt/homebrew/bin:$PATH"
export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
export PUB_HOSTED_URL="${PUB_HOSTED_URL:-https://pub.flutter-io.cn}"
export FLUTTER_STORAGE_BASE_URL="${FLUTTER_STORAGE_BASE_URL:-https://storage.flutter-io.cn}"
echo "==> test: go test"
cd backend
go test ./...
cd ..
echo "==> test: flutter analyze"
cd client
flutter analyze --no-fatal-infos --no-fatal-warnings
cd ..
echo "==> test: done"
+2
View File
@@ -5,6 +5,8 @@
========================================================================= */
:root {
color-scheme: light;
/* ---------- Brand: Primary (Slate Blue) ---------- */
/* Trustworthy enterprise blue with a slight slate cast. */
--brand-50: #EEF4FB;
+19 -4
View File
@@ -954,15 +954,15 @@ footer {
</p>
<div class="changelog">
<!-- v1.6.2 -->
<!-- 当前版本(由 JS 动态更新) -->
<div class="changelog-entry current">
<div class="changelog-meta">
<div class="changelog-version">v1.6.2</div>
<div class="changelog-date">2026-05-15</div>
<div class="changelog-version" id="version-badge">...</div>
<div class="changelog-date" id="release-date">-</div>
<span class="changelog-tag">当前版本</span>
</div>
<div class="changelog-body">
<h3>多仓库联动盘点与权限增强</h3>
<h3 id="release-notes">加载中...</h3>
<div class="changelog-cat">
<div class="changelog-cat-label"><span class="dot feature"></span>新功能</div>
<ul>
@@ -1193,8 +1193,23 @@ function renderNav() {
if (window.lucide) lucide.createIcons();
}
function loadReleaseInfo() {
fetch('https://jiu.51yanmei.com/api/v1/public/release')
.then(function(r) { return r.json(); })
.then(function(data) {
var badge = document.getElementById('version-badge');
var notes = document.getElementById('release-notes');
var date = document.getElementById('release-date');
if (badge && data.version) badge.textContent = 'v' + data.version;
if (notes && data.release_notes) notes.textContent = data.release_notes;
if (date) date.textContent = new Date().toISOString().slice(0, 10);
})
.catch(function() {});
}
document.addEventListener('DOMContentLoaded', function() {
renderNav();
loadReleaseInfo();
if (window.lucide) lucide.createIcons();
});
</script>
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<title>岩美酒库管理系统 — 为酒行与酒店设计的库存管理平台</title>
<link rel="stylesheet" href="assets/colors_and_type.css" />
<link rel="preconnect" href="https://fonts.googleapis.com">