Compare commits

...

18 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
wangjia 097e466372 fix(client): 标签打印 2× 超采样 + 阈值 160,消除热敏字体模糊
Deploy / build-linux-web (push) Successful in 51s
Deploy / build-windows (push) Successful in 1m46s
Deploy / build-macos (push) Successful in 1m30s
Deploy / build-android (push) Successful in 1m0s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m54s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 18:20:42 +08:00
wangjia c1ba6c6d97 feat(backend): 二维码链接统一改为 /app/product/ 路径,消除 URL 跳变
Deploy / build-linux-web (push) Successful in 46s
Deploy / build-windows (push) Successful in 1m41s
Deploy / build-macos (push) Successful in 1m27s
Deploy / build-android (push) Successful in 58s
Deploy / build-ios (push) Successful in 6s
Deploy / release-deploy (push) Successful in 1m53s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 17:55:24 +08:00
34 changed files with 874 additions and 4090 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 私有文件
.claude/commands/ .claude/commands/
.claude/worktrees/ .claude/worktrees/
# iOS 证书私钥(绝不入库)
.ios-certs/
+40
View File
@@ -5,6 +5,46 @@ 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/), 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). 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
### 改进
- 标签打印字体更清晰:采用 2× 超采样渲染,消除热敏打印时反锯齿导致的模糊笔划
## [1.0.49] - 2026-06-15
### 改进
- 二维码链接统一使用 `/app/product/` 路径,扫码后 URL 不再跳变,分享与打开同一个链接
## [1.0.48] - 2026-06-15 ## [1.0.48] - 2026-06-15
### 修复 ### 修复
+3 -21
View File
@@ -310,28 +310,10 @@ CI/CDForgejo`.gitea/workflows/deploy.yml`)收到 tag 后自动:编译
### 项目 TODO 管理 ### 项目 TODO 管理
**唯一待办系统**:任务执行过程中如发现需要做的事项,**必须**用 `/todo add <描述>` 记入项目 todo 系统,**禁止**使用 TaskCreate/TodoWrite 等其它 todo 工具,保持单一真相源 **本项目已停用 todo。** 即便全局 `~/.claude/CLAUDE.md` 要求用 `/todo` 记录待办,**本项目一律不记 todo、不调用 `/todo`、不创建 `todo/` 数据**(此项目规则覆盖全局规则)。需要追踪的事项直接在对话里说明即可
- **真相源**`todo/todo.json`(结构化 JSON,含 id/标题/重要度/平台标签/创建时间/完成版本) - **改动等级仍把控**:接口/schema/跨 2+ 模块的「大改」先进 plan 模式经用户批准,完成后同步更新设计文档;小 bugfix 直接做。
- **HTML**`todo/todo.html` 为生成物,每次写操作自动重渲染 - **禁止**使用 TaskCreate/TodoWrite 等内置 todo 工具。
- **重要度**`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 <子命令> [参数]`
--- ---
+124 -23
View File
@@ -496,6 +496,7 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
total int total int
imported int imported int
updated int updated int
skipped int
errors []string errors []string
} }
var res importResult var res importResult
@@ -560,6 +561,7 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
// Dynamic column detection from header row // Dynamic column detection from header row
colProductCode, colProductName, colSeries, colSpec, colUnit := 0, 1, 2, 3, 4 colProductCode, colProductName, colSeries, colSpec, colUnit := 0, 1, 2, 3, 4
colQty, colPrice, colProductionDate, colBatchNo, colWarehouse, colSupplier, colRemark := 5, 6, 8, 9, 11, 13, 15 colQty, colPrice, colProductionDate, colBatchNo, colWarehouse, colSupplier, colRemark := 5, 6, 8, 9, 11, 13, 15
colStockInDate := 12
if len(rows) > 0 { if len(rows) > 0 {
log.Printf("[import-inv] header row (%d cols): %v", len(rows[0]), rows[0]) log.Printf("[import-inv] header row (%d cols): %v", len(rows[0]), rows[0])
for j, h := range rows[0] { for j, h := range rows[0] {
@@ -580,8 +582,10 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
colPrice = j colPrice = j
case "生产日期", "生产年月": case "生产日期", "生产年月":
colProductionDate = j colProductionDate = j
case "批次", "批次号": case "批次", "批次号", "批次/编号/物流码":
colBatchNo = j colBatchNo = j
case "入库日期", "入库时间", "入库日":
colStockInDate = j
case "所在仓库", "仓库", "库位": case "所在仓库", "仓库", "库位":
colWarehouse = j colWarehouse = j
case "供应商", "供应商名称": case "供应商", "供应商名称":
@@ -620,6 +624,7 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
warehouseName := cell(row, colWarehouse) warehouseName := cell(row, colWarehouse)
supplierName := cell(row, colSupplier) supplierName := cell(row, colSupplier)
remark := cell(row, colRemark) remark := cell(row, colRemark)
stockInDateStr := cell(row, colStockInDate)
qty, _ := strconv.ParseFloat(qtyStr, 64) qty, _ := strconv.ParseFloat(qtyStr, 64)
if qty <= 0 { if qty <= 0 {
@@ -662,6 +667,10 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
productionDate = &d productionDate = &d
} }
// 解析入库日期:用 Excel 里的值作为库存记录的入库时间(CreatedAt)
// 解析失败或为空时回退到当前时间。
stockInTime, _ := parseTimeWithFallback(stockInDateStr, time.Now())
whIDPtr := findWarehouse(warehouseName) whIDPtr := findWarehouse(warehouseName)
var unitPricePtr *float64 var unitPricePtr *float64
@@ -677,35 +686,93 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
existing := lookupInv(productCode, prod.Name, prod.Series, prod.Spec, whIDVal) existing := lookupInv(productCode, prod.Name, prod.Series, prod.Spec, whIDVal)
if existing != nil { if existing != nil {
updates := map[string]interface{}{ // 去重/增量更新:逐字段对比 Excel 行与库内现有记录,
"quantity": qty, // 仅把「不一致」的字段放进 updates;全部一致则跳过不写库。
"product_name": prod.Name, updates := map[string]interface{}{}
"series": prod.Series,
"spec": prod.Spec, if existing.Quantity != qty {
"unit": prod.Unit, updates["quantity"] = qty
"warehouse_name": warehouseName,
"supplier_name": supplierName,
"remark": remark,
"deleted_at": nil,
} }
if unitPricePtr != nil { if existing.ProductName != prod.Name {
updates["unit_price"] = *unitPricePtr updates["product_name"] = prod.Name
} }
if productionDate != nil { if existing.Series != prod.Series {
updates["production_date"] = productionDate 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 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 { if err := h.db.Model(existing).Updates(updates).Error; err != nil {
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存更新失败: %s", i+2, err.Error())) res.errors = append(res.errors, fmt.Sprintf("行%d: 库存更新失败: %s", i+2, err.Error()))
continue continue
} }
logsToCreate = append(logsToCreate, model.InventoryLog{ // 仅在库存数量变化时才记一笔流水(用更新前的数量作为 QtyBefore)
ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID, if _, ok := updates["quantity"]; ok {
Direction: "in", Quantity: qty, QtyBefore: existing.Quantity, QtyAfter: qty, logsToCreate = append(logsToCreate, model.InventoryLog{
RefType: "import", RefID: 0, 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++ res.updated++
} else { } else {
productIDCopy := prod.ID productIDCopy := prod.ID
@@ -726,6 +793,7 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
BatchNo: batchNo, BatchNo: batchNo,
SupplierName: supplierName, SupplierName: supplierName,
Remark: remark, Remark: remark,
CreatedAt: stockInTime, // 用 Excel「入库日期」作为入库时间
} }
if err := h.db.Create(&inv).Error; err != nil { if err := h.db.Create(&inv).Error; err != nil {
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存写入失败: %s", i+2, err.Error())) 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)) fmt.Sprintf("未解析到任何有效行,可能列格式不匹配。识别到的表头:%s", detectedHeader))
} }
log.Printf("[import-inv] RESULT: total=%d imported=%d updated=%d errors=%d", log.Printf("[import-inv] RESULT: total=%d imported=%d updated=%d skipped=%d errors=%d",
res.total, res.imported, res.updated, len(res.errors)) res.total, res.imported, res.updated, res.skipped, len(res.errors))
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"total": res.total, "total": res.total,
"imported": res.imported, "imported": res.imported,
"updated": res.updated, "updated": res.updated,
"skipped": res.skipped,
"errors": res.errors, "errors": res.errors,
}) })
} }
@@ -922,6 +991,38 @@ func parseDate(s string) model.Date {
return model.Date{Time: t} 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 { func parsePartnerType(raw string) string {
hasCust := strings.Contains(raw, "客户") hasCust := strings.Contains(raw, "客户")
hasSupp := strings.Contains(raw, "供应商") hasSupp := strings.Contains(raw, "供应商")
+1 -1
View File
@@ -198,7 +198,7 @@ func (h *ProductHandler) QRCode(c *gin.Context) {
h.db.Model(&product).Update("public_id", product.PublicID) h.db.Model(&product).Update("public_id", product.PublicID)
} }
url := config.C.Storage.PublicURL + "/product/" + product.PublicID url := config.C.Storage.PublicURL + "/app/product/" + product.PublicID
if product.Code != "" { if product.Code != "" {
url += "?code=" + product.Code url += "?code=" + product.Code
} }
+1 -1
View File
@@ -369,7 +369,7 @@ func buildProductOG(product model.Product, shopName, publicURL string) string {
} }
// og:url // og:url
pageURL := publicURL + "/product/" + product.PublicID pageURL := publicURL + "/app/product/" + product.PublicID
var sb strings.Builder var sb strings.Builder
sb.WriteString("\n") sb.WriteString("\n")
@@ -697,6 +697,10 @@
); );
PRODUCT_BUNDLE_IDENTIFIER = com.yanmei.jiu; PRODUCT_BUNDLE_IDENTIFIER = com.yanmei.jiu;
PRODUCT_NAME = "$(TARGET_NAME)"; 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_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic"; 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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>用于拍摄商品照片</string> <string>用于拍摄商品照片</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>用于从相册选择商品照片</string> <string>用于从相册选择商品照片</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>本应用不主动获取您的位置信息(部分系统组件依赖此声明)</string>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
+16 -6
View File
@@ -5,13 +5,20 @@ import '../auth/auth_state.dart';
import '../config/app_config.dart'; import '../config/app_config.dart';
import '../errors/error_reporter.dart'; import '../errors/error_reporter.dart';
import '../../providers/connectivity_provider.dart'; import '../../providers/connectivity_provider.dart';
import 'retry_interceptor.dart';
/// Public Dio instance for unauthenticated calls (login / refresh) /// Public Dio instance for unauthenticated calls (login / refresh)
final _publicDio = Dio(BaseOptions( final _publicDio = _buildPublicDio();
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 5), Dio _buildPublicDio() {
receiveTimeout: const Duration(seconds: 15), 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) { final apiClientProvider = Provider<ApiClient>((ref) {
// 只监听登录/登出,不监听 token 内容变化。 // 只监听登录/登出,不监听 token 内容变化。
@@ -52,13 +59,16 @@ class ApiClient {
}) { }) {
_dio = Dio(BaseOptions( _dio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl, baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 5), connectTimeout: const Duration(seconds: 8),
receiveTimeout: const Duration(seconds: 15), receiveTimeout: const Duration(seconds: 15),
headers: { headers: {
if (token != null) 'Authorization': 'Bearer $token', if (token != null) 'Authorization': 'Bearer $token',
}, },
)); ));
// 网络层错误自动重试(须在错误处理拦截器之前,先重试再走 401/上报逻辑)
_dio.interceptors.add(RetryInterceptor(_dio));
// 网络错误 + 401 拦截器 // 网络错误 + 401 拦截器
_dio.interceptors.add( _dio.interceptors.add(
InterceptorsWrapper( 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>( final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>(
(ref) => AuthNotifier(), (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';
});
+126 -31
View File
@@ -3,6 +3,7 @@ import 'dart:convert' show ascii;
import 'dart:typed_data'; import 'dart:typed_data';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'dart:ffi'; import 'dart:ffi';
import 'package:charset_converter/charset_converter.dart';
import 'package:ffi/ffi.dart'; import 'package:ffi/ffi.dart';
import 'package:win32/win32.dart' import 'package:win32/win32.dart'
show show
@@ -13,7 +14,8 @@ import 'package:win32/win32.dart'
EndPagePrinter, EndPagePrinter,
EndDocPrinter, EndDocPrinter,
ClosePrinter, ClosePrinter,
DOC_INFO_1; DOC_INFO_1,
WideCharToMultiByte;
import 'package:flutter/foundation.dart' show kIsWeb, debugPrint; import 'package:flutter/foundation.dart' show kIsWeb, debugPrint;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../errors/error_reporter.dart'; import '../errors/error_reporter.dart';
@@ -189,7 +191,8 @@ double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
return size; return size;
} }
/// 用 dart:ui 把一张标签绘制成 320×160 位图(热敏标签实际尺寸 40×20mm @203dpi)。 /// 用 dart:ui 把一张标签绘制成位图(热敏标签实际尺寸 40×20mm @203dpi)。
/// 内部以 2× 超采样(640×320)绘制,转 TSPL 时再降采样,消除反锯齿模糊。
/// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。 /// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。
Future<ui.Image> _renderLabelBitmap({ Future<ui.Image> _renderLabelBitmap({
required String shop, required String shop,
@@ -199,10 +202,13 @@ Future<ui.Image> _renderLabelBitmap({
required String date, required String date,
required Uint8List qrBytes, required Uint8List qrBytes,
}) async { }) async {
const w = 320, h = 160; // 40×20mm @203dpi const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸)
const scale = 2; // 2× 超采样,内部以 640×320 绘制
const sw = w * scale, sh = h * scale;
final recorder = ui.PictureRecorder(); final recorder = ui.PictureRecorder();
final canvas = ui.Canvas( final canvas = ui.Canvas(
recorder, ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble())); recorder, ui.Rect.fromLTWH(0, 0, sw.toDouble(), sh.toDouble()));
canvas.scale(scale.toDouble(), scale.toDouble()); // 坐标系放大,后续坐标不变
canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()), canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()),
ui.Paint()..color = const ui.Color(0xFFFFFFFF)); ui.Paint()..color = const ui.Color(0xFFFFFFFF));
@@ -236,8 +242,8 @@ Future<ui.Image> _renderLabelBitmap({
final codeText = code.isNotEmpty ? '编号:$code' : ''; final codeText = code.isNotEmpty ? '编号:$code' : '';
final rows = <(String, double, bool)>[ final rows = <(String, double, bool)>[
if (shop.isNotEmpty) (shop, kShopSize, true), if (shop.isNotEmpty) (shop, kShopSize, false), // 店名细体,突出商品名
if (name.isNotEmpty) (name, nameSize, true), if (name.isNotEmpty) (name, nameSize, true), // 商品名粗体
if (codeText.isNotEmpty) (codeText, kSmallSize, false), if (codeText.isNotEmpty) (codeText, kSmallSize, false),
if (degSpec.isNotEmpty) (degSpec, kSmallSize, false), if (degSpec.isNotEmpty) (degSpec, kSmallSize, false),
if (date.isNotEmpty) (date, kSmallSize, false), if (date.isNotEmpty) (date, kSmallSize, false),
@@ -258,11 +264,55 @@ Future<ui.Image> _renderLabelBitmap({
yy += r.$2 * kLineH + gap; yy += r.$2 * kLineH + gap;
} }
return recorder.endRecording().toImage(w, h); 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,失败抛异常。 /// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。
/// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。 /// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。
Future<bool> _printFlatLabelThermal({ Future<bool> _printFlatLabelThermal({
@@ -279,34 +329,79 @@ Future<bool> _printFlatLabelThermal({
debugPrint('[label] thermal printer = $printer'); debugPrint('[label] thermal printer = $printer');
if (printer == null) return false; if (printer == null) return false;
const w = 320, h = 160; // QR BITMAP: 124×124 dots, 右侧 x=192, y=8
final img = await _renderLabelBitmap( const qrW = 124, qrH = 124;
shop: shop, name: name, code: code, degSpec: degSpec, date: date, qrBytes: qrBytes); const qrX = 192, qrY = 8;
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba); const qrWBytes = (qrW + 7) >> 3; // 16 bytes/row
final rgba = bd!.buffer.asUint8List();
// 打包 TSPL(SIZE 用整数,避免固件不认小数导致多走纸) // 将后端 256×256 QR PNG 缩放到 124×124 并转 1-bit
const wbytes = (w + 7) >> 3; final codec = await ui.instantiateImageCodec(qrBytes, targetWidth: qrW, targetHeight: qrH);
final out = BytesBuilder(); final qrImg = (await codec.getNextFrame()).image;
out.add(ascii.encode('SIZE 40 mm,20 mm\r\nGAP 2 mm,0 mm\r\nDIRECTION 1\r\n' final qrBd = await qrImg.toByteData(format: ui.ImageByteFormat.rawRgba);
'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n' final qrRgba = qrBd!.buffer.asUint8List();
'BITMAP 0,0,$wbytes,$h,1,'));
for (int yy = 0; yy < h; yy++) { final buf = BytesBuilder();
final row = Uint8List(wbytes)..fillRange(0, wbytes, 0xFF); // 默认白 bit=1 buf.add(ascii.encode(
for (int xx = 0; xx < w; xx++) { 'SIZE 40 mm,20 mm\r\nGAP 2 mm,0 mm\r\nDIRECTION 1\r\n'
final i = (yy * w + xx) * 4; 'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n',
final a = rgba[i + 3]; ));
// 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 final lum = a < 128
? 255.0 ? 255.0
: 0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2]; : 0.299 * qrRgba[i] + 0.587 * qrRgba[i + 1] + 0.114 * qrRgba[i + 2];
if (lum < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7)); // 黑点 -> bit0 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')); buf.add(ascii.encode('\r\n'));
final tspl = out.toBytes();
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); await _sendRaw(printer, tspl);
return true; return true;
} }
+63 -10
View File
@@ -7,34 +7,55 @@ import '../core/config/app_config.dart';
/// 数据 provider 通过 watch 此值实现网络恢复后自动刷新。 /// 数据 provider 通过 watch 此值实现网络恢复后自动刷新。
final networkRecoveryCountProvider = StateProvider<int>((ref) => 0); final networkRecoveryCountProvider = StateProvider<int>((ref) => 0);
/// 是否正在进行一次「带重试的连通性检测」(供 UI 显示「重试中…」转圈)。
final connectivityCheckingProvider = StateProvider<bool>((ref) => false);
final connectivityProvider = final connectivityProvider =
StateNotifierProvider<ConnectivityNotifier, bool>((ref) { StateNotifierProvider<ConnectivityNotifier, bool>((ref) {
return ConnectivityNotifier( return ConnectivityNotifier(
onRecovered: () { onRecovered: () {
ref.read(networkRecoveryCountProvider.notifier).update((s) => s + 1); ref.read(networkRecoveryCountProvider.notifier).update((s) => s + 1);
}, },
onCheckingChanged: (checking) {
ref.read(connectivityCheckingProvider.notifier).state = checking;
},
); );
}); });
class ConnectivityNotifier extends StateNotifier<bool> { class ConnectivityNotifier extends StateNotifier<bool> {
final void Function()? onRecovered; 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) { if (!skipInit) {
_check(); // 启动首检走「带重试」:跨境冷启动 DNS+TLS 握手较慢,单次 4s 易误判离线,
// 退避重试 4s→8s→12s 可显著降低首屏假离线。
_check(withRetry: true);
_startOnlineTimer(); _startOnlineTimer();
} }
} }
Timer? _timer; Timer? _timer;
// 独立轻量 Dio短超时,无拦截器 // 独立轻量 Dio:无拦截器。连接预算用 Future.timeout 逐次控制,
// 故 BaseOptions 超时放宽到 15s 作为兜底。
final _dio = Dio(BaseOptions( final _dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 3), connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 3), receiveTimeout: const Duration(seconds: 15),
)); ));
/// 在线时:每 30 秒检测一次 /// 每次尝试的超时预算,逐次拉长(越来越长)。
static const _budgets = <Duration>[
Duration(seconds: 4),
Duration(seconds: 8),
Duration(seconds: 12),
];
/// 在线时:每 30 秒轻量检测一次(单次,不重试)
void _startOnlineTimer() { void _startOnlineTimer() {
_timer?.cancel(); _timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 30), (_) => _check()); _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()); _timer = Timer.periodic(const Duration(minutes: 1), (_) => _check());
} }
/// 立即触发一次检测(供外部调用,如 API 请求失败 / 启动时) /// 立即触发一次检测(供外部调用,如 API 请求失败 / 启动时)。单次,不重试。
Future<void> forceCheck() => _check(); Future<void> forceCheck() => _check();
Future<void> _check() async { /// 用户手动重试:带退避重试,并对外广播「检测中」状态,返回最终是否在线。
Future<bool> retry() async {
onCheckingChanged?.call(true);
try { 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) { if (!state) {
// 离线 → 在线:切回高频检测,广播恢复事件 // 离线 → 在线:切回高频检测,广播恢复事件
state = true; state = true;
onRecovered?.call(); onRecovered?.call();
_startOnlineTimer(); _startOnlineTimer();
} }
} catch (_) { } else {
if (state) { if (state) {
// 在线 → 离线:切换为低频嗅探 // 在线 → 离线:切换为低频嗅探
state = false; state = false;
_startOfflineTimer(); _startOfflineTimer();
} }
} }
return ok;
} }
@override @override
@@ -11,6 +11,7 @@ import '../../core/theme/app_theme.dart';
import '../../core/storage/login_history.dart'; import '../../core/storage/login_history.dart';
import '../../providers/connectivity_provider.dart'; import '../../providers/connectivity_provider.dart';
import '../../repositories/auth_repository.dart'; import '../../repositories/auth_repository.dart';
import '../../widgets/network_retry_button.dart';
class LoginScreen extends ConsumerStatefulWidget { class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key}); const LoginScreen({super.key});
@@ -426,6 +427,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
fontSize: 13), fontSize: 13),
), ),
), ),
NetworkRetryButton(
foreground: Color(0xFF5D4037)),
], ],
), ),
), ),
@@ -11,6 +11,7 @@ import '../../widgets/page_scaffold.dart';
import '../../providers/connectivity_provider.dart'; import '../../providers/connectivity_provider.dart';
import '../../core/utils/export_util.dart'; import '../../core/utils/export_util.dart';
import '../../repositories/finance_repository.dart'; import '../../repositories/finance_repository.dart';
import '../../widgets/write_guard.dart';
class FinanceScreen extends ConsumerWidget { class FinanceScreen extends ConsumerWidget {
const FinanceScreen({super.key}); const FinanceScreen({super.key});
@@ -160,7 +161,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
MobileCardField('状态', null, valueWidget: _StatusBadge(r.status)), MobileCardField('状态', null, valueWidget: _StatusBadge(r.status)),
if (r.remark?.isNotEmpty == true) MobileCardField('备注', r.remark), if (r.remark?.isNotEmpty == true) MobileCardField('备注', r.remark),
], ],
actions: canClose actions: (canClose && !WriteGuard.isReadonly(ref))
? [ ? [
TextButton( TextButton(
onPressed: () => _closeRecord(r), onPressed: () => _closeRecord(r),
@@ -304,7 +305,9 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)), style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)),
)); ));
case 'actions': 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( return DataCell(TextButton(
onPressed: () => _closeRecord(r), onPressed: () => _closeRecord(r),
child: const Text('结清', child: const Text('结清',
@@ -405,13 +408,14 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
}, },
toolbar: Row( toolbar: Row(
children: [ children: [
if (addLabel != null) if (addLabel != null && !WriteGuard.isReadonly(ref))
ElevatedButton.icon( ElevatedButton.icon(
onPressed: _showAddDialog, onPressed: _showAddDialog,
icon: const Icon(Icons.add, size: 16), icon: const Icon(Icons.add, size: 16),
label: Text(addLabel), label: Text(addLabel),
), ),
if (addLabel != null) const SizedBox(width: 8), if (addLabel != null && !WriteGuard.isReadonly(ref))
const SizedBox(width: 8),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () { onPressed: () {
final tabName = widget.typeFilter.isEmpty final tabName = widget.typeFilter.isEmpty
@@ -9,6 +9,7 @@ import '../../core/responsive/responsive.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../models/inventory.dart'; import '../../models/inventory.dart';
import '../../core/auth/auth_state.dart'; import '../../core/auth/auth_state.dart';
import '../../widgets/write_guard.dart';
import '../../providers/inventory_provider.dart'; import '../../providers/inventory_provider.dart';
import '../../widgets/data_table_card.dart'; import '../../widgets/data_table_card.dart';
import '../../widgets/mobile_list_card.dart'; import '../../widgets/mobile_list_card.dart';
@@ -292,7 +293,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
message: item.remark.isEmpty ? '' : item.remark, message: item.remark.isEmpty ? '' : item.remark,
waitDuration: const Duration(milliseconds: 300), waitDuration: const Duration(milliseconds: 300),
child: GestureDetector( child: GestureDetector(
onTap: () => _editRemark(context, item), onTap: WriteGuard.isReadonly(ref)
? null
: () => _editRemark(context, item),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -358,10 +361,11 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
MobileCardField('供应商', item.supplierName), MobileCardField('供应商', item.supplierName),
], ],
actions: [ actions: [
TextButton( if (!WriteGuard.isReadonly(ref))
onPressed: () => _editRemark(context, item), TextButton(
child: const Text('备注', style: TextStyle(fontSize: 13)), onPressed: () => _editRemark(context, item),
), child: const Text('备注', style: TextStyle(fontSize: 13)),
),
if (item.productId != null) if (item.productId != null)
TextButton( TextButton(
onPressed: () => _printLabel(context, item), onPressed: () => _printLabel(context, item),
@@ -551,12 +555,14 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
ref.read(inventoryListProvider.notifier).setPageSize(s), ref.read(inventoryListProvider.notifier).setPageSize(s),
toolbar: Row( toolbar: Row(
children: [ children: [
OutlinedButton.icon( if (!WriteGuard.isReadonly(ref)) ...[
onPressed: () => context.go('/inventory/check'), OutlinedButton.icon(
icon: const Icon(Icons.fact_check, size: 16), onPressed: () => context.go('/inventory/check'),
label: const Text('发起盘点'), icon: const Icon(Icons.fact_check, size: 16),
), label: const Text('发起盘点'),
const SizedBox(width: 8), ),
const SizedBox(width: 8),
],
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () => exportExcel( onPressed: () => exportExcel(
filename: '库存查询', filename: '库存查询',
@@ -11,6 +11,7 @@ import '../../widgets/data_table_card.dart';
import '../../widgets/mobile_list_card.dart'; import '../../widgets/mobile_list_card.dart';
import '../../widgets/page_scaffold.dart'; import '../../widgets/page_scaffold.dart';
import '../../core/utils/export_util.dart'; import '../../core/utils/export_util.dart';
import '../../widgets/write_guard.dart';
class PartnersScreen extends ConsumerStatefulWidget { class PartnersScreen extends ConsumerStatefulWidget {
const PartnersScreen({super.key}); const PartnersScreen({super.key});
@@ -166,19 +167,21 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
if (p.phone?.isNotEmpty == true) MobileCardField('电话', p.phone), if (p.phone?.isNotEmpty == true) MobileCardField('电话', p.phone),
if (p.address?.isNotEmpty == true) MobileCardField('地址', p.address), if (p.address?.isNotEmpty == true) MobileCardField('地址', p.address),
], ],
actions: [ actions: WriteGuard.isReadonly(ref)
TextButton( ? const []
key: Key('btn_edit_${p.id}'), : [
onPressed: () => onEdit(p), TextButton(
child: const Text('编辑', style: TextStyle(fontSize: 13)), key: Key('btn_edit_${p.id}'),
), onPressed: () => onEdit(p),
TextButton( child: const Text('编辑', style: TextStyle(fontSize: 13)),
key: Key('btn_delete_${p.id}'), ),
onPressed: () => onDelete(p), TextButton(
child: const Text('删除', key: Key('btn_delete_${p.id}'),
style: TextStyle(fontSize: 13, color: AppTheme.danger)), 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(), mobileCards: partners.map(partnerCard).toList(),
toolbar: Row( toolbar: Row(
children: [ children: [
ElevatedButton.icon( if (!WriteGuard.isReadonly(ref)) ...[
onPressed: onAdd, ElevatedButton.icon(
icon: const Icon(Icons.add, size: 16), onPressed: onAdd,
label: Text(isSupplier ? '新建' : '新建'), icon: const Icon(Icons.add, size: 16),
), label: Text(isSupplier ? '新建' : '新建'),
const SizedBox(width: 8), ),
const SizedBox(width: 8),
],
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () => exportExcel( onPressed: () => exportExcel(
filename: isSupplier ? '供应商列表' : '客户列表', filename: isSupplier ? '供应商列表' : '客户列表',
@@ -280,22 +285,24 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
)), )),
DataCell(Row( DataCell(Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: WriteGuard.isReadonly(ref)
TextButton( ? const []
key: Key('btn_edit_${p.id}'), : [
onPressed: () => onEdit(p), TextButton(
child: const Text('编辑', key: Key('btn_edit_${p.id}'),
style: TextStyle(fontSize: 12)), onPressed: () => onEdit(p),
), child: const Text('编辑',
TextButton( style: TextStyle(fontSize: 12)),
key: Key('btn_delete_${p.id}'), ),
onPressed: () => onDelete(p), TextButton(
child: const Text('删除', key: Key('btn_delete_${p.id}'),
style: TextStyle( onPressed: () => onDelete(p),
fontSize: 12, child: const Text('删除',
color: AppTheme.danger)), 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/config/app_config.dart';
import '../../core/utils/print_util.dart'; import '../../core/utils/print_util.dart';
import '../../widgets/label_preview_dialog.dart'; import '../../widgets/label_preview_dialog.dart';
import '../../widgets/write_guard.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../models/product.dart'; import '../../models/product.dart';
import '../../models/product_image.dart'; import '../../models/product_image.dart';
@@ -313,7 +314,10 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
onDelete: () => _deleteImage(img), onDelete: () => _deleteImage(img),
)), )),
if (p.images.length < 5) 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)), fontSize: 14, fontWeight: FontWeight.w600)),
const Spacer(), const Spacer(),
if (_descChanged) if (_descChanged)
_savingDesc WriteGuard(
? const SizedBox( child: _savingDesc
width: 20, ? const SizedBox(
height: 20, width: 20,
child: CircularProgressIndicator(strokeWidth: 2)) height: 20,
: ElevatedButton( child: CircularProgressIndicator(strokeWidth: 2))
onPressed: _saveDesc, : ElevatedButton(
style: ElevatedButton.styleFrom( onPressed: _saveDesc,
padding: const EdgeInsets.symmetric( style: ElevatedButton.styleFrom(
horizontal: 16, vertical: 6), padding: const EdgeInsets.symmetric(
minimumSize: Size.zero, horizontal: 16, vertical: 6),
tapTargetSize: MaterialTapTargetSize.shrinkWrap), minimumSize: Size.zero,
child: const Text('保存', style: TextStyle(fontSize: 13)), tapTargetSize:
), MaterialTapTargetSize.shrinkWrap),
child:
const Text('保存', style: TextStyle(fontSize: 13)),
),
),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -586,7 +594,8 @@ class _ImageThumbnail extends StatelessWidget {
Positioned( Positioned(
top: 4, top: 4,
right: 4, right: 4,
child: GestureDetector( child: WriteGuard(
child: GestureDetector(
onTap: onDelete, onTap: onDelete,
child: Container( child: Container(
width: 20, width: 20,
@@ -599,6 +608,7 @@ class _ImageThumbnail extends StatelessWidget {
const Icon(Icons.close, size: 14, color: Colors.white), 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/mobile_list_card.dart';
import '../../widgets/page_scaffold.dart'; import '../../widgets/page_scaffold.dart';
import '../../core/utils/export_util.dart'; import '../../core/utils/export_util.dart';
import '../../widgets/write_guard.dart';
class ProductsScreen extends ConsumerStatefulWidget { class ProductsScreen extends ConsumerStatefulWidget {
const ProductsScreen({super.key}); const ProductsScreen({super.key});
@@ -356,12 +357,14 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
}) { }) {
return Row( return Row(
children: [ children: [
ElevatedButton.icon( if (!WriteGuard.isReadonly(ref)) ...[
onPressed: onAdd, ElevatedButton.icon(
icon: const Icon(Icons.add, size: 16), onPressed: onAdd,
label: const Text('新建'), icon: const Icon(Icons.add, size: 16),
), label: const Text('新建'),
const SizedBox(width: 8), ),
const SizedBox(width: 8),
],
OutlinedButton.icon( OutlinedButton.icon(
onPressed: onExport, onPressed: onExport,
icon: const Icon(Icons.download, size: 16), icon: const Icon(Icons.download, size: 16),
@@ -416,33 +419,37 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
MobileCardField('单品数量', '$quantity'), MobileCardField('单品数量', '$quantity'),
if (remark?.isNotEmpty == true) MobileCardField('备注', remark), if (remark?.isNotEmpty == true) MobileCardField('备注', remark),
], ],
actions: [ actions: WriteGuard.isReadonly(ref)
TextButton( ? const []
onPressed: onEdit, : [
child: const Text('编辑', style: TextStyle(fontSize: 13)), TextButton(
), onPressed: onEdit,
TextButton( child: const Text('编辑', style: TextStyle(fontSize: 13)),
onPressed: onDelete, ),
child: const Text('删除', TextButton(
style: TextStyle(fontSize: 13, color: AppTheme.danger)), onPressed: onDelete,
), child: const Text('删除',
], style: TextStyle(fontSize: 13, color: AppTheme.danger)),
),
],
); );
} }
Widget _actionButtons({required VoidCallback onEdit, required VoidCallback onDelete}) { Widget _actionButtons({required VoidCallback onEdit, required VoidCallback onDelete}) {
return Row( return WriteGuard(
mainAxisSize: MainAxisSize.min, child: Row(
children: [ mainAxisSize: MainAxisSize.min,
TextButton( children: [
onPressed: onEdit, TextButton(
child: const Text('编辑', style: TextStyle(fontSize: 12)), onPressed: onEdit,
), child: const Text('编辑', style: TextStyle(fontSize: 12)),
TextButton( ),
onPressed: onDelete, TextButton(
child: const Text('删除', style: TextStyle(fontSize: 12, color: AppTheme.danger)), 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/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_state.dart'; import '../../core/auth/auth_state.dart';
import '../../widgets/write_guard.dart';
import '../../core/config/app_config.dart'; import '../../core/config/app_config.dart';
import '../../core/config/app_info.dart'; import '../../core/config/app_info.dart';
import '../../core/responsive/responsive.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: '负责人', label: '负责人',
value: value:
shop.managerName.isNotEmpty ? shop.managerName : ''), 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), padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row( child: Row(
children: [ children: [
ElevatedButton.icon( WriteGuard(
onPressed: () => _showAddUserDialog(context), child: ElevatedButton.icon(
icon: const Icon(Icons.person_add, size: 16), onPressed: () => _showAddUserDialog(context),
label: const Text('新增用户'), icon: const Icon(Icons.person_add, size: 16),
label: const Text('新增用户'),
),
), ),
], ],
), ),
@@ -266,7 +295,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
)), )),
DataCell(Row( DataCell(Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: WriteGuard.isReadonly(ref)
? const []
: [
TextButton( TextButton(
onPressed: () => onPressed: () =>
_showEditUserDialog(context, u), _showEditUserDialog(context, u),
@@ -428,16 +459,18 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
style: const TextStyle(fontSize: 13), style: const TextStyle(fontSize: 13),
), ),
), ),
const SizedBox(width: 8), if (!WriteGuard.isReadonly(ref)) ...[
ElevatedButton( const SizedBox(width: 8),
onPressed: _isActivating ? null : _activateLicense, ElevatedButton(
child: _isActivating onPressed: _isActivating ? null : _activateLicense,
? const SizedBox( child: _isActivating
width: 16, ? const SizedBox(
height: 16, width: 16,
child: CircularProgressIndicator(strokeWidth: 2)) height: 16,
: const Text('激活'), child: CircularProgressIndicator(strokeWidth: 2))
), : const Text('激活'),
),
],
], ],
), ),
], ],
@@ -588,11 +621,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
style: const TextStyle( style: const TextStyle(
fontFamily: 'monospace', fontSize: 12))), fontFamily: 'monospace', fontSize: 12))),
DataCell(Text('${r.currentNo}')), DataCell(Text('${r.currentNo}')),
DataCell(TextButton( DataCell(WriteGuard(
onPressed: () => child: TextButton(
_showNumberRuleDialog(context, r), onPressed: () =>
child: const Text('编辑', _showNumberRuleDialog(context, r),
style: TextStyle(fontSize: 12)))), child: const Text('编辑',
style: TextStyle(fontSize: 12))))),
])) ]))
.toList(), .toList(),
), ),
@@ -771,6 +805,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
// ── 数据导入 Tab ────────────────────────────────────────── // ── 数据导入 Tab ──────────────────────────────────────────
Widget _buildImportTab() { Widget _buildImportTab() {
if (WriteGuard.isReadonly(ref)) {
return const Center(
child: Text('只读账号无导入权限',
style: TextStyle(color: AppTheme.textSecondary)),
);
}
final currentUser = ref.watch(authStateProvider).user; final currentUser = ref.watch(authStateProvider).user;
final isSuperAdmin = currentUser?.role == 'superadmin'; final isSuperAdmin = currentUser?.role == 'superadmin';
return _BatchImportWidget(isSuperAdmin: isSuperAdmin); return _BatchImportWidget(isSuperAdmin: isSuperAdmin);
+53 -9
View File
@@ -6,6 +6,7 @@ import 'package:intl/intl.dart';
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
import '../../core/auth/auth_state.dart'; import '../../core/auth/auth_state.dart';
import '../../widgets/write_guard.dart';
import '../../core/config/app_config.dart'; import '../../core/config/app_config.dart';
import '../../core/responsive/responsive.dart'; import '../../core/responsive/responsive.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
@@ -14,6 +15,7 @@ import '../../providers/shop_provider.dart';
import '../../providers/update_provider.dart'; import '../../providers/update_provider.dart';
import '../../core/update/app_updater.dart'; import '../../core/update/app_updater.dart';
import '../../providers/license_provider.dart'; import '../../providers/license_provider.dart';
import '../../widgets/network_retry_button.dart';
class AppShell extends ConsumerStatefulWidget { class AppShell extends ConsumerStatefulWidget {
final Widget child; final Widget child;
@@ -141,6 +143,19 @@ class _AppShellState extends ConsumerState<AppShell> {
}).toList(), }).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 isOnline = ref.watch(connectivityProvider);
final location = GoRouterState.of(context).matchedLocation; final location = GoRouterState.of(context).matchedLocation;
final isMobile = context.isMobile; final isMobile = context.isMobile;
// iOS/
final topInset = MediaQuery.of(context).padding.top;
final sidebarWidth = _sidebarExpanded ? 200.0 : 56.0; final sidebarWidth = _sidebarExpanded ? 200.0 : 56.0;
final updateNotifier = ref.watch(updateProvider.notifier); final updateNotifier = ref.watch(updateProvider.notifier);
final updateInfo = ref.watch(updateProvider).valueOrNull; final updateInfo = ref.watch(updateProvider).valueOrNull;
@@ -168,11 +185,11 @@ class _AppShellState extends ConsumerState<AppShell> {
drawerEnableOpenDragGesture: !kIsWeb && isMobile, drawerEnableOpenDragGesture: !kIsWeb && isMobile,
body: Column( body: Column(
children: [ children: [
// Top Bar // Top Bar
Container( Container(
height: 56, height: 56 + topInset,
color: AppTheme.primary, color: AppTheme.primary,
padding: const EdgeInsets.symmetric(horizontal: 8), padding: EdgeInsets.only(top: topInset, left: 8, right: 8),
child: Row( child: Row(
children: [ children: [
IconButton( IconButton(
@@ -194,6 +211,30 @@ class _AppShellState extends ConsumerState<AppShell> {
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
_ShopButton(user: user, version: appVersion), _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(), const Spacer(),
if (user != null) ...[ if (user != null) ...[
// / // /
@@ -382,13 +423,16 @@ class _AppShellState extends ConsumerState<AppShell> {
Icon(Icons.wifi_off, Icon(Icons.wifi_off,
size: 16, color: Colors.white), size: 16, color: Colors.white),
SizedBox(width: 8), SizedBox(width: 8),
Text( Expanded(
'网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新', child: Text(
style: TextStyle( '网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新',
color: Colors.white, style: TextStyle(
fontSize: 13, color: Colors.white,
fontWeight: FontWeight.w500), fontSize: 13,
fontWeight: FontWeight.w500),
),
), ),
NetworkRetryButton(),
], ],
), ),
), ),
@@ -22,6 +22,7 @@ import '../../providers/product_provider.dart' show productRepositoryProvider;
import '../../repositories/product_repository.dart'; import '../../repositories/product_repository.dart';
import '../../providers/finance_provider.dart' show financeRepositoryProvider; import '../../providers/finance_provider.dart' show financeRepositoryProvider;
import '../../providers/shop_provider.dart' show shopInfoProvider; import '../../providers/shop_provider.dart' show shopInfoProvider;
import '../../widgets/write_guard.dart';
class StockInListScreen extends ConsumerStatefulWidget { class StockInListScreen extends ConsumerStatefulWidget {
const StockInListScreen({super.key}); const StockInListScreen({super.key});
@@ -280,7 +281,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
ref.read(stockInListProvider.notifier).setPageSize(s), ref.read(stockInListProvider.notifier).setPageSize(s),
toolbar: Row( toolbar: Row(
children: [ children: [
if (showNewButton) if (showNewButton && !WriteGuard.isReadonly(ref))
ElevatedButton.icon( ElevatedButton.icon(
onPressed: () => context.go('/stock-in/new'), onPressed: () => context.go('/stock-in/new'),
icon: const Icon(Icons.add, size: 16), icon: const Icon(Icons.add, size: 16),
@@ -344,6 +345,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
/// ///
List<Widget> _orderActions(BuildContext context, StockInOrder o) { List<Widget> _orderActions(BuildContext context, StockInOrder o) {
final readonly = WriteGuard.isReadonly(ref);
return [ return [
TextButton( TextButton(
onPressed: () => _showDetail(context, o.id), onPressed: () => _showDetail(context, o.id),
@@ -390,13 +392,13 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
child: const Text('打标签', child: const Text('打标签',
style: TextStyle(fontSize: 12, color: AppTheme.primary)), style: TextStyle(fontSize: 12, color: AppTheme.primary)),
), ),
if (o.status == 'approved') if (!readonly && o.status == 'approved')
TextButton( TextButton(
onPressed: () => _confirmSettle(context, o.id, 'stock_in'), onPressed: () => _confirmSettle(context, o.id, 'stock_in'),
child: const Text('结清', child: const Text('结清',
style: TextStyle(fontSize: 12, color: AppTheme.accent)), style: TextStyle(fontSize: 12, color: AppTheme.accent)),
), ),
if (o.status == 'draft') ...[ if (!readonly && o.status == 'draft') ...[
TextButton( TextButton(
onPressed: () => context.go('/stock-in/edit/${o.id}'), onPressed: () => context.go('/stock-in/edit/${o.id}'),
child: const Text('修改', child: const Text('修改',
@@ -413,7 +415,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
style: TextStyle(fontSize: 12, color: AppTheme.primary)), style: TextStyle(fontSize: 12, color: AppTheme.primary)),
), ),
], ],
if (o.status == 'pending') ...[ if (!readonly && o.status == 'pending') ...[
TextButton( TextButton(
key: Key('btn_approve_${o.id}'), key: Key('btn_approve_${o.id}'),
onPressed: () => _confirmApprove(context, o), onPressed: () => _confirmApprove(context, o),
@@ -20,6 +20,7 @@ import '../../providers/inventory_provider.dart';
import '../../providers/tab_state_provider.dart'; import '../../providers/tab_state_provider.dart';
import '../../providers/product_provider.dart'; import '../../providers/product_provider.dart';
import '../../providers/finance_provider.dart' show financeRepositoryProvider; import '../../providers/finance_provider.dart' show financeRepositoryProvider;
import '../../widgets/write_guard.dart';
class StockOutListScreen extends ConsumerStatefulWidget { class StockOutListScreen extends ConsumerStatefulWidget {
const StockOutListScreen({super.key}); const StockOutListScreen({super.key});
@@ -286,7 +287,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
ref.read(stockOutListProvider.notifier).setPageSize(s), ref.read(stockOutListProvider.notifier).setPageSize(s),
toolbar: Row( toolbar: Row(
children: [ children: [
if (showNewButton) if (showNewButton && !WriteGuard.isReadonly(ref))
ElevatedButton.icon( ElevatedButton.icon(
onPressed: () => context.go('/stock-out/new'), onPressed: () => context.go('/stock-out/new'),
icon: const Icon(Icons.add, size: 16), icon: const Icon(Icons.add, size: 16),
@@ -350,6 +351,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
/// ///
List<Widget> _orderActions(BuildContext context, StockOutOrder o) { List<Widget> _orderActions(BuildContext context, StockOutOrder o) {
final readonly = WriteGuard.isReadonly(ref);
return [ return [
TextButton( TextButton(
onPressed: () => _showDetail(context, o.id), onPressed: () => _showDetail(context, o.id),
@@ -366,13 +368,13 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
child: const Text('打印', child: const Text('打印',
style: TextStyle(fontSize: 12, color: AppTheme.primary)), style: TextStyle(fontSize: 12, color: AppTheme.primary)),
), ),
if (o.status == 'approved') if (!readonly && o.status == 'approved')
TextButton( TextButton(
onPressed: () => _confirmSettle(context, o.id, 'stock_out'), onPressed: () => _confirmSettle(context, o.id, 'stock_out'),
child: const Text('结清', child: const Text('结清',
style: TextStyle(fontSize: 12, color: AppTheme.accent)), style: TextStyle(fontSize: 12, color: AppTheme.accent)),
), ),
if (o.status == 'draft') ...[ if (!readonly && o.status == 'draft') ...[
TextButton( TextButton(
onPressed: () => context.go('/stock-out/edit/${o.id}'), onPressed: () => context.go('/stock-out/edit/${o.id}'),
child: const Text('修改', child: const Text('修改',
@@ -389,7 +391,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
style: TextStyle(fontSize: 12, color: AppTheme.primary)), style: TextStyle(fontSize: 12, color: AppTheme.primary)),
), ),
], ],
if (o.status == 'pending') ...[ if (!readonly && o.status == 'pending') ...[
TextButton( TextButton(
key: Key('btn_approve_${o.id}'), key: Key('btn_approve_${o.id}'),
onPressed: () => _confirmApprove(context, o), 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 FlutterMacOS
import Foundation import Foundation
import charset_converter
import file_picker import file_picker
import file_selector_macos import file_selector_macos
import package_info_plus import package_info_plus
@@ -13,6 +14,7 @@ import shared_preferences_foundation
import url_launcher_macos import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
CharsetConverterPlugin.register(with: registry.registrar(forPlugin: "CharsetConverterPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
+12 -4
View File
@@ -57,6 +57,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" 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: clock:
dependency: transitive dependency: transitive
description: description:
@@ -513,10 +521,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: path_provider name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.5" version: "2.1.6"
path_provider_android: path_provider_android:
dependency: transitive dependency: transitive
description: description:
@@ -545,10 +553,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: path_provider_platform_interface name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.3"
path_provider_windows: path_provider_windows:
dependency: transitive dependency: transitive
description: description:
+3 -1
View File
@@ -1,7 +1,7 @@
name: jiu_client name: jiu_client
description: 酒库管理系统 - 酒店仓库管理 description: 酒库管理系统 - 酒店仓库管理
publish_to: 'none' publish_to: 'none'
version: 1.0.3+1 version: 1.0.52+1
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'
@@ -29,6 +29,8 @@ dependencies:
# Windows 热敏机 TSPL 裸发(winspool WritePrinter),仅 Windows 运行时调用 # Windows 热敏机 TSPL 裸发(winspool WritePrinter),仅 Windows 运行时调用
win32: ^5.5.0 win32: ^5.5.0
ffi: ^2.1.3 ffi: ^2.1.3
# GBK 编码(TSPL TEXT 内置中文字体需要),支持 macOS/Windows/Linux
charset_converter: ^2.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -6,11 +6,14 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <charset_converter/charset_converter_plugin.h>
#include <file_selector_windows/file_selector_windows.h> #include <file_selector_windows/file_selector_windows.h>
#include <printing/printing_plugin.h> #include <printing/printing_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h> #include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
CharsetConverterPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("CharsetConverterPlugin"));
FileSelectorWindowsRegisterWithRegistrar( FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows")); registry->GetRegistrarForPlugin("FileSelectorWindows"));
PrintingPluginRegisterWithRegistrar( PrintingPluginRegisterWithRegistrar(
@@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
charset_converter
file_selector_windows file_selector_windows
printing printing
url_launcher_windows 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