Compare commits

..

16 Commits

Author SHA1 Message Date
wangjia 21700fa64c chore: release v1.0.54
Deploy / build-linux-web (push) Successful in 52s
Deploy / build-windows (push) Successful in 1m58s
Deploy / build-macos (push) Successful in 1m27s
Deploy / build-android (push) Successful in 1m32s
Deploy / build-ios (push) Successful in 2m51s
Deploy / release-deploy (push) Successful in 1m55s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 06:57:43 +08:00
wangjia 5cfd1795ec fix(backend): 库存导入用 Excel「入库日期」+ 同 key 增量更新/全同跳过
- 读取 Excel「入库日期」列(默认第12列,兼容入库日期/入库时间/入库日),
  显式写入库存 CreatedAt,不再被 GORM 落成导入当天;新增
  parseTimeWithFallback 兼容 yyyy-MM-dd / 含时分秒 / Excel 序列号
- 同 key(商品编码|仓库,回退 名+系列+规格|仓库):全字段一致则跳过,
  仅差异字段增量更新;库存流水仅在 quantity 实际变化时追加
- 响应新增 skipped 计数;多租户 shop_id 隔离不变

go build / vet / test 全过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:56:31 +08:00
wangjia f15692bbd5 refactor(client): 只读判断统一走 WriteGuard 控件
将各屏内联的 ref.watch(isReadonlyProvider) 只读判断统一改为
WriteGuard(child:) 或 WriteGuard.isReadonly(ref),全项目只读逻辑
单一入口(仅 write_guard.dart 内部保留 provider 读取)。行为不变。

121 测试通过,analyze 无 error。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:56:31 +08:00
wangjia 196901b6d3 feat(client): 只读角色隐藏所有写按钮(WriteGuard 统一控件 + 顶栏只读标识)
- 新增 isReadonlyProvider(role == 'readonly')+ WriteGuard 控件,统一隐藏写控件
- 入库/出库/库存/商品/基础数据/往来单位/财务/设置 各屏:只读时隐藏
  新增/编辑/删除/审核/提交/驳回/结清/盘点/导入/激活/用户管理 等写操作
- 顶栏显示「只读」标识,让只读用户明确当前权限
- 后端 middleware.ReadOnly() 仍兜底 403,UI 隐藏 + 后端拦截双保险

修正:此前误判为后端漏洞/部署 bug,实为前端写按钮未对只读隐藏。
后端经验证正确拦截只读写操作(线上 stock-in/product-options 均 403)。

121 测试通过,analyze 无 error。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:42:40 +08:00
wangjia 53465ed704 fix(client): Info.plist 补 NSLocationWhenInUseUsageDescription,消除 ITMS-90683
某依赖引用定位 API,Apple 要求声明用途字符串(即便 App 不主动用定位)。
补上后正式提交 App Store / Beta 审核不再被该警告卡住。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:46:32 +08:00
wangjia 5371d40f05 chore: 彻底停用项目 todo(删除 todo/ 目录 + CLAUDE.md 规则改为停用)
按用户要求停用 todo skill。todo/ 历史保留在 git 记录中可恢复。
CLAUDE.md 项目规则覆盖全局 todo 规则:本项目不再记 todo。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:43:12 +08:00
wangjia a63ee263ca chore: 清空项目 todo(按用户要求,历史保留在 git 记录中)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:38:30 +08:00
wangjia e8bdb19195 feat(client): 抽屉+设置页加退出登录入口;酒行信息显示微信号
- 左侧抽屉底部、系统设置页底部各加「退出登录」按钮(窄屏顶栏菜单
  不便时也能退出;登出后路由 redirect 自动跳 /login)
- 酒行信息 tab 补显示「微信号」行(模型/编辑弹窗早已支持 wechat_id,
  仅只读展示遗漏)

121 测试通过。todo #54 #55。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:26:17 +08:00
wangjia cd672e500f fix(client): iOS 顶栏避开状态栏,修复菜单按钮被遮挡退不出登录
顶栏 Container 原从 y=0 起绘,iOS 状态栏盖住左上角菜单按钮,窄屏无法
打开抽屉菜单/退出登录。顶栏高度加上 MediaQuery.padding.top,内容下移
一个状态栏高度,蓝色仍铺满到屏幕顶。桌面/Web 状态栏高度为 0 不受影响。

todo #53。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:20:07 +08:00
wangjia 018180de8c feat(client): 网络请求重试 + 离线提示可感知重试按钮
修复①请求级重试:新增 RetryInterceptor,网络层失败重试 3 次间隔递增
1s→2s→4s;GET 全重试,写操作仅在连接未建立时重试以防重复提交。
connectTimeout 5s→8s。

修复②连通性:启动首检改退避重试 4s→8s→12s,根治跨境冷启动假离线;
新增检测中状态 + retry();登录页/全局离线提示加 NetworkRetryButton,
重试中转圈、结果 SnackBar 反馈。

