Compare commits
12 Commits
v20260524.1451
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 626efa9c34 | |||
| b69c23c88a | |||
| 53f56ce086 | |||
| d48f9f4bd0 | |||
| b591bc522d | |||
| b15e422953 | |||
| 634162e0d9 | |||
| fbb4f90ebd | |||
| a05f9bd4ec | |||
| 831dbc5959 | |||
| d5dc499c6b | |||
| 5725e84971 |
+21
-81
@@ -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 }}"
|
||||
|
||||
@@ -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 }}"
|
||||
@@ -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 批量导入商品与库存
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 编号规则
|
||||
|
||||
@@ -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 '扩展字段',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: '门店名称'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.html,JS 从 URL 读取 id)
|
||||
location /scan/ {
|
||||
root /opt/jiu/marketing;
|
||||
|
||||
Executable
+38
@@ -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/
|
||||
Executable
+131
@@ -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"
|
||||
Executable
+24
@@ -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})"
|
||||
Executable
+111
@@ -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"
|
||||
Executable
+20
@@ -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"
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user