121 个测试通过,analyze 无 error。todo #52。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:42:23 +08:00
wangjia 3f3aa41121 feat(client): iOS Info.plist 声明加密豁免,免每次上传答出口合规
加 ITSAppUsesNonExemptEncryption=NO,App 仅用标准 HTTPS 属豁免加密。
以后上传 TestFlight/App Store 不再弹「App 加密文稿」手动问答。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:38:47 +08:00
wangjia 73dbd65ef5 fix(client): iOS Release 配置改手动签名,修复 CI archive 自动签名失败
archive 阶段原走 Automatic 签名,会去申请 Development profile 并失败
(Failed Registering Bundle Identifier / No profiles found)。改为 Manual
并指定 Team BYL4KQHMTN + Apple Distribution 证书 + Jiu App Store profile,
与 compile-ios.sh 的 app-store ExportOptions 一致。仅改 Release,
Debug 保持 Automatic,不影响本地开发。本机已用真实证书验证签名 IPA 构建成功。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:29:40 +08:00
wangjia 8749bbae72 ci: 新增 test-ios workflow 手动验证 iOS 签名上传链路
只跑 iOS 构建+上传 TestFlight,workflow_dispatch 触发,默认版本 v0.0.1
(build 号最低,不与正式发版冲突),用于验证 7 个签名 secret 配置正确。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:20:12 +08:00
wangjia 146444ccc3 fix(client): Windows 热敏打印 GBK 编码改用 WideCharToMultiByte FFI
Deploy / build-linux-web (push) Successful in 47s
Deploy / build-macos (push) Successful in 1m29s
Deploy / build-windows (push) Successful in 2m32s
Deploy / build-android (push) Successful in 1m0s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m52s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 22:09:57 +08:00
wangjia 2ca65e8077 chore: release v1.0.52
Deploy / build-linux-web (push) Successful in 52s
Deploy / build-windows (push) Successful in 1m52s
Deploy / build-macos (push) Successful in 1m24s
Deploy / build-android (push) Successful in 1m56s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m53s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 21:10:54 +08:00
wangjia bf36f7aa3f fix(client): 标签字体细化:阈值 128,店名细体
Deploy / build-linux-web (push) Successful in 46s
Deploy / build-windows (push) Successful in 1m43s
Deploy / build-macos (push) Successful in 1m24s
Deploy / build-android (push) Successful in 59s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m53s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 19:04:32 +08:00
32 changed files with 856 additions and 4094 deletions
+36
View File
@@ -0,0 +1,36 @@
name: Test iOS Build
# 手动触发,只跑 iOS 构建 + 上传 TestFlight,用于验证签名链路(证书/Profile/API Key
# 是否正常,无需打 tag 发版。默认版本 v0.0.1(build 号最低=1,不与正式发版冲突)。
on:
workflow_dispatch:
inputs:
version:
description: '测试版本号(build 号由此推导,默认 v0.0.1 不与正式版冲突)'
required: true
type: string
default: 'v0.0.1'
jobs:
build-ios:
runs-on: mac
env:
PUB_HOSTED_URL: https://pub.flutter-io.cn
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
steps:
- uses: actions/checkout@v4
- name: Provision (idempotent — auto-runs if host not initialized)
run: sh scripts/ci/provision-mac.sh
- name: Compile & upload to TestFlight (Flutter iOS)
env:
IOS_DIST_CERT_P12_BASE64: ${{ secrets.IOS_DIST_CERT_P12_BASE64 }}
IOS_DIST_CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
IOS_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
IOS_TEAM_ID: ${{ secrets.IOS_TEAM_ID }}
APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
APPSTORE_API_KEY_P8_BASE64: ${{ secrets.APPSTORE_API_KEY_P8_BASE64 }}
run: sh scripts/ci/compile-ios.sh "${{ inputs.version }}"
+3
View File
@@ -59,3 +59,6 @@ backend/issue
# Claude 私有文件
.claude/commands/
.claude/worktrees/
# iOS 证书私钥(绝不入库)
.ios-certs/
+30
View File
@@ -5,6 +5,36 @@ 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.54] - 2026-06-17
### 新功能
- 只读账号自动隐藏所有写操作按钮(新增/编辑/删除/审核/提交/结清/导入等),顶栏显示「只读」标识,权限一目了然
- 左侧抽屉和系统设置页新增「退出登录」入口,窄屏也能方便退出
- 酒行信息新增「微信号」显示
### 改进
- 网络请求失败时自动重试(间隔递增),离线时提供带状态反馈的「重试」按钮
- iOS 顶栏避开状态栏,菜单按钮不再被时间/信号遮挡
### 修复
- 库存导入改用 Excel 中的「入库日期」作为入库时间(不再统一记为导入当天);相同数据重复导入时自动跳过,仅更新有变化的字段,避免重复与误覆盖
- 修复 iOS 上顶栏被状态栏遮挡导致无法打开菜单/退出登录的问题
## [1.0.53] - 2026-06-15
### 修复
- 修复 Windows 上点击打印无反应的问题(GBK 编码改为直接调用系统 API,不再依赖第三方插件)
## [1.0.52] - 2026-06-15
### 改进
- 热敏标签打印改用打印机内置中文点阵字体(TSS24.BF2/TSS16.BF2),文字清晰度大幅提升,彻底解决 203 DPI 下字体模糊问题
## [1.0.51] - 2026-06-15
### 改进
- 标签打印字体细化:阈值调回 128,店名改细体,商品名保持粗体
## [1.0.50] - 2026-06-15
### 改进
+3 -21
View File
@@ -310,28 +310,10 @@ CI/CDForgejo`.gitea/workflows/deploy.yml`)收到 tag 后自动:编译
### 项目 TODO 管理
**唯一待办系统**:任务执行过程中如发现需要做的事项,**必须**用 `/todo add <描述>` 记入项目 todo 系统,**禁止**使用 TaskCreate/TodoWrite 等其它 todo 工具,保持单一真相源
**本项目已停用 todo。** 即便全局 `~/.claude/CLAUDE.md` 要求用 `/todo` 记录待办,**本项目一律不记 todo、不调用 `/todo`、不创建 `todo/` 数据**(此项目规则覆盖全局规则)。需要追踪的事项直接在对话里说明即可
- **真相源**`todo/todo.json`(结构化 JSON,含 id/标题/重要度/平台标签/创建时间/完成版本)
- **HTML**`todo/todo.html` 为生成物,每次写操作自动重渲染
- **重要度**`high`(阻断/紧急)/ `mid`(重要)/ `low`(一般/优化)三级
- **改动等级**`tier 1`(接口/schema/跨 2+ 模块)/ `tier 2`5+ 文件)/ `tier 3`(小改/bugfix);**一级改动须先进 plan 模式经用户批准,完成后同步更新设计文档**,详见 `/todo`
- **状态**`open`(待开始)→ `doing`(开发中)→ `done`(待验收)→ `accepted`(已验收)
- **完成即标记**:开发完成后 `/todo status <id> done`,验收通过后用户运行 `/todo done <id>` 记入版本
常用命令:
```
/todo — 查看 summary(按状态分组)
/todo add 修复... — 新增待办(Claude 自动推断级别/标签)
/todo status <id> doing — 标记开发中(Claude 开始处理时调用)
/todo status <id> done — 标记待验收(Claude 完成开发时调用)
/todo done <id> — 用户验收通过,自动记入版本号
/todo reject <id> <原因> — 用户拒绝验收,退回 open 并升为最高优先级
/todo reopen <id> — 重新开启(清空验收/拒绝记录)
/todo rm <id> — 删除
```
底层脚本:`node todo/todo.mjs <子命令> [参数]`
- **改动等级仍把控**:接口/schema/跨 2+ 模块的「大改」先进 plan 模式经用户批准,完成后同步更新设计文档;小 bugfix 直接做。
- **禁止**使用 TaskCreate/TodoWrite 等内置 todo 工具。
---
+124 -23
View File
@@ -496,6 +496,7 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
total int
imported int
updated int
skipped int
errors []string
}
var res importResult
@@ -560,6 +561,7 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
// 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
colStockInDate := 12
if len(rows) > 0 {
log.Printf("[import-inv] header row (%d cols): %v", len(rows[0]), rows[0])
for j, h := range rows[0] {
@@ -580,8 +582,10 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
colPrice = j
case "生产日期", "生产年月":
colProductionDate = j
case "批次", "批次号":
case "批次", "批次号", "批次/编号/物流码":
colBatchNo = j
case "入库日期", "入库时间", "入库日":
colStockInDate = j
case "所在仓库", "仓库", "库位":
colWarehouse = j
case "供应商", "供应商名称":
@@ -620,6 +624,7 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
warehouseName := cell(row, colWarehouse)
supplierName := cell(row, colSupplier)
remark := cell(row, colRemark)
stockInDateStr := cell(row, colStockInDate)
qty, _ := strconv.ParseFloat(qtyStr, 64)
if qty <= 0 {
@@ -662,6 +667,10 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
productionDate = &d
}
// 解析入库日期:用 Excel 里的值作为库存记录的入库时间(CreatedAt)
// 解析失败或为空时回退到当前时间。
stockInTime, _ := parseTimeWithFallback(stockInDateStr, time.Now())
whIDPtr := findWarehouse(warehouseName)
var unitPricePtr *float64
@@ -677,35 +686,93 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
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,
// 去重/增量更新:逐字段对比 Excel 行与库内现有记录,
// 仅把「不一致」的字段放进 updates;全部一致则跳过不写库。
updates := map[string]interface{}{}
if existing.Quantity != qty {
updates["quantity"] = qty
}
if unitPricePtr != nil {
updates["unit_price"] = *unitPricePtr
if existing.ProductName != prod.Name {
updates["product_name"] = prod.Name
}
if productionDate != nil {
updates["production_date"] = productionDate
if existing.Series != prod.Series {
updates["series"] = prod.Series
}
if batchNo != "" {
if existing.Spec != prod.Spec {
updates["spec"] = prod.Spec
}
if existing.Unit != prod.Unit {
updates["unit"] = prod.Unit
}
if existing.WarehouseName != warehouseName {
updates["warehouse_name"] = warehouseName
}
if existing.SupplierName != supplierName {
updates["supplier_name"] = supplierName
}
if existing.Remark != remark {
updates["remark"] = remark
}
if existing.BatchNo != batchNo {
updates["batch_no"] = batchNo
}
// 单价:仅当 Excel 提供了非零单价且与现有不同才更新
if unitPricePtr != nil {
if existing.UnitPrice == nil || *existing.UnitPrice != *unitPricePtr {
updates["unit_price"] = *unitPricePtr
}
}
// 生产日期:仅当 Excel 提供了值且与现有不同才更新
if productionDate != nil {
if existing.ProductionDate == nil ||
!existing.ProductionDate.Time.Equal(productionDate.Time) {
updates["production_date"] = productionDate
}
}
// 入库时间(created_at):与现有不同才更新
if !existing.CreatedAt.Equal(stockInTime) {
updates["created_at"] = stockInTime
}
if len(updates) == 0 {
// 所有字段都一致,跳过(不写库、不写流水)
res.skipped++
continue
}
qtyBefore := existing.Quantity
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,
})
// 仅在库存数量变化时才记一笔流水(用更新前的数量作为 QtyBefore)
if _, ok := updates["quantity"]; ok {
logsToCreate = append(logsToCreate, model.InventoryLog{
ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID,
Direction: "in", Quantity: qty, QtyBefore: qtyBefore, QtyAfter: qty,
RefType: "import", RefID: 0,
})
}
// 同步缓存,避免同文件后续行重复比对到旧值
existing.Quantity = qty
existing.ProductName = prod.Name
existing.Series = prod.Series
existing.Spec = prod.Spec
existing.Unit = prod.Unit
existing.WarehouseName = warehouseName
existing.SupplierName = supplierName
existing.Remark = remark
existing.BatchNo = batchNo
if _, ok := updates["unit_price"]; ok {
existing.UnitPrice = unitPricePtr
}
if _, ok := updates["production_date"]; ok {
existing.ProductionDate = productionDate
}
if _, ok := updates["created_at"]; ok {
existing.CreatedAt = stockInTime
}
res.updated++
} else {
productIDCopy := prod.ID
@@ -726,6 +793,7 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
BatchNo: batchNo,
SupplierName: supplierName,
Remark: remark,
CreatedAt: stockInTime, // 用 Excel「入库日期」作为入库时间
}
if err := h.db.Create(&inv).Error; err != nil {
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存写入失败: %s", i+2, err.Error()))
@@ -756,13 +824,14 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
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))
log.Printf("[import-inv] RESULT: total=%d imported=%d updated=%d skipped=%d errors=%d",
res.total, res.imported, res.updated, res.skipped, len(res.errors))
c.JSON(http.StatusOK, gin.H{
"total": res.total,
"imported": res.imported,
"updated": res.updated,
"skipped": res.skipped,
"errors": res.errors,
})
}
@@ -922,6 +991,38 @@ func parseDate(s string) model.Date {
return model.Date{Time: t}
}
// parseTimeWithFallback 解析「入库日期」等列的时间值。
// 兼容:yyyy-MM-dd、yyyy-MM-dd HH:mm:ss、yyyy/MM/dd、Excel 序列号。
// 解析失败或为空时返回 fallback(通常为 time.Now()),ok=false。
func parseTimeWithFallback(s string, fallback time.Time) (t time.Time, ok bool) {
s = strings.TrimSpace(s)
if s == "" {
return fallback, false
}
layouts := []string{
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
"2006-01-02 15:04",
"2006-01-02",
"2006/01/02 15:04:05",
"2006/01/02",
}
for _, layout := range layouts {
if parsed, err := time.ParseInLocation(layout, s, time.Local); err == nil {
return parsed, true
}
}
// Excel 序列号(自 1899-12-30 起的天数,可带小数表示时间)
if serial, err := strconv.ParseFloat(s, 64); err == nil && serial > 0 && serial < 100000 {
base := time.Date(1899, 12, 30, 0, 0, 0, 0, time.Local)
days := int(serial)
frac := serial - float64(days)
parsed := base.AddDate(0, 0, days).Add(time.Duration(frac * 24 * float64(time.Hour)))
return parsed, true
}
return fallback, false
}
func parsePartnerType(raw string) string {
hasCust := strings.Contains(raw, "客户")
hasSupp := strings.Contains(raw, "供应商")
@@ -697,6 +697,10 @@
);
PRODUCT_BUNDLE_IDENTIFIER = com.yanmei.jiu;
PRODUCT_NAME = "$(TARGET_NAME)";
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = BYL4KQHMTN;
PROVISIONING_PROFILE_SPECIFIER = "Jiu App Store";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
+4
View File
@@ -2,12 +2,16 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>用于拍摄商品照片</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>用于从相册选择商品照片</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>本应用不主动获取您的位置信息(部分系统组件依赖此声明)</string>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
+16 -6
View File
@@ -5,13 +5,20 @@ import '../auth/auth_state.dart';
import '../config/app_config.dart';
import '../errors/error_reporter.dart';
import '../../providers/connectivity_provider.dart';
import 'retry_interceptor.dart';
/// Public Dio instance for unauthenticated calls (login / refresh)
final _publicDio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 15),
));
final _publicDio = _buildPublicDio();
Dio _buildPublicDio() {
final dio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 8),
receiveTimeout: const Duration(seconds: 15),
));
dio.interceptors.add(RetryInterceptor(dio));
return dio;
}
final apiClientProvider = Provider<ApiClient>((ref) {
// 只监听登录/登出,不监听 token 内容变化。
@@ -52,13 +59,16 @@ class ApiClient {
}) {
_dio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 5),
connectTimeout: const Duration(seconds: 8),
receiveTimeout: const Duration(seconds: 15),
headers: {
if (token != null) 'Authorization': 'Bearer $token',
},
));
// 网络层错误自动重试(须在错误处理拦截器之前,先重试再走 401/上报逻辑)
_dio.interceptors.add(RetryInterceptor(_dio));
// 网络错误 + 401 拦截器
_dio.interceptors.add(
InterceptorsWrapper(
@@ -0,0 +1,63 @@
import 'package:dio/dio.dart';
/// 网络层错误自动重试拦截器。
///
/// 仅对「网络层」失败(连接超时 / 连接失败 / 读写超时)重试,**不**对带响应的
/// HTTP 错误(4xx/5xx 业务错误)重试。重试 3 次,间隔递增(1s → 2s → 4s)。
///
/// 安全策略(避免重复入库/出库等副作用):
/// - GET:幂等,所有网络错误都重试。
/// - POST/PUT/PATCH/DELETE:仅在「连接尚未建立」(connectTimeout / connectionError
/// 服务器还没收到请求)时重试;receiveTimeout/sendTimeout 时服务器可能已处理,
/// 不重试,避免重复提交。
class RetryInterceptor extends Interceptor {
final Dio dio;
final List<Duration> delays;
RetryInterceptor(
this.dio, {
this.delays = const [
Duration(seconds: 1),
Duration(seconds: 2),
Duration(seconds: 4),
],
});
static const _attemptKey = 'retry_attempt';
bool _retriable(DioException e) {
final method = (e.requestOptions.method).toUpperCase();
final isIdempotent = method == 'GET' || method == 'HEAD';
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.connectionError:
// 连接未建立 → 服务器未收到 → 任何方法都可安全重试
return true;
case DioExceptionType.receiveTimeout:
case DioExceptionType.sendTimeout:
// 服务器可能已处理 → 仅幂等方法重试
return isIdempotent;
default:
return false;
}
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
final attempt = (err.requestOptions.extra[_attemptKey] as int?) ?? 0;
if (_retriable(err) && attempt < delays.length) {
await Future.delayed(delays[attempt]);
err.requestOptions.extra[_attemptKey] = attempt + 1;
try {
final resp = await dio.fetch(err.requestOptions);
return handler.resolve(resp);
} on DioException catch (e) {
return handler.next(e);
} catch (_) {
return handler.next(err);
}
}
return handler.next(err);
}
}
+8
View File
@@ -126,3 +126,11 @@ class AuthNotifier extends StateNotifier<AuthState> {
final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>(
(ref) => AuthNotifier(),
);
/// 当前登录用户是否为只读角色(role == 'readonly')。
/// 只读用户禁止任何写操作:UI 据此隐藏新增/编辑/删除/审核等按钮,
/// 后端亦有 middleware.ReadOnly() 兜底返回 403。
final isReadonlyProvider = Provider<bool>((ref) {
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
return role == 'readonly';
});
+120 -37
View File
@@ -3,6 +3,7 @@ import 'dart:convert' show ascii;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'dart:ffi';
import 'package:charset_converter/charset_converter.dart';
import 'package:ffi/ffi.dart';
import 'package:win32/win32.dart'
show
@@ -13,7 +14,8 @@ import 'package:win32/win32.dart'
EndPagePrinter,
EndDocPrinter,
ClosePrinter,
DOC_INFO_1;
DOC_INFO_1,
WideCharToMultiByte;
import 'package:flutter/foundation.dart' show kIsWeb, debugPrint;
import 'package:flutter/services.dart';
import '../errors/error_reporter.dart';
@@ -240,8 +242,8 @@ Future<ui.Image> _renderLabelBitmap({
final codeText = code.isNotEmpty ? '编号:$code' : '';
final rows = <(String, double, bool)>[
if (shop.isNotEmpty) (shop, kShopSize, true),
if (name.isNotEmpty) (name, nameSize, true),
if (shop.isNotEmpty) (shop, kShopSize, false), // 店名细体,突出商品名
if (name.isNotEmpty) (name, nameSize, true), // 商品名粗体
if (codeText.isNotEmpty) (codeText, kSmallSize, false),
if (degSpec.isNotEmpty) (degSpec, kSmallSize, false),
if (date.isNotEmpty) (date, kSmallSize, false),
@@ -265,8 +267,52 @@ Future<ui.Image> _renderLabelBitmap({
return recorder.endRecording().toImage(sw, sh); // 返回 2× 图(640×320
}
/// 桌面端把「扁平标签」直接画成单色位图,TSPL BITMAP 裸发到热敏机。
/// 版式(无样式):酒行名 / 酒名(长名缩小) / 度数(系列)+规格 / 日期;右侧二维码 + 扫码溯源。
/// UTF-8 → GBK 字节:
/// Windows 直接调 WideCharToMultiByte(936) FFI,无需插件;
/// macOS 走 charset_converterCoreFoundation 原生支持 GBK)。
Uint8List _encodeGbk(String text) {
if (text.isEmpty) return Uint8List(0);
if (Platform.isWindows) {
final wideStr = text.toNativeUtf16();
try {
final needed = WideCharToMultiByte(936, 0, wideStr, -1, nullptr, 0, nullptr, nullptr);
if (needed <= 1) return Uint8List(0);
final buf = calloc<Uint8>(needed);
try {
WideCharToMultiByte(936, 0, wideStr, -1, buf.cast(), needed, nullptr, nullptr);
return Uint8List.fromList(buf.asTypedList(needed - 1)); // 不含 null 终止符
} finally {
calloc.free(buf);
}
} finally {
calloc.free(wideStr);
}
}
// macOS: charset_converter(同步 CoreFoundation
// encode() 在 macOS 上是同步 channel 调用,此处以 Future.value 包装保持接口一致
throw UnsupportedError('call _encodeGbkAsync on macOS');
}
Future<Uint8List> _encodeGbkAsync(String text) async {
if (text.isEmpty) return Uint8List(0);
if (Platform.isWindows) return _encodeGbk(text);
return CharsetConverter.encode('GBK', text);
}
/// TSPL TEXT 命令辅助:将文本以 GBK 编码写入 BytesBuilder。
/// TSS24.BF2/TSS16.BF2 是 TSC 固件内置简体中文点阵字体,需 GBK 编码。
Future<void> _addTextCmd(
BytesBuilder buf, int x, int y, String font, String text) async {
if (text.isEmpty) return;
final gbk = await _encodeGbkAsync(text);
buf.add(ascii.encode('TEXT $x,$y,"$font",0,1,1,"'));
buf.add(gbk);
buf.add(ascii.encode('"\r\n'));
}
/// 桌面端把标签以 TSPL TEXT(内置中文点阵字体)+ QR BITMAP 混合方式裸发到热敏机。
/// TEXT 走 TSS24.BF2/TSS16.BF2GBK 编码,203 DPI 下比 TrueType 位图清晰数倍)。
/// QR 仍走 BITMAP(内置 QRCODE 命令扫描不可靠)。
/// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。
/// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。
Future<bool> _printFlatLabelThermal({
@@ -283,42 +329,79 @@ Future<bool> _printFlatLabelThermal({
debugPrint('[label] thermal printer = $printer');
if (printer == null) return false;
// img 是 2× 超采样图(640×320);TSPL 输出仍是 320×160
const w = 320, h = 160;
final img = await _renderLabelBitmap(
shop: shop, name: name, code: code, degSpec: degSpec, date: date, qrBytes: qrBytes);
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
final rgba = bd!.buffer.asUint8List();
final sw = img.width; // 6402×宽)
// QR BITMAP: 124×124 dots, 右侧 x=192, y=8
const qrW = 124, qrH = 124;
const qrX = 192, qrY = 8;
const qrWBytes = (qrW + 7) >> 3; // 16 bytes/row
// 打包 TSPL(SIZE 用整数,避免固件不认小数导致多走纸)
const wbytes = (w + 7) >> 3;
final out = BytesBuilder();
out.add(ascii.encode('SIZE 40 mm,20 mm\r\nGAP 2 mm,0 mm\r\nDIRECTION 1\r\n'
'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n'
'BITMAP 0,0,$wbytes,$h,1,'));
for (int yy = 0; yy < h; yy++) {
final row = Uint8List(wbytes)..fillRange(0, wbytes, 0xFF); // 默认白 bit=1
for (int xx = 0; xx < w; xx++) {
// 2×2 四像素平均后阈值,消除反锯齿模糊(阈值 160 = 偏灰的边缘算黑)
double lum = 0;
for (int dy = 0; dy < 2; dy++) {
for (int dx = 0; dx < 2; dx++) {
final i = ((yy * 2 + dy) * sw + (xx * 2 + dx)) * 4;
final a = rgba[i + 3];
lum += a < 128
? 255.0
: 0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2];
}
}
if (lum / 4 < 160) row[xx >> 3] &= ~(0x80 >> (xx & 7)); // 黑点 -> bit0
// 将后端 256×256 QR PNG 缩放到 124×124 并转 1-bit
final codec = await ui.instantiateImageCodec(qrBytes, targetWidth: qrW, targetHeight: qrH);
final qrImg = (await codec.getNextFrame()).image;
final qrBd = await qrImg.toByteData(format: ui.ImageByteFormat.rawRgba);
final qrRgba = qrBd!.buffer.asUint8List();
final buf = BytesBuilder();
buf.add(ascii.encode(
'SIZE 40 mm,20 mm\r\nGAP 2 mm,0 mm\r\nDIRECTION 1\r\n'
'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n',
));
// QR BITMAP 命令
buf.add(ascii.encode('BITMAP $qrX,$qrY,$qrWBytes,$qrH,1,'));
for (int yy = 0; yy < qrH; yy++) {
final row = Uint8List(qrWBytes)..fillRange(0, qrWBytes, 0xFF); // 默认白
for (int xx = 0; xx < qrW; xx++) {
final i = (yy * qrW + xx) * 4;
final a = qrRgba[i + 3];
final lum = a < 128
? 255.0
: 0.299 * qrRgba[i] + 0.587 * qrRgba[i + 1] + 0.114 * qrRgba[i + 2];
if (lum < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7));
}
out.add(row);
buf.add(row);
}
out.add(ascii.encode('\r\nPRINT 1,1\r\n'));
final tspl = out.toBytes();
buf.add(ascii.encode('\r\n'));
debugPrint('[label] flat tspl ${tspl.length}B -> $printer');
// "扫码溯源" 居中于 QR 正下方
// TSS16 CJK 每字 16 dots; 4字=64; QR 中心 x=192+62=254; 文字起始 x=254-32=222
await _addTextCmd(buf, 222, qrY + qrH + 2, 'TSS16.BF2', '扫码溯源');
// 左侧文字(x=4, 宽度至 x=186
// TSS24.BF2: 24×24 点阵; TSS16.BF2: 16×16 点阵
const kFontL = 'TSS16.BF2'; // 小号(编号/度数/日期)
const kFontXL = 'TSS24.BF2'; // 大号(商品名,≤7字时使用)
final nameFont = name.runes.length <= 7 ? kFontXL : kFontL;
final nameH = nameFont == kFontXL ? 24 : 16;
final codeText = code.isNotEmpty ? '编号:$code' : '';
// (文本, 字体, 字符高度点数)
final rows = <(String, String, int)>[];
if (shop.isNotEmpty) rows.add((shop, kFontL, 16));
if (name.isNotEmpty) rows.add((name, nameFont, nameH));
if (codeText.isNotEmpty) rows.add((codeText, kFontL, 16));
if (degSpec.isNotEmpty) rows.add((degSpec, kFontL, 16));
if (date.isNotEmpty) rows.add((date, kFontL, 16));
// 在 y=8..150 区间均匀分布各行
const kTextTop = 8;
const kTextBot = 150;
const kTextX = 4;
final totalH = rows.fold(0, (s, r) => s + r.$3);
final n = rows.length;
final gapSize = n > 0
? ((kTextBot - kTextTop - totalH) / (n + 1)).round().clamp(2, 20)
: 8;
int yPos = kTextTop + gapSize;
for (final r in rows) {
await _addTextCmd(buf, kTextX, yPos, r.$2, r.$1);
yPos += r.$3 + gapSize;
}
buf.add(ascii.encode('PRINT 1,1\r\n'));
final tspl = buf.toBytes();
debugPrint('[label] TEXT+BITMAP tspl ${tspl.length}B -> $printer');
await _sendRaw(printer, tspl);
return true;
}
+63 -10
View File
@@ -7,34 +7,55 @@ import '../core/config/app_config.dart';
/// 数据 provider 通过 watch 此值实现网络恢复后自动刷新。
final networkRecoveryCountProvider = StateProvider<int>((ref) => 0);
/// 是否正在进行一次「带重试的连通性检测」(供 UI 显示「重试中…」转圈)。
final connectivityCheckingProvider = StateProvider<bool>((ref) => false);
final connectivityProvider =
StateNotifierProvider<ConnectivityNotifier, bool>((ref) {
return ConnectivityNotifier(
onRecovered: () {
ref.read(networkRecoveryCountProvider.notifier).update((s) => s + 1);
},
onCheckingChanged: (checking) {
ref.read(connectivityCheckingProvider.notifier).state = checking;
},
);
});
class ConnectivityNotifier extends StateNotifier<bool> {
final void Function()? onRecovered;
final void Function(bool checking)? onCheckingChanged;
ConnectivityNotifier({this.onRecovered, bool skipInit = false}) : super(true) {
ConnectivityNotifier({
this.onRecovered,
this.onCheckingChanged,
bool skipInit = false,
}) : super(true) {
if (!skipInit) {
_check();
// 启动首检走「带重试」:跨境冷启动 DNS+TLS 握手较慢,单次 4s 易误判离线,
// 退避重试 4s→8s→12s 可显著降低首屏假离线。
_check(withRetry: true);
_startOnlineTimer();
}
}
Timer? _timer;
// 独立轻量 Dio短超时,无拦截器
// 独立轻量 Dio:无拦截器。连接预算用 Future.timeout 逐次控制,
// 故 BaseOptions 超时放宽到 15s 作为兜底。
final _dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 3),
receiveTimeout: const Duration(seconds: 3),
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
));
/// 在线时:每 30 秒检测一次
/// 每次尝试的超时预算,逐次拉长(越来越长)。
static const _budgets = <Duration>[
Duration(seconds: 4),
Duration(seconds: 8),
Duration(seconds: 12),
];
/// 在线时:每 30 秒轻量检测一次(单次,不重试)
void _startOnlineTimer() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 30), (_) => _check());
@@ -46,25 +67,57 @@ class ConnectivityNotifier extends StateNotifier<bool> {
_timer = Timer.periodic(const Duration(minutes: 1), (_) => _check());
}
/// 立即触发一次检测(供外部调用,如 API 请求失败 / 启动时)
/// 立即触发一次检测(供外部调用,如 API 请求失败 / 启动时)。单次,不重试。
Future<void> forceCheck() => _check();
Future<void> _check() async {
/// 用户手动重试:带退避重试,并对外广播「检测中」状态,返回最终是否在线。
Future<bool> retry() async {
onCheckingChanged?.call(true);
try {
await _dio.get(AppConfig.healthUrl);
return await _check(withRetry: true);
} finally {
onCheckingChanged?.call(false);
}
}
/// 单次 ping /health[budget] 为本次超时预算。
Future<bool> _ping(Duration budget) async {
try {
await _dio.get(AppConfig.healthUrl).timeout(budget);
return true;
} catch (_) {
return false;
}
}
/// 连通性检测。[withRetry]=true 时按 [_budgets] 退避重试,间隔递增。
Future<bool> _check({bool withRetry = false}) async {
final budgets = withRetry ? _budgets : const [Duration(seconds: 4)];
var ok = false;
for (var i = 0; i < budgets.length; i++) {
ok = await _ping(budgets[i]);
if (ok) break;
// 最后一次失败后不再等待;中间失败按递增间隔退避(0.6s, 1.2s…)
if (i < budgets.length - 1) {
await Future.delayed(Duration(milliseconds: 600 * (i + 1)));
}
}
if (ok) {
if (!state) {
// 离线 → 在线:切回高频检测,广播恢复事件
state = true;
onRecovered?.call();
_startOnlineTimer();
}
} catch (_) {
} else {
if (state) {
// 在线 → 离线:切换为低频嗅探
state = false;
_startOfflineTimer();
}
}
return ok;
}
@override
@@ -11,6 +11,7 @@ import '../../core/theme/app_theme.dart';
import '../../core/storage/login_history.dart';
import '../../providers/connectivity_provider.dart';
import '../../repositories/auth_repository.dart';
import '../../widgets/network_retry_button.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@@ -426,6 +427,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
fontSize: 13),
),
),
NetworkRetryButton(
foreground: Color(0xFF5D4037)),
],
),
),
@@ -11,6 +11,7 @@ import '../../widgets/page_scaffold.dart';
import '../../providers/connectivity_provider.dart';
import '../../core/utils/export_util.dart';
import '../../repositories/finance_repository.dart';
import '../../widgets/write_guard.dart';
class FinanceScreen extends ConsumerWidget {
const FinanceScreen({super.key});
@@ -160,7 +161,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
MobileCardField('状态', null, valueWidget: _StatusBadge(r.status)),
if (r.remark?.isNotEmpty == true) MobileCardField('备注', r.remark),
],
actions: canClose
actions: (canClose && !WriteGuard.isReadonly(ref))
? [
TextButton(
onPressed: () => _closeRecord(r),
@@ -304,7 +305,9 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)),
));
case 'actions':
if ((r.type == 'payable' || r.type == 'receivable') && r.status == 'open') {
if ((r.type == 'payable' || r.type == 'receivable') &&
r.status == 'open' &&
!WriteGuard.isReadonly(ref)) {
return DataCell(TextButton(
onPressed: () => _closeRecord(r),
child: const Text('结清',
@@ -405,13 +408,14 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
},
toolbar: Row(
children: [
if (addLabel != null)
if (addLabel != null && !WriteGuard.isReadonly(ref))
ElevatedButton.icon(
onPressed: _showAddDialog,
icon: const Icon(Icons.add, size: 16),
label: Text(addLabel),
),
if (addLabel != null) const SizedBox(width: 8),
if (addLabel != null && !WriteGuard.isReadonly(ref))
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () {
final tabName = widget.typeFilter.isEmpty
@@ -9,6 +9,7 @@ import '../../core/responsive/responsive.dart';
import '../../core/theme/app_theme.dart';
import '../../models/inventory.dart';
import '../../core/auth/auth_state.dart';
import '../../widgets/write_guard.dart';
import '../../providers/inventory_provider.dart';
import '../../widgets/data_table_card.dart';
import '../../widgets/mobile_list_card.dart';
@@ -292,7 +293,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
message: item.remark.isEmpty ? '' : item.remark,
waitDuration: const Duration(milliseconds: 300),
child: GestureDetector(
onTap: () => _editRemark(context, item),
onTap: WriteGuard.isReadonly(ref)
? null
: () => _editRemark(context, item),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -358,10 +361,11 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
MobileCardField('供应商', item.supplierName),
],
actions: [
TextButton(
onPressed: () => _editRemark(context, item),
child: const Text('备注', style: TextStyle(fontSize: 13)),
),
if (!WriteGuard.isReadonly(ref))
TextButton(
onPressed: () => _editRemark(context, item),
child: const Text('备注', style: TextStyle(fontSize: 13)),
),
if (item.productId != null)
TextButton(
onPressed: () => _printLabel(context, item),
@@ -551,12 +555,14 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
ref.read(inventoryListProvider.notifier).setPageSize(s),
toolbar: Row(
children: [
OutlinedButton.icon(
onPressed: () => context.go('/inventory/check'),
icon: const Icon(Icons.fact_check, size: 16),
label: const Text('发起盘点'),
),
const SizedBox(width: 8),
if (!WriteGuard.isReadonly(ref)) ...[
OutlinedButton.icon(
onPressed: () => context.go('/inventory/check'),
icon: const Icon(Icons.fact_check, size: 16),
label: const Text('发起盘点'),
),
const SizedBox(width: 8),
],
OutlinedButton.icon(
onPressed: () => exportExcel(
filename: '库存查询',
@@ -11,6 +11,7 @@ import '../../widgets/data_table_card.dart';
import '../../widgets/mobile_list_card.dart';
import '../../widgets/page_scaffold.dart';
import '../../core/utils/export_util.dart';
import '../../widgets/write_guard.dart';
class PartnersScreen extends ConsumerStatefulWidget {
const PartnersScreen({super.key});
@@ -166,19 +167,21 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
if (p.phone?.isNotEmpty == true) MobileCardField('电话', p.phone),
if (p.address?.isNotEmpty == true) MobileCardField('地址', p.address),
],
actions: [
TextButton(
key: Key('btn_edit_${p.id}'),
onPressed: () => onEdit(p),
child: const Text('编辑', style: TextStyle(fontSize: 13)),
),
TextButton(
key: Key('btn_delete_${p.id}'),
onPressed: () => onDelete(p),
child: const Text('删除',
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
),
],
actions: WriteGuard.isReadonly(ref)
? const []
: [
TextButton(
key: Key('btn_edit_${p.id}'),
onPressed: () => onEdit(p),
child: const Text('编辑', style: TextStyle(fontSize: 13)),
),
TextButton(
key: Key('btn_delete_${p.id}'),
onPressed: () => onDelete(p),
child: const Text('删除',
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
),
],
);
}
@@ -191,12 +194,14 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
mobileCards: partners.map(partnerCard).toList(),
toolbar: Row(
children: [
ElevatedButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add, size: 16),
label: Text(isSupplier ? '新建' : '新建'),
),
const SizedBox(width: 8),
if (!WriteGuard.isReadonly(ref)) ...[
ElevatedButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add, size: 16),
label: Text(isSupplier ? '新建' : '新建'),
),
const SizedBox(width: 8),
],
OutlinedButton.icon(
onPressed: () => exportExcel(
filename: isSupplier ? '供应商列表' : '客户列表',
@@ -280,22 +285,24 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
)),
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
key: Key('btn_edit_${p.id}'),
onPressed: () => onEdit(p),
child: const Text('编辑',
style: TextStyle(fontSize: 12)),
),
TextButton(
key: Key('btn_delete_${p.id}'),
onPressed: () => onDelete(p),
child: const Text('删除',
style: TextStyle(
fontSize: 12,
color: AppTheme.danger)),
),
],
children: WriteGuard.isReadonly(ref)
? const []
: [
TextButton(
key: Key('btn_edit_${p.id}'),
onPressed: () => onEdit(p),
child: const Text('编辑',
style: TextStyle(fontSize: 12)),
),
TextButton(
key: Key('btn_delete_${p.id}'),
onPressed: () => onDelete(p),
child: const Text('删除',
style: TextStyle(
fontSize: 12,
color: AppTheme.danger)),
),
],
)),
],
))
@@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/config/app_config.dart';
import '../../core/utils/print_util.dart';
import '../../widgets/label_preview_dialog.dart';
import '../../widgets/write_guard.dart';
import '../../core/theme/app_theme.dart';
import '../../models/product.dart';
import '../../models/product_image.dart';
@@ -313,7 +314,10 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
onDelete: () => _deleteImage(img),
)),
if (p.images.length < 5)
_UploadButton(uploading: _uploading, onTap: _pickAndUpload),
WriteGuard(
child:
_UploadButton(uploading: _uploading, onTap: _pickAndUpload),
),
],
),
),
@@ -470,20 +474,24 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
fontSize: 14, fontWeight: FontWeight.w600)),
const Spacer(),
if (_descChanged)
_savingDesc
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: ElevatedButton(
onPressed: _saveDesc,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap),
child: const Text('保存', style: TextStyle(fontSize: 13)),
),
WriteGuard(
child: _savingDesc
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: ElevatedButton(
onPressed: _saveDesc,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 6),
minimumSize: Size.zero,
tapTargetSize:
MaterialTapTargetSize.shrinkWrap),
child:
const Text('保存', style: TextStyle(fontSize: 13)),
),
),
],
),
const SizedBox(height: 12),
@@ -586,7 +594,8 @@ class _ImageThumbnail extends StatelessWidget {
Positioned(
top: 4,
right: 4,
child: GestureDetector(
child: WriteGuard(
child: GestureDetector(
onTap: onDelete,
child: Container(
width: 20,
@@ -599,6 +608,7 @@ class _ImageThumbnail extends StatelessWidget {
const Icon(Icons.close, size: 14, color: Colors.white),
),
),
),
),
],
),
@@ -10,6 +10,7 @@ import '../../widgets/data_table_card.dart';
import '../../widgets/mobile_list_card.dart';
import '../../widgets/page_scaffold.dart';
import '../../core/utils/export_util.dart';
import '../../widgets/write_guard.dart';
class ProductsScreen extends ConsumerStatefulWidget {
const ProductsScreen({super.key});
@@ -356,12 +357,14 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
}) {
return Row(
children: [
ElevatedButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add, size: 16),
label: const Text('新建'),
),
const SizedBox(width: 8),
if (!WriteGuard.isReadonly(ref)) ...[
ElevatedButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add, size: 16),
label: const Text('新建'),
),
const SizedBox(width: 8),
],
OutlinedButton.icon(
onPressed: onExport,
icon: const Icon(Icons.download, size: 16),
@@ -416,33 +419,37 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
MobileCardField('单品数量', '$quantity'),
if (remark?.isNotEmpty == true) MobileCardField('备注', remark),
],
actions: [
TextButton(
onPressed: onEdit,
child: const Text('编辑', style: TextStyle(fontSize: 13)),
),
TextButton(
onPressed: onDelete,
child: const Text('删除',
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
),
],
actions: WriteGuard.isReadonly(ref)
? const []
: [
TextButton(
onPressed: onEdit,
child: const Text('编辑', style: TextStyle(fontSize: 13)),
),
TextButton(
onPressed: onDelete,
child: const Text('删除',
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
),
],
);
}
Widget _actionButtons({required VoidCallback onEdit, required VoidCallback onDelete}) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: onEdit,
child: const Text('编辑', style: TextStyle(fontSize: 12)),
),
TextButton(
onPressed: onDelete,
child: const Text('删除', style: TextStyle(fontSize: 12, color: AppTheme.danger)),
),
],
return WriteGuard(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: onEdit,
child: const Text('编辑', style: TextStyle(fontSize: 12)),
),
TextButton(
onPressed: onDelete,
child: const Text('删除', style: TextStyle(fontSize: 12, color: AppTheme.danger)),
),
],
),
);
}
@@ -9,6 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_state.dart';
import '../../widgets/write_guard.dart';
import '../../core/config/app_config.dart';
import '../../core/config/app_info.dart';
import '../../core/responsive/responsive.dart';
@@ -78,6 +79,28 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
],
),
),
// 退出登录(常驻底部;登出后路由 redirect 自动跳 /login
const Divider(height: 1),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () =>
ref.read(authStateProvider.notifier).logout(),
icon: const Icon(Icons.logout, size: 18),
label: const Text('退出登录'),
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.danger,
side: const BorderSide(color: AppTheme.danger),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
),
),
],
),
);
@@ -154,6 +177,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
label: '负责人',
value:
shop.managerName.isNotEmpty ? shop.managerName : ''),
const Divider(height: 16),
_ShopInfoRow(
label: '微信号',
value: shop.wechatId.isNotEmpty ? shop.wechatId : ''),
],
),
),
@@ -192,10 +219,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
ElevatedButton.icon(
onPressed: () => _showAddUserDialog(context),
icon: const Icon(Icons.person_add, size: 16),
label: const Text('新增用户'),
WriteGuard(
child: ElevatedButton.icon(
onPressed: () => _showAddUserDialog(context),
icon: const Icon(Icons.person_add, size: 16),
label: const Text('新增用户'),
),
),
],
),
@@ -266,7 +295,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
)),
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
children: WriteGuard.isReadonly(ref)
? const []
: [
TextButton(
onPressed: () =>
_showEditUserDialog(context, u),
@@ -428,16 +459,18 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
style: const TextStyle(fontSize: 13),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _isActivating ? null : _activateLicense,
child: _isActivating
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('激活'),
),
if (!WriteGuard.isReadonly(ref)) ...[
const SizedBox(width: 8),
ElevatedButton(
onPressed: _isActivating ? null : _activateLicense,
child: _isActivating
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('激活'),
),
],
],
),
],
@@ -588,11 +621,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
style: const TextStyle(
fontFamily: 'monospace', fontSize: 12))),
DataCell(Text('${r.currentNo}')),
DataCell(TextButton(
onPressed: () =>
_showNumberRuleDialog(context, r),
child: const Text('编辑',
style: TextStyle(fontSize: 12)))),
DataCell(WriteGuard(
child: TextButton(
onPressed: () =>
_showNumberRuleDialog(context, r),
child: const Text('编辑',
style: TextStyle(fontSize: 12))))),
]))
.toList(),
),
@@ -771,6 +805,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
// ── 数据导入 Tab ──────────────────────────────────────────
Widget _buildImportTab() {
if (WriteGuard.isReadonly(ref)) {
return const Center(
child: Text('只读账号无导入权限',
style: TextStyle(color: AppTheme.textSecondary)),
);
}
final currentUser = ref.watch(authStateProvider).user;
final isSuperAdmin = currentUser?.role == 'superadmin';
return _BatchImportWidget(isSuperAdmin: isSuperAdmin);
+53 -9
View File
@@ -6,6 +6,7 @@ import 'package:intl/intl.dart';
import 'dart:async';
import 'package:flutter/foundation.dart' show kIsWeb;
import '../../core/auth/auth_state.dart';
import '../../widgets/write_guard.dart';
import '../../core/config/app_config.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_theme.dart';
@@ -14,6 +15,7 @@ import '../../providers/shop_provider.dart';
import '../../providers/update_provider.dart';
import '../../core/update/app_updater.dart';
import '../../providers/license_provider.dart';
import '../../widgets/network_retry_button.dart';
class AppShell extends ConsumerStatefulWidget {
final Widget child;
@@ -141,6 +143,19 @@ class _AppShellState extends ConsumerState<AppShell> {
}).toList(),
),
),
// 退出登录(抽屉底部常驻,窄屏顶栏菜单不便时也能退出)
const Divider(height: 1, color: Colors.white24),
ListTile(
leading: const Icon(Icons.logout, color: Colors.white70),
title: const Text('退出登录',
style: TextStyle(
color: Colors.white, fontWeight: FontWeight.w500)),
onTap: () {
Navigator.pop(context); // 关闭抽屉
ref.read(authStateProvider.notifier).logout();
context.go('/login');
},
),
],
),
),
@@ -154,6 +169,8 @@ class _AppShellState extends ConsumerState<AppShell> {
final isOnline = ref.watch(connectivityProvider);
final location = GoRouterState.of(context).matchedLocation;
final isMobile = context.isMobile;
// iOS/刘海屏状态栏高度:顶栏需下移此距离,避免菜单按钮被状态栏遮挡
final topInset = MediaQuery.of(context).padding.top;
final sidebarWidth = _sidebarExpanded ? 200.0 : 56.0;
final updateNotifier = ref.watch(updateProvider.notifier);
final updateInfo = ref.watch(updateProvider).valueOrNull;
@@ -168,11 +185,11 @@ class _AppShellState extends ConsumerState<AppShell> {
drawerEnableOpenDragGesture: !kIsWeb && isMobile,
body: Column(
children: [
// Top Bar
// Top Bar(高度含状态栏内边距,蓝色铺满到屏幕顶,内容下移避开状态栏)
Container(
height: 56,
height: 56 + topInset,
color: AppTheme.primary,
padding: const EdgeInsets.symmetric(horizontal: 8),
padding: EdgeInsets.only(top: topInset, left: 8, right: 8),
child: Row(
children: [
IconButton(
@@ -194,6 +211,30 @@ class _AppShellState extends ConsumerState<AppShell> {
),
const SizedBox(width: 4),
_ShopButton(user: user, version: appVersion),
if (WriteGuard.isReadonly(ref)) ...[
const SizedBox(width: 8),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(4),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.visibility_outlined,
size: 13, color: Colors.white),
SizedBox(width: 4),
Text('只读',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600)),
],
),
),
],
const Spacer(),
if (user != null) ...[
// 窄屏隐藏门店号/用户名文字块,仅保留用户菜单,避免顶栏拥挤
@@ -382,13 +423,16 @@ class _AppShellState extends ConsumerState<AppShell> {
Icon(Icons.wifi_off,
size: 16, color: Colors.white),
SizedBox(width: 8),
Text(
'网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新',
style: TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500),
Expanded(
child: Text(
'网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新',
style: TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500),
),
),
NetworkRetryButton(),
],
),
),
@@ -22,6 +22,7 @@ import '../../providers/product_provider.dart' show productRepositoryProvider;
import '../../repositories/product_repository.dart';
import '../../providers/finance_provider.dart' show financeRepositoryProvider;
import '../../providers/shop_provider.dart' show shopInfoProvider;
import '../../widgets/write_guard.dart';
class StockInListScreen extends ConsumerStatefulWidget {
const StockInListScreen({super.key});
@@ -280,7 +281,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
ref.read(stockInListProvider.notifier).setPageSize(s),
toolbar: Row(
children: [
if (showNewButton)
if (showNewButton && !WriteGuard.isReadonly(ref))
ElevatedButton.icon(
onPressed: () => context.go('/stock-in/new'),
icon: const Icon(Icons.add, size: 16),
@@ -344,6 +345,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
///
List<Widget> _orderActions(BuildContext context, StockInOrder o) {
final readonly = WriteGuard.isReadonly(ref);
return [
TextButton(
onPressed: () => _showDetail(context, o.id),
@@ -390,13 +392,13 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
child: const Text('打标签',
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
),
if (o.status == 'approved')
if (!readonly && o.status == 'approved')
TextButton(
onPressed: () => _confirmSettle(context, o.id, 'stock_in'),
child: const Text('结清',
style: TextStyle(fontSize: 12, color: AppTheme.accent)),
),
if (o.status == 'draft') ...[
if (!readonly && o.status == 'draft') ...[
TextButton(
onPressed: () => context.go('/stock-in/edit/${o.id}'),
child: const Text('修改',
@@ -413,7 +415,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
),
],
if (o.status == 'pending') ...[
if (!readonly && o.status == 'pending') ...[
TextButton(
key: Key('btn_approve_${o.id}'),
onPressed: () => _confirmApprove(context, o),
@@ -20,6 +20,7 @@ import '../../providers/inventory_provider.dart';
import '../../providers/tab_state_provider.dart';
import '../../providers/product_provider.dart';
import '../../providers/finance_provider.dart' show financeRepositoryProvider;
import '../../widgets/write_guard.dart';
class StockOutListScreen extends ConsumerStatefulWidget {
const StockOutListScreen({super.key});
@@ -286,7 +287,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
ref.read(stockOutListProvider.notifier).setPageSize(s),
toolbar: Row(
children: [
if (showNewButton)
if (showNewButton && !WriteGuard.isReadonly(ref))
ElevatedButton.icon(
onPressed: () => context.go('/stock-out/new'),
icon: const Icon(Icons.add, size: 16),
@@ -350,6 +351,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
///
List<Widget> _orderActions(BuildContext context, StockOutOrder o) {
final readonly = WriteGuard.isReadonly(ref);
return [
TextButton(
onPressed: () => _showDetail(context, o.id),
@@ -366,13 +368,13 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
child: const Text('打印',
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
),
if (o.status == 'approved')
if (!readonly && o.status == 'approved')
TextButton(
onPressed: () => _confirmSettle(context, o.id, 'stock_out'),
child: const Text('结清',
style: TextStyle(fontSize: 12, color: AppTheme.accent)),
),
if (o.status == 'draft') ...[
if (!readonly && o.status == 'draft') ...[
TextButton(
onPressed: () => context.go('/stock-out/edit/${o.id}'),
child: const Text('修改',
@@ -389,7 +391,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
),
],
if (o.status == 'pending') ...[
if (!readonly && o.status == 'pending') ...[
TextButton(
key: Key('btn_approve_${o.id}'),
onPressed: () => _confirmApprove(context, o),
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/theme/app_theme.dart';
import '../providers/connectivity_provider.dart';
/// 线
/// - +
/// - SnackBar /
///
/// [foreground]
class NetworkRetryButton extends ConsumerWidget {
final Color foreground;
const NetworkRetryButton({super.key, this.foreground = Colors.white});
@override
Widget build(BuildContext context, WidgetRef ref) {
final checking = ref.watch(connectivityCheckingProvider);
return TextButton(
onPressed: checking
? null
: () async {
final ok =
await ref.read(connectivityProvider.notifier).retry();
if (!context.mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(ok ? '已重新连接服务器' : '仍无法连接服务器,请稍后重试'),
backgroundColor: ok ? AppTheme.success : AppTheme.danger,
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
));
},
style: TextButton.styleFrom(
foregroundColor: foreground,
minimumSize: const Size(0, 32),
padding: const EdgeInsets.symmetric(horizontal: 10),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (checking)
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(foreground),
),
)
else
Icon(Icons.refresh, size: 16, color: foreground),
const SizedBox(width: 6),
Text(
checking ? '重试中…' : '重试',
style: TextStyle(
color: foreground, fontSize: 13, fontWeight: FontWeight.w600),
),
],
),
);
}
}
+39
View File
@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/auth/auth_state.dart';
/// role == 'readonly'
/// //////
///
/// `if (!ref.watch(isReadonlyProvider))`
/// `middleware.ReadOnly()` 403
/// UI +
///
///
/// ```dart
/// WriteGuard(child: ElevatedButton(onPressed: _add, child: const Text('新建')))
/// ```
/// children [hidden] collection-if
/// ```dart
/// if (!WriteGuard.isReadonly(ref)) ...[button, const SizedBox(width: 8)]
/// ```
class WriteGuard extends ConsumerWidget {
final Widget child;
///
final Widget placeholder;
const WriteGuard({
super.key,
required this.child,
this.placeholder = const SizedBox.shrink(),
});
/// collection-if /
static bool isReadonly(WidgetRef ref) => ref.watch(isReadonlyProvider);
@override
Widget build(BuildContext context, WidgetRef ref) {
return ref.watch(isReadonlyProvider) ? placeholder : child;
}
}
@@ -5,6 +5,7 @@
import FlutterMacOS
import Foundation
import charset_converter
import file_picker
import file_selector_macos
import package_info_plus
@@ -13,6 +14,7 @@ import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
CharsetConverterPlugin.register(with: registry.registrar(forPlugin: "CharsetConverterPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
+12 -4
View File
@@ -57,6 +57,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
charset_converter:
dependency: "direct main"
description:
name: charset_converter
sha256: af00b5b73b367101c661b8dfaa52abf3e533de7b3e247e3f65c535ebc4df820a
url: "https://pub.dev"
source: hosted
version: "2.4.0"
clock:
dependency: transitive
description:
@@ -513,10 +521,10 @@ packages:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev"
source: hosted
version: "2.1.5"
version: "2.1.6"
path_provider_android:
dependency: transitive
description:
@@ -545,10 +553,10 @@ packages:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
+3 -1
View File
@@ -1,7 +1,7 @@
name: jiu_client
description: 酒库管理系统 - 酒店仓库管理
publish_to: 'none'
version: 1.0.3+1
version: 1.0.52+1
environment:
sdk: '>=3.0.0 <4.0.0'
@@ -29,6 +29,8 @@ dependencies:
# Windows 热敏机 TSPL 裸发(winspool WritePrinter),仅 Windows 运行时调用
win32: ^5.5.0
ffi: ^2.1.3
# GBK 编码(TSPL TEXT 内置中文字体需要),支持 macOS/Windows/Linux
charset_converter: ^2.1.0
dev_dependencies:
flutter_test:
@@ -6,11 +6,14 @@
#include "generated_plugin_registrant.h"
#include <charset_converter/charset_converter_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <printing/printing_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
CharsetConverterPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("CharsetConverterPlugin"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
PrintingPluginRegisterWithRegistrar(
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
charset_converter
file_selector_windows
printing
url_launcher_windows
-1973
View File
File diff suppressed because it is too large Load Diff
-821
View File
@@ -1,821 +0,0 @@
{
"meta": {
"title": "酒库管理系统 — 项目 TODO",
"updated_at": "2026-06-14T00:13:09.458Z"
},
"seq": 51,
"items": [
{
"id": 1,
"title": "两套 CSS / 设计令牌并存",
"desc": "主站 njk 用 color.css+style.css,功能页用 colors_and_type.css,导航/页脚各写一份,维护分裂。建议合并到一套,功能页走 base.njk 模板。",
"level": "mid",
"tags": [
"前端",
"Web"
],
"created_at": "2026-06-07T17:30:32.138Z",
"done": true,
"completed_at": "2026-06-07T17:48:38.554Z",
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 2,
"title": "备案号为占位假值",
"desc": "现为沪ICP备2026000000号(全0),上线前必须替换真实备案号。",
"level": "low",
"tags": [
"Web"
],
"created_at": "2026-06-07T17:30:32.188Z",
"done": false,
"completed_at": null,
"version": null,
"status": "done"
},
{
"id": 3,
"title": "注册接口同源依赖问题",
"desc": "用 window.location.origin+'/api/v1/public/register',要求同源部署;分离部署时指错。需确认部署形态。",
"level": "low",
"tags": [
"前端",
"Web"
],
"created_at": "2026-06-07T17:30:32.237Z",
"done": true,
"completed_at": "2026-06-08T14:33:22.523Z",
"version": "v1.0.25",
"status": "accepted"
},
{
"id": 4,
"title": "scan.html / features 走 passthrough 不享模板",
"desc": "features/scan.html 直接复制,不经 njk 模板,页脚/备案/CSS 都得手动同步。建议长期改造为模板化。",
"level": "low",
"tags": [
"前端",
"Web"
],
"created_at": "2026-06-07T17:30:32.287Z",
"done": true,
"completed_at": "2026-06-07T18:07:13.211Z",
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 5,
"title": "页脚双份维护",
"desc": "njk 站点用 footer.njk 读变量,功能页把页脚硬编码各写一份,改一处不同步。根因同「两套模板」。",
"level": "low",
"tags": [
"前端",
"Web"
],
"created_at": "2026-06-07T17:30:32.334Z",
"done": true,
"completed_at": "2026-06-08T14:33:22.584Z",
"version": "v1.0.25",
"status": "accepted"
},
{
"id": 6,
"title": "注册成功提示路径需核对",
"desc": "提示「忘记门店编码可在「系统设置→关于」查看」,需与实际客户端菜单路径一致。",
"level": "low",
"tags": [
"前端",
"Web"
],
"created_at": "2026-06-07T17:30:32.381Z",
"done": true,
"completed_at": "2026-06-08T14:33:22.648Z",
"version": "v1.0.25",
"status": "accepted"
},
{
"id": 8,
"title": "补全「查看本店其他商品」功能",
"desc": "PublicProductScreen _ShopCard 底部按钮 onPressed: () {} 为空,需实现跳转至该门店商品列表页。client/lib/screens/public/public_product_screen.dart:1240",
"level": "mid",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-07T18:08:24.432Z",
"done": true,
"completed_at": "2026-06-07T23:18:23.239Z",
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 9,
"title": "设计并实现添加门店微信功能",
"desc": "PublicProductScreen _ShopCard「添加门店微信」按钮 onPressed: () {} 为空。需设计方案:门店维护微信号字段 or 二维码图片,扫码页展示。涉及后端 shop model 扩展 + 前端展示。client/lib/screens/public/public_product_screen.dart:1255",
"level": "mid",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-07T18:08:32.748Z",
"done": true,
"completed_at": "2026-06-07T23:28:52.932Z",
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 10,
"title": "公开商品 API 补充 sale_price 并在页面展示价格",
"desc": "product model 有 sale_price 字段但 public.go GetProduct 未返回。需:1) 后端 batchData/productData 加 sale_price2) Flutter _TitleBlock 或单独区块展示建议零售价。client: public_product_screen.dart, backend: handler/public.go",
"level": "mid",
"tags": [
"前端",
"后端",
"iOS",
"Android"
],
"created_at": "2026-06-07T18:12:21.217Z",
"done": true,
"completed_at": "2026-06-07T23:28:53.001Z",
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 13,
"title": "公开商品 API 补充 batch.quantity 和 product code/barcode",
"desc": "Flutter _AuthenticityCard 读取 batch['quantity'] 但后端 public.go batchData 未返回 quantity。另 product code/barcode 未暴露,参数卡无法显示商品编码。handler/public.go GetProduct",
"level": "low",
"tags": [
"后端",
"iOS",
"Android"
],
"created_at": "2026-06-07T18:12:43.136Z",
"done": true,
"completed_at": "2026-06-08T14:40:04.464Z",
"version": "v1.0.25",
"status": "accepted"
},
{
"id": 14,
"title": "Footer「商品报错」「意见反馈」补充实际功能",
"desc": "public_product_screen.dart _Footer 底部两个链接无 url/action,点击静默。需设计方案:跳转反馈表单、弹出联系方式、或发邮件。line:1353-1355",
"level": "low",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-07T18:12:50.299Z",
"done": true,
"completed_at": "2026-06-08T00:06:15.299Z",
"version": "v1.0.24",
"status": "accepted"
},
{
"id": 15,
"title": "4 张商品属性字典表 + 后端 CRUD",
"desc": "新增 product_origin_options/product_shelf_life_options/product_storage_options/product_description_docs 4 张字典表(TenantBase+shop_id 隔离);Product 增加 4 个可空外键;AutoMigrate 注册;handler/product_attr.go CRUD16 个方法);router.go 追加 4 组路由在 /product-options/ 下",
"level": "mid",
"tags": [
"后端",
"数据库"
],
"created_at": "2026-06-07T22:51:51.881Z",
"done": true,
"completed_at": "2026-06-07T22:56:24.807Z",
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 16,
"title": "public.go 返回产地/保质期/储存/介绍,公开页去硬编码",
"desc": "public.go GetProduct Preload 4 张新表 + 三级回退(描述文档→旧Description→通用中性兜底)+ 默认值常量(保质期/储存方式);Flutter public_product_screen.dart _ParamsCard/_DescriptionSection 改读真实数据,移除茅台 mock",
"level": "mid",
"tags": [
"后端",
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-07T22:51:59.988Z",
"done": true,
"completed_at": "2026-06-07T22:58:32.882Z",
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 17,
"title": "入库表单关联 4 个商品属性字典",
"desc": "stock_in_form_screen.dart 生产日期字段附近增加产地/保质期/储存方式/描述文档 4 个下拉选择(数据来自字典 List 接口);FindOrCreate/Update 接受并写入 4 个 id;前端新增对应 API client 方法",
"level": "mid",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-07T22:52:07.331Z",
"done": true,
"completed_at": "2026-06-07T23:03:13.631Z",
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 18,
"title": "基础数据管理 UI 增加 4 个字典维护 Tab",
"desc": "products_screen.dart 基础数据管理页增加产地/保质期/储存方式/描述文档 4 个维护 Tab,复用现有 option 维护组件(增删改查);描述文档 Tab 含 title+content 多行输入",
"level": "low",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-07T22:52:13.283Z",
"done": true,
"completed_at": "2026-06-08T00:06:15.371Z",
"version": "v1.0.24",
"status": "accepted"
},
{
"id": 19,
"title": "入库单新建表单增加产地/保质期/储存方式,选填项默认折叠",
"desc": "新建入库单的商品行增加产地、保质期、储存方式三个字典下拉选项(已有字典数据)。必填项(商品名、数量等)正常显示;选填项默认隐藏,点击展开按钮后在下一行展示。",
"level": "high",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-08T14:56:32.850Z",
"done": false,
"completed_at": null,
"version": null,
"status": "done",
"reject_reason": null,
"rejected_at": null
},
{
"id": 20,
"title": "商品详情页重设计:展示所有字段,内容为公开页超集",
"desc": "当前 product_detail_screen.dart 显示内容过于简单。需先做页面设计(UI 方案),内容要求是公开扫码页的超集:包含商品基本信息、参数(产地/保质期/储存方式)、价格、描述文档、图片、库存批次、入库记录等管理侧专属字段。另:商品公开链接展示为可点击超链接(点击可跳转公开扫码页)。",
"level": "mid",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-08T14:59:08.342Z",
"done": false,
"completed_at": null,
"version": null,
"status": "done"
},
{
"id": 21,
"title": "授权信息功能方案设计",
"desc": "完整方案设计,涵盖:界面设计与交互、前端实现方案、后端接口与数据模型、安全策略(防盗用、防滥用、授权校验)等。需产出设计文档后再进入开发阶段。",
"level": "low",
"tags": [
"前端",
"后端",
"安全",
"文档"
],
"created_at": "2026-06-08T15:02:58.896Z",
"done": false,
"completed_at": null,
"version": null,
"status": "done",
"subtasks": [
{
"sid": "21A",
"title": "后端:Ed25519 签发验签工具 + GenerateKey 修复",
"tier": 2,
"deps": [],
"status": "done",
"created_at": "2026-06-09T16:14:58.628Z"
},
{
"sid": "21B",
"title": "数据库:license_devices 表 + licenses 表改造",
"tier": 2,
"deps": [
"21A"
],
"status": "done",
"created_at": "2026-06-09T16:15:09.490Z"
},
{
"sid": "21C",
"title": "后端:注册自动签发 30 天 trial license",
"tier": 2,
"deps": [
"21A"
],
"status": "done",
"created_at": "2026-06-09T16:15:09.541Z"
},
{
"sid": "21D",
"title": "后端:LicenseGuard 中间件 + JWT claim 扩展",
"tier": 2,
"deps": [
"21B",
"21C"
],
"status": "done",
"created_at": "2026-06-09T16:15:09.590Z"
},
{
"sid": "21E",
"title": "前端:license model/repository/device_id 采集",
"tier": 2,
"deps": [
"21A"
],
"status": "done",
"created_at": "2026-06-09T16:15:09.639Z"
},
{
"sid": "21F",
"title": "前端:激活与设备管理 UI(授权 Tab 重构)",
"tier": 2,
"deps": [
"21D",
"21E"
],
"status": "done",
"created_at": "2026-06-09T16:15:09.689Z"
},
{
"sid": "21G",
"title": "前端:gating 横幅 + 到期弹窗(app_shell 注入)",
"tier": 2,
"deps": [
"21D",
"21E"
],
"status": "done",
"created_at": "2026-06-09T16:15:09.739Z"
},
{
"sid": "21H",
"title": "端到端验证:phase 切换 + 签名拒绝 + 多设备上限",
"tier": 2,
"deps": [
"21D",
"21F",
"21G"
],
"status": "done",
"created_at": "2026-06-09T16:15:09.789Z"
}
]
},
{
"id": 22,
"title": "关于我们页文档/更新日志链接更新,Web 新增版本历史页",
"desc": "1. 客户端「关于我们」页面的「打开文档」「更新日志」链接指向需更新为正确地址。2. Web 营销站新增版本更新页面(/changelog 或 /download/),从 CHANGELOG.md 自动渲染,展示所有历史版本更新记录。",
"level": "low",
"tags": [
"前端",
"Web",
"文档"
],
"created_at": "2026-06-08T15:05:00.447Z",
"done": false,
"completed_at": null,
"version": null,
"status": "done"
},
{
"id": 23,
"title": "注册/联系入口「发邮件」升级为「加客服微信」并优化文案",
"desc": "当前注册成功页和网站联系支持入口引导用户「发邮件」,体验较弱。升级为扫码加客服微信,同时梳理相关文案措辞,使表达更专业、有亲和力。",
"level": "low",
"tags": [
"前端",
"Web"
],
"created_at": "2026-06-08T15:06:00.175Z",
"done": false,
"completed_at": null,
"version": null,
"status": "done"
},
{
"id": 24,
"title": "服务条款与隐私政策页面",
"desc": "在营销网站(jiu.51yanmei.com)补充服务条款(Terms of Service)和隐私政策(Privacy Policy)页面,注册页底部添加链接。最低优先级,待其他功能稳定后处理。",
"level": "low",
"tags": [
"Web",
"文档"
],
"created_at": "2026-06-08T15:06:26.383Z",
"done": false,
"completed_at": null,
"version": null,
"status": "done"
},
{
"id": 25,
"title": "移动端左侧抽屉菜单支持手势侧滑唤出",
"desc": "窄屏(手机)模式下,导航 Drawer 当前需点击汉堡按钮打开,需支持从屏幕左边缘向右滑动手势唤出菜单,符合移动端操作习惯。涉及 app_shell.dart 的 Drawer 配置。",
"level": "low",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-08T15:09:27.652Z",
"done": false,
"completed_at": null,
"version": null,
"status": "done"
},
{
"id": 26,
"title": "入库单详情商品明细改为卡片式竖排布局",
"desc": "当前入库单详情的商品明细以横向表格展示,字段多时在窄屏上挤压严重。改为每件商品独占一个卡片区块,字段竖向排列(商品名、规格、数量、单价、产地、保质期等各占一行),信息层次清晰,移动端阅读体验更好。宽屏可保持表格,窄屏自动切换为卡片流(复用 MobileListCard 模式)。",
"level": "low",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-08T15:11:09.971Z",
"done": false,
"completed_at": null,
"version": null,
"status": "done"
},
{
"id": 27,
"title": "桌面客户端单实例限制,禁止同时运行多个进程",
"desc": "Windows 和 macOS 客户端启动时检测是否已有实例运行;若有,则激活已有窗口并退出新进程。Flutter desktop 可通过 window_single_instance 或 dart:io 锁文件实现。",
"level": "mid",
"tags": [
"Windows",
"mac"
],
"status": "done",
"created_at": "2026-06-08T15:58:11.759Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 29,
"title": "修复库存扣减 TOCTOU 竞态:SUM 预检纳入事务且在 FOR UPDATE 加锁后执行",
"desc": "stock.go ApproveStockOut 中库存总量 SUM 在 FOR UPDATE 加锁前执行,存在 check-then-act 窗口,高并发下可超扣。需将预检 SUM 也放入事务并在加锁后执行",
"level": "high",
"tier": 2,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T15:31:13.249Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 30,
"title": "统一 handler Update 改用白名单字段更新,防止 GORM Save() 跨租户覆盖",
"desc": "partner.go/product_attr.go/warehouse.go 等多处用 db.Save(&object) 更新全字段,若中间件被绕过会造成 shop_id 被覆盖。改为 db.Model(&x).Where(\"id=? AND shop_id=?\").Updates(fields) 白名单模式",
"level": "high",
"tier": 2,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T15:31:15.102Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 31,
"title": "添加库存与财务流水定期对账检查,防止事务中断导致不平账",
"desc": "出库审批若 DB 断连,Rollback 后库存正确但财务记录可能未落地。需添加对账脚本:SUM(InventoryLog.quantity by direction) == SUM(Inventories.quantity),及告警机制",
"level": "high",
"tier": 2,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T15:31:16.854Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 32,
"title": "迁移 License 激活逻辑到 license_devices 表,废弃 licenses.device_id 字段",
"desc": "licenses.device_id 已标记 deprecated 但激活/解绑逻辑仍在使用它,license_devices 表未被完整采用。需将 Activate/Deactivate 逻辑迁移至 license_devices,并写数据迁移脚本",
"level": "mid",
"tier": 2,
"tags": [
"后端",
"数据库"
],
"status": "done",
"created_at": "2026-06-10T15:31:25.715Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 33,
"title": "将 checkInventory 业务逻辑从 StockOutHandler 移到 Service 层",
"desc": "checkInventory 实现在 handler/stock_out.go 中而非 service 层,导致单元测试困难、逻辑无法复用。应移至 StockService 或 InventoryService",
"level": "mid",
"tier": 2,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T15:31:28.106Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 34,
"title": "统一后端 API 错误响应格式为 {code, message},创建 util/response.go",
"desc": "各 handler 返回格式不一致:有的 {error: err.Error()},有的 {error: 硬编码字符串},有的直接 struct。前端解析困难。需定义 ErrorResponse struct 和 RespondError/RespondSuccess 辅助函数统一调用",
"level": "mid",
"tier": 2,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T15:31:33.596Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 35,
"title": "生产环境 CORS 强制校验:非 debug 模式禁止 Origin=*",
"desc": "config.go 默认 CORSOrigin=\"*\",注释提示生产需改但无代码强制。应在 main.go 中添加:server.mode!=debug 且 CORSOrigin==\"*\" 时 log.Fatal 拒绝启动",
"level": "mid",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T15:31:44.159Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 36,
"title": "License 私钥未配置时改为 Fatal 而非静默跳过",
"desc": "license.go 中私钥未配置只打 log 并跳过,导致授权功能失效但程序正常运行。应改为 log.Fatal,确保运维人员知晓配置缺失",
"level": "mid",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T15:31:46.792Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 37,
"title": "统一各 handler 的 pageSize 上限校验,创建 util.ValidatePageSize()",
"desc": "inventory.go 限制 pageSize 最大 500,其他 handler 无此限制,前端可传 pageSize=10000 打满 DB。创建 util.ValidatePageSize(n) 统一处理",
"level": "mid",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T15:31:49.032Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 38,
"title": "将拼音回填从启动逻辑改为一次性迁移脚本",
"desc": "main.go 每次启动执行 backfillPinyin 全表扫描,数据量大后拖慢启动。改为 go run ./cmd/migrate-pinyin 一次性迁移,后续新数据靠写入时自动生成",
"level": "low",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T15:31:57.979Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 39,
"title": "DB 连接池参数(MaxIdleConns/MaxOpenConns)改为可配置项",
"desc": "main.go 中硬编码 MaxIdleConns=10, MaxOpenConns=100,无法运维调优。改为读取 config(viper),并在文档中说明推荐值",
"level": "low",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T15:32:00.233Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 40,
"title": "明确 Inventories 反范式字段语义:是当前值还是入库快照",
"desc": "inventories 表存了 product_name/warehouse_name/supplier_name 等,既可能是冗余(与 products JOIN 重复),也可能是历史快照(入库时锁定)。需明确语义并在 model 注释中说明;若是快照,应只在写入时固定,不随主表变化",
"level": "low",
"tier": 2,
"tags": [
"后端",
"数据库"
],
"status": "done",
"created_at": "2026-06-10T15:32:03.641Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 41,
"title": "CheckInventoryAvailability Scan错误未检查导致所有商品误报库存不足",
"desc": "service/stock.go:312 DB查询失败时sums为空,所有商品have=0,合法出库审核被全部拒绝",
"level": "high",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:34.919Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 42,
"title": "finance ListRecords 和 inventory Logs handler 缺 pageSize 上限",
"desc": "finance.go:36 和 inventory.go:130 漏加 ValidatePageSize#37 未覆盖这两个 handler,可被任意用户触发全表扫描",
"level": "mid",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:46.152Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 43,
"title": "ReconcileInventory 两条 Scan 错误未检查导致对账误判",
"desc": "admin.go:122-123 invQuery/logQuery Scan错误被丢弃;invQuery失败→差异消失显示全部正常;logQuery失败→所有库存飘红为差异",
"level": "mid",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:46.205Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 44,
"title": "license Info 端点 device_count 统计错误且 ListDevices 错误被吞",
"desc": "handler/license.go:67-70 两个bug1.devs,_ 错误被_吞掉DB故障时deviceCount=02.ListDevices(shopID)包含该店所有license设备而非当前license",
"level": "mid",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:46.257Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 45,
"title": "license Activate 幂等路径 Update 错误未检查",
"desc": "service/license.go:70 s.db.Model(&existing).Update()失败时仍返回nil error,设备名未更新但调用方不知道",
"level": "low",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:59.016Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 46,
"title": "license Verify 加载 license 缺 shop_id 过滤(防御纵深)",
"desc": "service/license.go:102 WHERE id=? AND is_active=1 应加 AND shop_id=? 防御数据异常时跨租户泄露",
"level": "low",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:59.066Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 47,
"title": "util/response.go 的 RespondError 等辅助函数从未被调用(#34 实际未完成)",
"desc": "#34 只创建了util/response.go,没有将任何handler迁移过去,实际错误格式仍不统一,需完成迁移",
"level": "low",
"tier": 2,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:59.118Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 48,
"title": "清理 ErrDeviceMismatch 死代码(多设备迁移后残留)",
"desc": "service/license.go:24 ErrDeviceMismatch定义后从未返回,多设备迁移(#32)后已无单设备约束",
"level": "low",
"tier": 3,
"tags": [
"后端"
],
"status": "done",
"created_at": "2026-06-10T23:59:59.168Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 49,
"title": "修复 act_runner→Forgejo 任务状态上报丢失导致构建成功却标失败",
"desc": "v1.0.27~v1.0.32 连续 6 个版本 CI 全部'失败',但日志显示所有 job 实际构建成功(🏁 Job succeeded),是 act_runner 最终状态上报丢失(Failed to write EOT),被 Forgejo 僵尸任务清理标为 failure。生产环境因此停留在 v1.0.266/8)。需排查 relay/GOAWAY race 或升级 act_runner/Forgejo。",
"level": "high",
"tier": 2,
"tags": [
"CI/CD"
],
"status": "done",
"created_at": "2026-06-11T13:55:07.388Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 50,
"title": "License model 的 license_key 索引超 InnoDB 3072 字节限制需改用前缀索引",
"desc": "license.go 的 LicenseKey 是 size:2048+uniqueIndexutf8mb4 下 8192 字节超 MySQL InnoDB 索引上限,全新环境 AutoMigrate 必失败(线上已手动建 license_key(768) 前缀索引绕过)。需在 model 层固化:缩短列或 GORM 指定索引前缀长度,并同步 schema.sql。",
"level": "mid",
"tier": 3,
"tags": [
"后端",
"数据库"
],
"status": "done",
"created_at": "2026-06-11T15:52:40.353Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 51,
"title": "iOS 上线 TestFlight 正式分发",
"desc": "路线已定:CI 构建签名 IPA → 上传 TestFlight。前置(用户侧)Apple Developer Program($99/年)+App Store Connect 建 App(com.yanmei.jiu)+三件套凭证(.p12/.mobileprovision/.p8)+7个 Forgejo secrets+TestFlight 公开链接填 version.yaml。代码缺口(我方)compile-ios.sh 需在 flutter build ipa 前向 Release.xcconfig 注入 manual 签名(CODE_SIGN_STYLE/DEVELOPMENT_TEAM/PROVISIONING_PROFILE_SPECIFIER/CODE_SIGN_IDENTITY),否则 archive 阶段因 Automatic 无 team 必失败。详见计划 luminous-hugging-platypus.md。暂不调度。",
"level": "mid",
"tier": 1,
"tags": [
"iOS",
"CI/CD"
],
"status": "open",
"created_at": "2026-06-13T15:22:29.363Z",
"done": false,
"completed_at": null,
"version": null
}
]
}
-1066
View File
File diff suppressed because it is too large Load Diff