Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21700fa64c | |||
| 5cfd1795ec | |||
| f15692bbd5 | |||
| 196901b6d3 | |||
| 53465ed704 | |||
| 5371d40f05 | |||
| a63ee263ca | |||
| e8bdb19195 | |||
| cd672e500f | |||
| 018180de8c | |||
| 3f3aa41121 | |||
| 73dbd65ef5 | |||
| 8749bbae72 | |||
| 146444ccc3 | |||
| 2ca65e8077 | |||
| bf36f7aa3f |
@@ -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 }}"
|
||||
@@ -59,3 +59,6 @@ backend/issue
|
||||
# Claude 私有文件
|
||||
.claude/commands/
|
||||
.claude/worktrees/
|
||||
|
||||
# iOS 证书私钥(绝不入库)
|
||||
.ios-certs/
|
||||
|
||||
@@ -5,6 +5,36 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.54] - 2026-06-17
|
||||
|
||||
### 新功能
|
||||
- 只读账号自动隐藏所有写操作按钮(新增/编辑/删除/审核/提交/结清/导入等),顶栏显示「只读」标识,权限一目了然
|
||||
- 左侧抽屉和系统设置页新增「退出登录」入口,窄屏也能方便退出
|
||||
- 酒行信息新增「微信号」显示
|
||||
|
||||
### 改进
|
||||
- 网络请求失败时自动重试(间隔递增),离线时提供带状态反馈的「重试」按钮
|
||||
- iOS 顶栏避开状态栏,菜单按钮不再被时间/信号遮挡
|
||||
|
||||
### 修复
|
||||
- 库存导入改用 Excel 中的「入库日期」作为入库时间(不再统一记为导入当天);相同数据重复导入时自动跳过,仅更新有变化的字段,避免重复与误覆盖
|
||||
- 修复 iOS 上顶栏被状态栏遮挡导致无法打开菜单/退出登录的问题
|
||||
|
||||
## [1.0.53] - 2026-06-15
|
||||
|
||||
### 修复
|
||||
- 修复 Windows 上点击打印无反应的问题(GBK 编码改为直接调用系统 API,不再依赖第三方插件)
|
||||
|
||||
## [1.0.52] - 2026-06-15
|
||||
|
||||
### 改进
|
||||
- 热敏标签打印改用打印机内置中文点阵字体(TSS24.BF2/TSS16.BF2),文字清晰度大幅提升,彻底解决 203 DPI 下字体模糊问题
|
||||
|
||||
## [1.0.51] - 2026-06-15
|
||||
|
||||
### 改进
|
||||
- 标签打印字体细化:阈值调回 128,店名改细体,商品名保持粗体
|
||||
|
||||
## [1.0.50] - 2026-06-15
|
||||
|
||||
### 改进
|
||||
|
||||
@@ -310,28 +310,10 @@ CI/CD(Forgejo,`.gitea/workflows/deploy.yml`)收到 tag 后自动:编译
|
||||
|
||||
### 项目 TODO 管理
|
||||
|
||||
**唯一待办系统**:任务执行过程中如发现需要做的事项,**必须**用 `/todo add <描述>` 记入项目 todo 系统,**禁止**使用 TaskCreate/TodoWrite 等其它 todo 工具,保持单一真相源。
|
||||
**本项目已停用 todo。** 即便全局 `~/.claude/CLAUDE.md` 要求用 `/todo` 记录待办,**本项目一律不记 todo、不调用 `/todo`、不创建 `todo/` 数据**(此项目规则覆盖全局规则)。需要追踪的事项直接在对话里说明即可。
|
||||
|
||||
- **真相源**:`todo/todo.json`(结构化 JSON,含 id/标题/重要度/平台标签/创建时间/完成版本)
|
||||
- **HTML**:`todo/todo.html` 为生成物,每次写操作自动重渲染
|
||||
- **重要度**:`high`(阻断/紧急)/ `mid`(重要)/ `low`(一般/优化)三级
|
||||
- **改动等级**:`tier 1`(接口/schema/跨 2+ 模块)/ `tier 2`(5+ 文件)/ `tier 3`(小改/bugfix);**一级改动须先进 plan 模式经用户批准,完成后同步更新设计文档**,详见 `/todo`
|
||||
- **状态**:`open`(待开始)→ `doing`(开发中)→ `done`(待验收)→ `accepted`(已验收)
|
||||
- **完成即标记**:开发完成后 `/todo status <id> done`,验收通过后用户运行 `/todo done <id>` 记入版本
|
||||
|
||||
常用命令:
|
||||
```
|
||||
/todo — 查看 summary(按状态分组)
|
||||
/todo add 修复... — 新增待办(Claude 自动推断级别/标签)
|
||||
/todo status <id> doing — 标记开发中(Claude 开始处理时调用)
|
||||
/todo status <id> done — 标记待验收(Claude 完成开发时调用)
|
||||
/todo done <id> — 用户验收通过,自动记入版本号
|
||||
/todo reject <id> <原因> — 用户拒绝验收,退回 open 并升为最高优先级
|
||||
/todo reopen <id> — 重新开启(清空验收/拒绝记录)
|
||||
/todo rm <id> — 删除
|
||||
```
|
||||
|
||||
底层脚本:`node todo/todo.mjs <子命令> [参数]`
|
||||
- **改动等级仍把控**:接口/schema/跨 2+ 模块的「大改」先进 plan 模式经用户批准,完成后同步更新设计文档;小 bugfix 直接做。
|
||||
- 仍**禁止**使用 TaskCreate/TodoWrite 等内置 todo 工具。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -496,6 +496,7 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
||||
total int
|
||||
imported int
|
||||
updated int
|
||||
skipped int
|
||||
errors []string
|
||||
}
|
||||
var res importResult
|
||||
@@ -560,6 +561,7 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
||||
// Dynamic column detection from header row
|
||||
colProductCode, colProductName, colSeries, colSpec, colUnit := 0, 1, 2, 3, 4
|
||||
colQty, colPrice, colProductionDate, colBatchNo, colWarehouse, colSupplier, colRemark := 5, 6, 8, 9, 11, 13, 15
|
||||
colStockInDate := 12
|
||||
if len(rows) > 0 {
|
||||
log.Printf("[import-inv] header row (%d cols): %v", len(rows[0]), rows[0])
|
||||
for j, h := range rows[0] {
|
||||
@@ -580,8 +582,10 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
||||
colPrice = j
|
||||
case "生产日期", "生产年月":
|
||||
colProductionDate = j
|
||||
case "批次", "批次号":
|
||||
case "批次", "批次号", "批次/编号/物流码":
|
||||
colBatchNo = j
|
||||
case "入库日期", "入库时间", "入库日":
|
||||
colStockInDate = j
|
||||
case "所在仓库", "仓库", "库位":
|
||||
colWarehouse = j
|
||||
case "供应商", "供应商名称":
|
||||
@@ -620,6 +624,7 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
||||
warehouseName := cell(row, colWarehouse)
|
||||
supplierName := cell(row, colSupplier)
|
||||
remark := cell(row, colRemark)
|
||||
stockInDateStr := cell(row, colStockInDate)
|
||||
|
||||
qty, _ := strconv.ParseFloat(qtyStr, 64)
|
||||
if qty <= 0 {
|
||||
@@ -662,6 +667,10 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
||||
productionDate = &d
|
||||
}
|
||||
|
||||
// 解析入库日期:用 Excel 里的值作为库存记录的入库时间(CreatedAt),
|
||||
// 解析失败或为空时回退到当前时间。
|
||||
stockInTime, _ := parseTimeWithFallback(stockInDateStr, time.Now())
|
||||
|
||||
whIDPtr := findWarehouse(warehouseName)
|
||||
|
||||
var unitPricePtr *float64
|
||||
@@ -677,35 +686,93 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
||||
existing := lookupInv(productCode, prod.Name, prod.Series, prod.Spec, whIDVal)
|
||||
|
||||
if existing != nil {
|
||||
updates := map[string]interface{}{
|
||||
"quantity": qty,
|
||||
"product_name": prod.Name,
|
||||
"series": prod.Series,
|
||||
"spec": prod.Spec,
|
||||
"unit": prod.Unit,
|
||||
"warehouse_name": warehouseName,
|
||||
"supplier_name": supplierName,
|
||||
"remark": remark,
|
||||
"deleted_at": nil,
|
||||
// 去重/增量更新:逐字段对比 Excel 行与库内现有记录,
|
||||
// 仅把「不一致」的字段放进 updates;全部一致则跳过不写库。
|
||||
updates := map[string]interface{}{}
|
||||
|
||||
if existing.Quantity != qty {
|
||||
updates["quantity"] = qty
|
||||
}
|
||||
if unitPricePtr != nil {
|
||||
updates["unit_price"] = *unitPricePtr
|
||||
if existing.ProductName != prod.Name {
|
||||
updates["product_name"] = prod.Name
|
||||
}
|
||||
if productionDate != nil {
|
||||
updates["production_date"] = productionDate
|
||||
if existing.Series != prod.Series {
|
||||
updates["series"] = prod.Series
|
||||
}
|
||||
if batchNo != "" {
|
||||
if existing.Spec != prod.Spec {
|
||||
updates["spec"] = prod.Spec
|
||||
}
|
||||
if existing.Unit != prod.Unit {
|
||||
updates["unit"] = prod.Unit
|
||||
}
|
||||
if existing.WarehouseName != warehouseName {
|
||||
updates["warehouse_name"] = warehouseName
|
||||
}
|
||||
if existing.SupplierName != supplierName {
|
||||
updates["supplier_name"] = supplierName
|
||||
}
|
||||
if existing.Remark != remark {
|
||||
updates["remark"] = remark
|
||||
}
|
||||
if existing.BatchNo != batchNo {
|
||||
updates["batch_no"] = batchNo
|
||||
}
|
||||
// 单价:仅当 Excel 提供了非零单价且与现有不同才更新
|
||||
if unitPricePtr != nil {
|
||||
if existing.UnitPrice == nil || *existing.UnitPrice != *unitPricePtr {
|
||||
updates["unit_price"] = *unitPricePtr
|
||||
}
|
||||
}
|
||||
// 生产日期:仅当 Excel 提供了值且与现有不同才更新
|
||||
if productionDate != nil {
|
||||
if existing.ProductionDate == nil ||
|
||||
!existing.ProductionDate.Time.Equal(productionDate.Time) {
|
||||
updates["production_date"] = productionDate
|
||||
}
|
||||
}
|
||||
// 入库时间(created_at):与现有不同才更新
|
||||
if !existing.CreatedAt.Equal(stockInTime) {
|
||||
updates["created_at"] = stockInTime
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
// 所有字段都一致,跳过(不写库、不写流水)
|
||||
res.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
qtyBefore := existing.Quantity
|
||||
if err := h.db.Model(existing).Updates(updates).Error; err != nil {
|
||||
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存更新失败: %s", i+2, err.Error()))
|
||||
continue
|
||||
}
|
||||
logsToCreate = append(logsToCreate, model.InventoryLog{
|
||||
ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID,
|
||||
Direction: "in", Quantity: qty, QtyBefore: existing.Quantity, QtyAfter: qty,
|
||||
RefType: "import", RefID: 0,
|
||||
})
|
||||
// 仅在库存数量变化时才记一笔流水(用更新前的数量作为 QtyBefore)
|
||||
if _, ok := updates["quantity"]; ok {
|
||||
logsToCreate = append(logsToCreate, model.InventoryLog{
|
||||
ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID,
|
||||
Direction: "in", Quantity: qty, QtyBefore: qtyBefore, QtyAfter: qty,
|
||||
RefType: "import", RefID: 0,
|
||||
})
|
||||
}
|
||||
// 同步缓存,避免同文件后续行重复比对到旧值
|
||||
existing.Quantity = qty
|
||||
existing.ProductName = prod.Name
|
||||
existing.Series = prod.Series
|
||||
existing.Spec = prod.Spec
|
||||
existing.Unit = prod.Unit
|
||||
existing.WarehouseName = warehouseName
|
||||
existing.SupplierName = supplierName
|
||||
existing.Remark = remark
|
||||
existing.BatchNo = batchNo
|
||||
if _, ok := updates["unit_price"]; ok {
|
||||
existing.UnitPrice = unitPricePtr
|
||||
}
|
||||
if _, ok := updates["production_date"]; ok {
|
||||
existing.ProductionDate = productionDate
|
||||
}
|
||||
if _, ok := updates["created_at"]; ok {
|
||||
existing.CreatedAt = stockInTime
|
||||
}
|
||||
res.updated++
|
||||
} else {
|
||||
productIDCopy := prod.ID
|
||||
@@ -726,6 +793,7 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
||||
BatchNo: batchNo,
|
||||
SupplierName: supplierName,
|
||||
Remark: remark,
|
||||
CreatedAt: stockInTime, // 用 Excel「入库日期」作为入库时间
|
||||
}
|
||||
if err := h.db.Create(&inv).Error; err != nil {
|
||||
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存写入失败: %s", i+2, err.Error()))
|
||||
@@ -756,13 +824,14 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
||||
fmt.Sprintf("未解析到任何有效行,可能列格式不匹配。识别到的表头:%s", detectedHeader))
|
||||
}
|
||||
|
||||
log.Printf("[import-inv] RESULT: total=%d imported=%d updated=%d errors=%d",
|
||||
res.total, res.imported, res.updated, len(res.errors))
|
||||
log.Printf("[import-inv] RESULT: total=%d imported=%d updated=%d skipped=%d errors=%d",
|
||||
res.total, res.imported, res.updated, res.skipped, len(res.errors))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"total": res.total,
|
||||
"imported": res.imported,
|
||||
"updated": res.updated,
|
||||
"skipped": res.skipped,
|
||||
"errors": res.errors,
|
||||
})
|
||||
}
|
||||
@@ -922,6 +991,38 @@ func parseDate(s string) model.Date {
|
||||
return model.Date{Time: t}
|
||||
}
|
||||
|
||||
// parseTimeWithFallback 解析「入库日期」等列的时间值。
|
||||
// 兼容:yyyy-MM-dd、yyyy-MM-dd HH:mm:ss、yyyy/MM/dd、Excel 序列号。
|
||||
// 解析失败或为空时返回 fallback(通常为 time.Now()),ok=false。
|
||||
func parseTimeWithFallback(s string, fallback time.Time) (t time.Time, ok bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return fallback, false
|
||||
}
|
||||
layouts := []string{
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04",
|
||||
"2006-01-02",
|
||||
"2006/01/02 15:04:05",
|
||||
"2006/01/02",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if parsed, err := time.ParseInLocation(layout, s, time.Local); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
// Excel 序列号(自 1899-12-30 起的天数,可带小数表示时间)
|
||||
if serial, err := strconv.ParseFloat(s, 64); err == nil && serial > 0 && serial < 100000 {
|
||||
base := time.Date(1899, 12, 30, 0, 0, 0, 0, time.Local)
|
||||
days := int(serial)
|
||||
frac := serial - float64(days)
|
||||
parsed := base.AddDate(0, 0, days).Add(time.Duration(frac * 24 * float64(time.Hour)))
|
||||
return parsed, true
|
||||
}
|
||||
return fallback, false
|
||||
}
|
||||
|
||||
func parsePartnerType(raw string) string {
|
||||
hasCust := strings.Contains(raw, "客户")
|
||||
hasSupp := strings.Contains(raw, "供应商")
|
||||
|
||||
@@ -697,6 +697,10 @@
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanmei.jiu;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Jiu App Store";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>用于拍摄商品照片</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>用于从相册选择商品照片</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>本应用不主动获取您的位置信息(部分系统组件依赖此声明)</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
|
||||
@@ -5,13 +5,20 @@ import '../auth/auth_state.dart';
|
||||
import '../config/app_config.dart';
|
||||
import '../errors/error_reporter.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
import 'retry_interceptor.dart';
|
||||
|
||||
/// Public Dio instance for unauthenticated calls (login / refresh)
|
||||
final _publicDio = Dio(BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
));
|
||||
final _publicDio = _buildPublicDio();
|
||||
|
||||
Dio _buildPublicDio() {
|
||||
final dio = Dio(BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 8),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
));
|
||||
dio.interceptors.add(RetryInterceptor(dio));
|
||||
return dio;
|
||||
}
|
||||
|
||||
final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
// 只监听登录/登出,不监听 token 内容变化。
|
||||
@@ -52,13 +59,16 @@ class ApiClient {
|
||||
}) {
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
connectTimeout: const Duration(seconds: 8),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
headers: {
|
||||
if (token != null) 'Authorization': 'Bearer $token',
|
||||
},
|
||||
));
|
||||
|
||||
// 网络层错误自动重试(须在错误处理拦截器之前,先重试再走 401/上报逻辑)
|
||||
_dio.interceptors.add(RetryInterceptor(_dio));
|
||||
|
||||
// 网络错误 + 401 拦截器
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// 网络层错误自动重试拦截器。
|
||||
///
|
||||
/// 仅对「网络层」失败(连接超时 / 连接失败 / 读写超时)重试,**不**对带响应的
|
||||
/// HTTP 错误(4xx/5xx 业务错误)重试。重试 3 次,间隔递增(1s → 2s → 4s)。
|
||||
///
|
||||
/// 安全策略(避免重复入库/出库等副作用):
|
||||
/// - GET:幂等,所有网络错误都重试。
|
||||
/// - POST/PUT/PATCH/DELETE:仅在「连接尚未建立」(connectTimeout / connectionError,
|
||||
/// 服务器还没收到请求)时重试;receiveTimeout/sendTimeout 时服务器可能已处理,
|
||||
/// 不重试,避免重复提交。
|
||||
class RetryInterceptor extends Interceptor {
|
||||
final Dio dio;
|
||||
final List<Duration> delays;
|
||||
|
||||
RetryInterceptor(
|
||||
this.dio, {
|
||||
this.delays = const [
|
||||
Duration(seconds: 1),
|
||||
Duration(seconds: 2),
|
||||
Duration(seconds: 4),
|
||||
],
|
||||
});
|
||||
|
||||
static const _attemptKey = 'retry_attempt';
|
||||
|
||||
bool _retriable(DioException e) {
|
||||
final method = (e.requestOptions.method).toUpperCase();
|
||||
final isIdempotent = method == 'GET' || method == 'HEAD';
|
||||
switch (e.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.connectionError:
|
||||
// 连接未建立 → 服务器未收到 → 任何方法都可安全重试
|
||||
return true;
|
||||
case DioExceptionType.receiveTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
// 服务器可能已处理 → 仅幂等方法重试
|
||||
return isIdempotent;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
final attempt = (err.requestOptions.extra[_attemptKey] as int?) ?? 0;
|
||||
|
||||
if (_retriable(err) && attempt < delays.length) {
|
||||
await Future.delayed(delays[attempt]);
|
||||
err.requestOptions.extra[_attemptKey] = attempt + 1;
|
||||
try {
|
||||
final resp = await dio.fetch(err.requestOptions);
|
||||
return handler.resolve(resp);
|
||||
} on DioException catch (e) {
|
||||
return handler.next(e);
|
||||
} catch (_) {
|
||||
return handler.next(err);
|
||||
}
|
||||
}
|
||||
return handler.next(err);
|
||||
}
|
||||
}
|
||||
@@ -126,3 +126,11 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>(
|
||||
(ref) => AuthNotifier(),
|
||||
);
|
||||
|
||||
/// 当前登录用户是否为只读角色(role == 'readonly')。
|
||||
/// 只读用户禁止任何写操作:UI 据此隐藏新增/编辑/删除/审核等按钮,
|
||||
/// 后端亦有 middleware.ReadOnly() 兜底返回 403。
|
||||
final isReadonlyProvider = Provider<bool>((ref) {
|
||||
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
|
||||
return role == 'readonly';
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert' show ascii;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
import 'dart:ffi';
|
||||
import 'package:charset_converter/charset_converter.dart';
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:win32/win32.dart'
|
||||
show
|
||||
@@ -13,7 +14,8 @@ import 'package:win32/win32.dart'
|
||||
EndPagePrinter,
|
||||
EndDocPrinter,
|
||||
ClosePrinter,
|
||||
DOC_INFO_1;
|
||||
DOC_INFO_1,
|
||||
WideCharToMultiByte;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb, debugPrint;
|
||||
import 'package:flutter/services.dart';
|
||||
import '../errors/error_reporter.dart';
|
||||
@@ -240,8 +242,8 @@ Future<ui.Image> _renderLabelBitmap({
|
||||
|
||||
final codeText = code.isNotEmpty ? '编号:$code' : '';
|
||||
final rows = <(String, double, bool)>[
|
||||
if (shop.isNotEmpty) (shop, kShopSize, true),
|
||||
if (name.isNotEmpty) (name, nameSize, true),
|
||||
if (shop.isNotEmpty) (shop, kShopSize, false), // 店名细体,突出商品名
|
||||
if (name.isNotEmpty) (name, nameSize, true), // 商品名粗体
|
||||
if (codeText.isNotEmpty) (codeText, kSmallSize, false),
|
||||
if (degSpec.isNotEmpty) (degSpec, kSmallSize, false),
|
||||
if (date.isNotEmpty) (date, kSmallSize, false),
|
||||
@@ -265,8 +267,52 @@ Future<ui.Image> _renderLabelBitmap({
|
||||
return recorder.endRecording().toImage(sw, sh); // 返回 2× 图(640×320)
|
||||
}
|
||||
|
||||
/// 桌面端把「扁平标签」直接画成单色位图,TSPL BITMAP 裸发到热敏机。
|
||||
/// 版式(无样式):酒行名 / 酒名(长名缩小) / 度数(系列)+规格 / 日期;右侧二维码 + 扫码溯源。
|
||||
/// UTF-8 → GBK 字节:
|
||||
/// Windows 直接调 WideCharToMultiByte(936) FFI,无需插件;
|
||||
/// macOS 走 charset_converter(CoreFoundation 原生支持 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.BF2(GBK 编码,203 DPI 下比 TrueType 位图清晰数倍)。
|
||||
/// QR 仍走 BITMAP(内置 QRCODE 命令扫描不可靠)。
|
||||
/// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。
|
||||
/// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。
|
||||
Future<bool> _printFlatLabelThermal({
|
||||
@@ -283,42 +329,79 @@ Future<bool> _printFlatLabelThermal({
|
||||
debugPrint('[label] thermal printer = $printer');
|
||||
if (printer == null) return false;
|
||||
|
||||
// img 是 2× 超采样图(640×320);TSPL 输出仍是 320×160
|
||||
const w = 320, h = 160;
|
||||
final img = await _renderLabelBitmap(
|
||||
shop: shop, name: name, code: code, degSpec: degSpec, date: date, qrBytes: qrBytes);
|
||||
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||
final rgba = bd!.buffer.asUint8List();
|
||||
final sw = img.width; // 640(2×宽)
|
||||
// QR BITMAP: 124×124 dots, 右侧 x=192, y=8
|
||||
const qrW = 124, qrH = 124;
|
||||
const qrX = 192, qrY = 8;
|
||||
const qrWBytes = (qrW + 7) >> 3; // 16 bytes/row
|
||||
|
||||
// 打包 TSPL(SIZE 用整数,避免固件不认小数导致多走纸)
|
||||
const wbytes = (w + 7) >> 3;
|
||||
final out = BytesBuilder();
|
||||
out.add(ascii.encode('SIZE 40 mm,20 mm\r\nGAP 2 mm,0 mm\r\nDIRECTION 1\r\n'
|
||||
'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n'
|
||||
'BITMAP 0,0,$wbytes,$h,1,'));
|
||||
for (int yy = 0; yy < h; yy++) {
|
||||
final row = Uint8List(wbytes)..fillRange(0, wbytes, 0xFF); // 默认白 bit=1
|
||||
for (int xx = 0; xx < w; xx++) {
|
||||
// 2×2 四像素平均后阈值,消除反锯齿模糊(阈值 160 = 偏灰的边缘算黑)
|
||||
double lum = 0;
|
||||
for (int dy = 0; dy < 2; dy++) {
|
||||
for (int dx = 0; dx < 2; dx++) {
|
||||
final i = ((yy * 2 + dy) * sw + (xx * 2 + dx)) * 4;
|
||||
final a = rgba[i + 3];
|
||||
lum += a < 128
|
||||
? 255.0
|
||||
: 0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2];
|
||||
}
|
||||
}
|
||||
if (lum / 4 < 160) row[xx >> 3] &= ~(0x80 >> (xx & 7)); // 黑点 -> bit0
|
||||
// 将后端 256×256 QR PNG 缩放到 124×124 并转 1-bit
|
||||
final codec = await ui.instantiateImageCodec(qrBytes, targetWidth: qrW, targetHeight: qrH);
|
||||
final qrImg = (await codec.getNextFrame()).image;
|
||||
final qrBd = await qrImg.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||
final qrRgba = qrBd!.buffer.asUint8List();
|
||||
|
||||
final buf = BytesBuilder();
|
||||
buf.add(ascii.encode(
|
||||
'SIZE 40 mm,20 mm\r\nGAP 2 mm,0 mm\r\nDIRECTION 1\r\n'
|
||||
'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n',
|
||||
));
|
||||
|
||||
// QR BITMAP 命令
|
||||
buf.add(ascii.encode('BITMAP $qrX,$qrY,$qrWBytes,$qrH,1,'));
|
||||
for (int yy = 0; yy < qrH; yy++) {
|
||||
final row = Uint8List(qrWBytes)..fillRange(0, qrWBytes, 0xFF); // 默认白
|
||||
for (int xx = 0; xx < qrW; xx++) {
|
||||
final i = (yy * qrW + xx) * 4;
|
||||
final a = qrRgba[i + 3];
|
||||
final lum = a < 128
|
||||
? 255.0
|
||||
: 0.299 * qrRgba[i] + 0.587 * qrRgba[i + 1] + 0.114 * qrRgba[i + 2];
|
||||
if (lum < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7));
|
||||
}
|
||||
out.add(row);
|
||||
buf.add(row);
|
||||
}
|
||||
out.add(ascii.encode('\r\nPRINT 1,1\r\n'));
|
||||
final tspl = out.toBytes();
|
||||
buf.add(ascii.encode('\r\n'));
|
||||
|
||||
debugPrint('[label] flat tspl ${tspl.length}B -> $printer');
|
||||
// "扫码溯源" 居中于 QR 正下方
|
||||
// TSS16 CJK 每字 16 dots; 4字=64; QR 中心 x=192+62=254; 文字起始 x=254-32=222
|
||||
await _addTextCmd(buf, 222, qrY + qrH + 2, 'TSS16.BF2', '扫码溯源');
|
||||
|
||||
// 左侧文字(x=4, 宽度至 x=186)
|
||||
// TSS24.BF2: 24×24 点阵; TSS16.BF2: 16×16 点阵
|
||||
const kFontL = 'TSS16.BF2'; // 小号(编号/度数/日期)
|
||||
const kFontXL = 'TSS24.BF2'; // 大号(商品名,≤7字时使用)
|
||||
final nameFont = name.runes.length <= 7 ? kFontXL : kFontL;
|
||||
final nameH = nameFont == kFontXL ? 24 : 16;
|
||||
final codeText = code.isNotEmpty ? '编号:$code' : '';
|
||||
|
||||
// (文本, 字体, 字符高度点数)
|
||||
final rows = <(String, String, int)>[];
|
||||
if (shop.isNotEmpty) rows.add((shop, kFontL, 16));
|
||||
if (name.isNotEmpty) rows.add((name, nameFont, nameH));
|
||||
if (codeText.isNotEmpty) rows.add((codeText, kFontL, 16));
|
||||
if (degSpec.isNotEmpty) rows.add((degSpec, kFontL, 16));
|
||||
if (date.isNotEmpty) rows.add((date, kFontL, 16));
|
||||
|
||||
// 在 y=8..150 区间均匀分布各行
|
||||
const kTextTop = 8;
|
||||
const kTextBot = 150;
|
||||
const kTextX = 4;
|
||||
final totalH = rows.fold(0, (s, r) => s + r.$3);
|
||||
final n = rows.length;
|
||||
final gapSize = n > 0
|
||||
? ((kTextBot - kTextTop - totalH) / (n + 1)).round().clamp(2, 20)
|
||||
: 8;
|
||||
|
||||
int yPos = kTextTop + gapSize;
|
||||
for (final r in rows) {
|
||||
await _addTextCmd(buf, kTextX, yPos, r.$2, r.$1);
|
||||
yPos += r.$3 + gapSize;
|
||||
}
|
||||
|
||||
buf.add(ascii.encode('PRINT 1,1\r\n'));
|
||||
final tspl = buf.toBytes();
|
||||
|
||||
debugPrint('[label] TEXT+BITMAP tspl ${tspl.length}B -> $printer');
|
||||
await _sendRaw(printer, tspl);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -7,34 +7,55 @@ import '../core/config/app_config.dart';
|
||||
/// 数据 provider 通过 watch 此值实现网络恢复后自动刷新。
|
||||
final networkRecoveryCountProvider = StateProvider<int>((ref) => 0);
|
||||
|
||||
/// 是否正在进行一次「带重试的连通性检测」(供 UI 显示「重试中…」转圈)。
|
||||
final connectivityCheckingProvider = StateProvider<bool>((ref) => false);
|
||||
|
||||
final connectivityProvider =
|
||||
StateNotifierProvider<ConnectivityNotifier, bool>((ref) {
|
||||
return ConnectivityNotifier(
|
||||
onRecovered: () {
|
||||
ref.read(networkRecoveryCountProvider.notifier).update((s) => s + 1);
|
||||
},
|
||||
onCheckingChanged: (checking) {
|
||||
ref.read(connectivityCheckingProvider.notifier).state = checking;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
class ConnectivityNotifier extends StateNotifier<bool> {
|
||||
final void Function()? onRecovered;
|
||||
final void Function(bool checking)? onCheckingChanged;
|
||||
|
||||
ConnectivityNotifier({this.onRecovered, bool skipInit = false}) : super(true) {
|
||||
ConnectivityNotifier({
|
||||
this.onRecovered,
|
||||
this.onCheckingChanged,
|
||||
bool skipInit = false,
|
||||
}) : super(true) {
|
||||
if (!skipInit) {
|
||||
_check();
|
||||
// 启动首检走「带重试」:跨境冷启动 DNS+TLS 握手较慢,单次 4s 易误判离线,
|
||||
// 退避重试 4s→8s→12s 可显著降低首屏假离线。
|
||||
_check(withRetry: true);
|
||||
_startOnlineTimer();
|
||||
}
|
||||
}
|
||||
|
||||
Timer? _timer;
|
||||
|
||||
// 独立轻量 Dio:短超时,无拦截器
|
||||
// 独立轻量 Dio:无拦截器。连接预算用 Future.timeout 逐次控制,
|
||||
// 故 BaseOptions 超时放宽到 15s 作为兜底。
|
||||
final _dio = Dio(BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 3),
|
||||
receiveTimeout: const Duration(seconds: 3),
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
));
|
||||
|
||||
/// 在线时:每 30 秒检测一次
|
||||
/// 每次尝试的超时预算,逐次拉长(越来越长)。
|
||||
static const _budgets = <Duration>[
|
||||
Duration(seconds: 4),
|
||||
Duration(seconds: 8),
|
||||
Duration(seconds: 12),
|
||||
];
|
||||
|
||||
/// 在线时:每 30 秒轻量检测一次(单次,不重试)
|
||||
void _startOnlineTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 30), (_) => _check());
|
||||
@@ -46,25 +67,57 @@ class ConnectivityNotifier extends StateNotifier<bool> {
|
||||
_timer = Timer.periodic(const Duration(minutes: 1), (_) => _check());
|
||||
}
|
||||
|
||||
/// 立即触发一次检测(供外部调用,如 API 请求失败 / 启动时)
|
||||
/// 立即触发一次检测(供外部调用,如 API 请求失败 / 启动时)。单次,不重试。
|
||||
Future<void> forceCheck() => _check();
|
||||
|
||||
Future<void> _check() async {
|
||||
/// 用户手动重试:带退避重试,并对外广播「检测中」状态,返回最终是否在线。
|
||||
Future<bool> retry() async {
|
||||
onCheckingChanged?.call(true);
|
||||
try {
|
||||
await _dio.get(AppConfig.healthUrl);
|
||||
return await _check(withRetry: true);
|
||||
} finally {
|
||||
onCheckingChanged?.call(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 单次 ping /health,[budget] 为本次超时预算。
|
||||
Future<bool> _ping(Duration budget) async {
|
||||
try {
|
||||
await _dio.get(AppConfig.healthUrl).timeout(budget);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 连通性检测。[withRetry]=true 时按 [_budgets] 退避重试,间隔递增。
|
||||
Future<bool> _check({bool withRetry = false}) async {
|
||||
final budgets = withRetry ? _budgets : const [Duration(seconds: 4)];
|
||||
var ok = false;
|
||||
for (var i = 0; i < budgets.length; i++) {
|
||||
ok = await _ping(budgets[i]);
|
||||
if (ok) break;
|
||||
// 最后一次失败后不再等待;中间失败按递增间隔退避(0.6s, 1.2s…)
|
||||
if (i < budgets.length - 1) {
|
||||
await Future.delayed(Duration(milliseconds: 600 * (i + 1)));
|
||||
}
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
if (!state) {
|
||||
// 离线 → 在线:切回高频检测,广播恢复事件
|
||||
state = true;
|
||||
onRecovered?.call();
|
||||
_startOnlineTimer();
|
||||
}
|
||||
} catch (_) {
|
||||
} else {
|
||||
if (state) {
|
||||
// 在线 → 离线:切换为低频嗅探
|
||||
state = false;
|
||||
_startOfflineTimer();
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../core/theme/app_theme.dart';
|
||||
import '../../core/storage/login_history.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
import '../../repositories/auth_repository.dart';
|
||||
import '../../widgets/network_retry_button.dart';
|
||||
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
@@ -426,6 +427,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
fontSize: 13),
|
||||
),
|
||||
),
|
||||
NetworkRetryButton(
|
||||
foreground: Color(0xFF5D4037)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../widgets/page_scaffold.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
import '../../core/utils/export_util.dart';
|
||||
import '../../repositories/finance_repository.dart';
|
||||
import '../../widgets/write_guard.dart';
|
||||
|
||||
class FinanceScreen extends ConsumerWidget {
|
||||
const FinanceScreen({super.key});
|
||||
@@ -160,7 +161,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
MobileCardField('状态', null, valueWidget: _StatusBadge(r.status)),
|
||||
if (r.remark?.isNotEmpty == true) MobileCardField('备注', r.remark),
|
||||
],
|
||||
actions: canClose
|
||||
actions: (canClose && !WriteGuard.isReadonly(ref))
|
||||
? [
|
||||
TextButton(
|
||||
onPressed: () => _closeRecord(r),
|
||||
@@ -304,7 +305,9 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)),
|
||||
));
|
||||
case 'actions':
|
||||
if ((r.type == 'payable' || r.type == 'receivable') && r.status == 'open') {
|
||||
if ((r.type == 'payable' || r.type == 'receivable') &&
|
||||
r.status == 'open' &&
|
||||
!WriteGuard.isReadonly(ref)) {
|
||||
return DataCell(TextButton(
|
||||
onPressed: () => _closeRecord(r),
|
||||
child: const Text('结清',
|
||||
@@ -405,13 +408,14 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
},
|
||||
toolbar: Row(
|
||||
children: [
|
||||
if (addLabel != null)
|
||||
if (addLabel != null && !WriteGuard.isReadonly(ref))
|
||||
ElevatedButton.icon(
|
||||
onPressed: _showAddDialog,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(addLabel),
|
||||
),
|
||||
if (addLabel != null) const SizedBox(width: 8),
|
||||
if (addLabel != null && !WriteGuard.isReadonly(ref))
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
final tabName = widget.typeFilter.isEmpty
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../../core/responsive/responsive.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/inventory.dart';
|
||||
import '../../core/auth/auth_state.dart';
|
||||
import '../../widgets/write_guard.dart';
|
||||
import '../../providers/inventory_provider.dart';
|
||||
import '../../widgets/data_table_card.dart';
|
||||
import '../../widgets/mobile_list_card.dart';
|
||||
@@ -292,7 +293,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
message: item.remark.isEmpty ? '' : item.remark,
|
||||
waitDuration: const Duration(milliseconds: 300),
|
||||
child: GestureDetector(
|
||||
onTap: () => _editRemark(context, item),
|
||||
onTap: WriteGuard.isReadonly(ref)
|
||||
? null
|
||||
: () => _editRemark(context, item),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -358,10 +361,11 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
MobileCardField('供应商', item.supplierName),
|
||||
],
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => _editRemark(context, item),
|
||||
child: const Text('备注', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
if (!WriteGuard.isReadonly(ref))
|
||||
TextButton(
|
||||
onPressed: () => _editRemark(context, item),
|
||||
child: const Text('备注', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
if (item.productId != null)
|
||||
TextButton(
|
||||
onPressed: () => _printLabel(context, item),
|
||||
@@ -551,12 +555,14 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
ref.read(inventoryListProvider.notifier).setPageSize(s),
|
||||
toolbar: Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.go('/inventory/check'),
|
||||
icon: const Icon(Icons.fact_check, size: 16),
|
||||
label: const Text('发起盘点'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (!WriteGuard.isReadonly(ref)) ...[
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.go('/inventory/check'),
|
||||
icon: const Icon(Icons.fact_check, size: 16),
|
||||
label: const Text('发起盘点'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => exportExcel(
|
||||
filename: '库存查询',
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../widgets/data_table_card.dart';
|
||||
import '../../widgets/mobile_list_card.dart';
|
||||
import '../../widgets/page_scaffold.dart';
|
||||
import '../../core/utils/export_util.dart';
|
||||
import '../../widgets/write_guard.dart';
|
||||
|
||||
class PartnersScreen extends ConsumerStatefulWidget {
|
||||
const PartnersScreen({super.key});
|
||||
@@ -166,19 +167,21 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
|
||||
if (p.phone?.isNotEmpty == true) MobileCardField('电话', p.phone),
|
||||
if (p.address?.isNotEmpty == true) MobileCardField('地址', p.address),
|
||||
],
|
||||
actions: [
|
||||
TextButton(
|
||||
key: Key('btn_edit_${p.id}'),
|
||||
onPressed: () => onEdit(p),
|
||||
child: const Text('编辑', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
TextButton(
|
||||
key: Key('btn_delete_${p.id}'),
|
||||
onPressed: () => onDelete(p),
|
||||
child: const Text('删除',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
|
||||
),
|
||||
],
|
||||
actions: WriteGuard.isReadonly(ref)
|
||||
? const []
|
||||
: [
|
||||
TextButton(
|
||||
key: Key('btn_edit_${p.id}'),
|
||||
onPressed: () => onEdit(p),
|
||||
child: const Text('编辑', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
TextButton(
|
||||
key: Key('btn_delete_${p.id}'),
|
||||
onPressed: () => onDelete(p),
|
||||
child: const Text('删除',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,12 +194,14 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
|
||||
mobileCards: partners.map(partnerCard).toList(),
|
||||
toolbar: Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(isSupplier ? '新建' : '新建'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (!WriteGuard.isReadonly(ref)) ...[
|
||||
ElevatedButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(isSupplier ? '新建' : '新建'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => exportExcel(
|
||||
filename: isSupplier ? '供应商列表' : '客户列表',
|
||||
@@ -280,22 +285,24 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
|
||||
)),
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
key: Key('btn_edit_${p.id}'),
|
||||
onPressed: () => onEdit(p),
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
TextButton(
|
||||
key: Key('btn_delete_${p.id}'),
|
||||
onPressed: () => onDelete(p),
|
||||
child: const Text('删除',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.danger)),
|
||||
),
|
||||
],
|
||||
children: WriteGuard.isReadonly(ref)
|
||||
? const []
|
||||
: [
|
||||
TextButton(
|
||||
key: Key('btn_edit_${p.id}'),
|
||||
onPressed: () => onEdit(p),
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
TextButton(
|
||||
key: Key('btn_delete_${p.id}'),
|
||||
onPressed: () => onDelete(p),
|
||||
child: const Text('删除',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.danger)),
|
||||
),
|
||||
],
|
||||
)),
|
||||
],
|
||||
))
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/utils/print_util.dart';
|
||||
import '../../widgets/label_preview_dialog.dart';
|
||||
import '../../widgets/write_guard.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/product.dart';
|
||||
import '../../models/product_image.dart';
|
||||
@@ -313,7 +314,10 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
|
||||
onDelete: () => _deleteImage(img),
|
||||
)),
|
||||
if (p.images.length < 5)
|
||||
_UploadButton(uploading: _uploading, onTap: _pickAndUpload),
|
||||
WriteGuard(
|
||||
child:
|
||||
_UploadButton(uploading: _uploading, onTap: _pickAndUpload),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -470,20 +474,24 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
|
||||
fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
if (_descChanged)
|
||||
_savingDesc
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: ElevatedButton(
|
||||
onPressed: _saveDesc,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap),
|
||||
child: const Text('保存', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
WriteGuard(
|
||||
child: _savingDesc
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: ElevatedButton(
|
||||
onPressed: _saveDesc,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap),
|
||||
child:
|
||||
const Text('保存', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -586,7 +594,8 @@ class _ImageThumbnail extends StatelessWidget {
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
child: GestureDetector(
|
||||
child: WriteGuard(
|
||||
child: GestureDetector(
|
||||
onTap: onDelete,
|
||||
child: Container(
|
||||
width: 20,
|
||||
@@ -599,6 +608,7 @@ class _ImageThumbnail extends StatelessWidget {
|
||||
const Icon(Icons.close, size: 14, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../../widgets/data_table_card.dart';
|
||||
import '../../widgets/mobile_list_card.dart';
|
||||
import '../../widgets/page_scaffold.dart';
|
||||
import '../../core/utils/export_util.dart';
|
||||
import '../../widgets/write_guard.dart';
|
||||
|
||||
class ProductsScreen extends ConsumerStatefulWidget {
|
||||
const ProductsScreen({super.key});
|
||||
@@ -356,12 +357,14 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新建'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (!WriteGuard.isReadonly(ref)) ...[
|
||||
ElevatedButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新建'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
OutlinedButton.icon(
|
||||
onPressed: onExport,
|
||||
icon: const Icon(Icons.download, size: 16),
|
||||
@@ -416,33 +419,37 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
|
||||
MobileCardField('单品数量', '$quantity'),
|
||||
if (remark?.isNotEmpty == true) MobileCardField('备注', remark),
|
||||
],
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: onEdit,
|
||||
child: const Text('编辑', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onDelete,
|
||||
child: const Text('删除',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
|
||||
),
|
||||
],
|
||||
actions: WriteGuard.isReadonly(ref)
|
||||
? const []
|
||||
: [
|
||||
TextButton(
|
||||
onPressed: onEdit,
|
||||
child: const Text('编辑', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onDelete,
|
||||
child: const Text('删除',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _actionButtons({required VoidCallback onEdit, required VoidCallback onDelete}) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: onEdit,
|
||||
child: const Text('编辑', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onDelete,
|
||||
child: const Text('删除', style: TextStyle(fontSize: 12, color: AppTheme.danger)),
|
||||
),
|
||||
],
|
||||
return WriteGuard(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: onEdit,
|
||||
child: const Text('编辑', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onDelete,
|
||||
child: const Text('删除', style: TextStyle(fontSize: 12, color: AppTheme.danger)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/auth/auth_state.dart';
|
||||
import '../../widgets/write_guard.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/config/app_info.dart';
|
||||
import '../../core/responsive/responsive.dart';
|
||||
@@ -78,6 +79,28 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
// 退出登录(常驻底部;登出后路由 redirect 自动跳 /login)
|
||||
const Divider(height: 1),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
ref.read(authStateProvider.notifier).logout(),
|
||||
icon: const Icon(Icons.logout, size: 18),
|
||||
label: const Text('退出登录'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppTheme.danger,
|
||||
side: const BorderSide(color: AppTheme.danger),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -154,6 +177,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
label: '负责人',
|
||||
value:
|
||||
shop.managerName.isNotEmpty ? shop.managerName : '—'),
|
||||
const Divider(height: 16),
|
||||
_ShopInfoRow(
|
||||
label: '微信号',
|
||||
value: shop.wechatId.isNotEmpty ? shop.wechatId : '—'),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -192,10 +219,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _showAddUserDialog(context),
|
||||
icon: const Icon(Icons.person_add, size: 16),
|
||||
label: const Text('新增用户'),
|
||||
WriteGuard(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _showAddUserDialog(context),
|
||||
icon: const Icon(Icons.person_add, size: 16),
|
||||
label: const Text('新增用户'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -266,7 +295,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
)),
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
children: WriteGuard.isReadonly(ref)
|
||||
? const []
|
||||
: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
_showEditUserDialog(context, u),
|
||||
@@ -428,16 +459,18 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _isActivating ? null : _activateLicense,
|
||||
child: _isActivating
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('激活'),
|
||||
),
|
||||
if (!WriteGuard.isReadonly(ref)) ...[
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _isActivating ? null : _activateLicense,
|
||||
child: _isActivating
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('激活'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -588,11 +621,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace', fontSize: 12))),
|
||||
DataCell(Text('${r.currentNo}')),
|
||||
DataCell(TextButton(
|
||||
onPressed: () =>
|
||||
_showNumberRuleDialog(context, r),
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12)))),
|
||||
DataCell(WriteGuard(
|
||||
child: TextButton(
|
||||
onPressed: () =>
|
||||
_showNumberRuleDialog(context, r),
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12))))),
|
||||
]))
|
||||
.toList(),
|
||||
),
|
||||
@@ -771,6 +805,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
|
||||
// ── 数据导入 Tab ──────────────────────────────────────────
|
||||
Widget _buildImportTab() {
|
||||
if (WriteGuard.isReadonly(ref)) {
|
||||
return const Center(
|
||||
child: Text('只读账号无导入权限',
|
||||
style: TextStyle(color: AppTheme.textSecondary)),
|
||||
);
|
||||
}
|
||||
final currentUser = ref.watch(authStateProvider).user;
|
||||
final isSuperAdmin = currentUser?.role == 'superadmin';
|
||||
return _BatchImportWidget(isSuperAdmin: isSuperAdmin);
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:intl/intl.dart';
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import '../../core/auth/auth_state.dart';
|
||||
import '../../widgets/write_guard.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/responsive/responsive.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
@@ -14,6 +15,7 @@ import '../../providers/shop_provider.dart';
|
||||
import '../../providers/update_provider.dart';
|
||||
import '../../core/update/app_updater.dart';
|
||||
import '../../providers/license_provider.dart';
|
||||
import '../../widgets/network_retry_button.dart';
|
||||
|
||||
class AppShell extends ConsumerStatefulWidget {
|
||||
final Widget child;
|
||||
@@ -141,6 +143,19 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
// 退出登录(抽屉底部常驻,窄屏顶栏菜单不便时也能退出)
|
||||
const Divider(height: 1, color: Colors.white24),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.logout, color: Colors.white70),
|
||||
title: const Text('退出登录',
|
||||
style: TextStyle(
|
||||
color: Colors.white, fontWeight: FontWeight.w500)),
|
||||
onTap: () {
|
||||
Navigator.pop(context); // 关闭抽屉
|
||||
ref.read(authStateProvider.notifier).logout();
|
||||
context.go('/login');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -154,6 +169,8 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
final isOnline = ref.watch(connectivityProvider);
|
||||
final location = GoRouterState.of(context).matchedLocation;
|
||||
final isMobile = context.isMobile;
|
||||
// iOS/刘海屏状态栏高度:顶栏需下移此距离,避免菜单按钮被状态栏遮挡
|
||||
final topInset = MediaQuery.of(context).padding.top;
|
||||
final sidebarWidth = _sidebarExpanded ? 200.0 : 56.0;
|
||||
final updateNotifier = ref.watch(updateProvider.notifier);
|
||||
final updateInfo = ref.watch(updateProvider).valueOrNull;
|
||||
@@ -168,11 +185,11 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
drawerEnableOpenDragGesture: !kIsWeb && isMobile,
|
||||
body: Column(
|
||||
children: [
|
||||
// Top Bar
|
||||
// Top Bar(高度含状态栏内边距,蓝色铺满到屏幕顶,内容下移避开状态栏)
|
||||
Container(
|
||||
height: 56,
|
||||
height: 56 + topInset,
|
||||
color: AppTheme.primary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
padding: EdgeInsets.only(top: topInset, left: 8, right: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
@@ -194,6 +211,30 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_ShopButton(user: user, version: appVersion),
|
||||
if (WriteGuard.isReadonly(ref)) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white24,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.visibility_outlined,
|
||||
size: 13, color: Colors.white),
|
||||
SizedBox(width: 4),
|
||||
Text('只读',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
if (user != null) ...[
|
||||
// 窄屏隐藏门店号/用户名文字块,仅保留用户菜单,避免顶栏拥挤
|
||||
@@ -382,13 +423,16 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
Icon(Icons.wifi_off,
|
||||
size: 16, color: Colors.white),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
NetworkRetryButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -22,6 +22,7 @@ import '../../providers/product_provider.dart' show productRepositoryProvider;
|
||||
import '../../repositories/product_repository.dart';
|
||||
import '../../providers/finance_provider.dart' show financeRepositoryProvider;
|
||||
import '../../providers/shop_provider.dart' show shopInfoProvider;
|
||||
import '../../widgets/write_guard.dart';
|
||||
|
||||
class StockInListScreen extends ConsumerStatefulWidget {
|
||||
const StockInListScreen({super.key});
|
||||
@@ -280,7 +281,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
ref.read(stockInListProvider.notifier).setPageSize(s),
|
||||
toolbar: Row(
|
||||
children: [
|
||||
if (showNewButton)
|
||||
if (showNewButton && !WriteGuard.isReadonly(ref))
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.go('/stock-in/new'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
@@ -344,6 +345,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
|
||||
/// 操作按钮列表,表格与移动端卡片共用。
|
||||
List<Widget> _orderActions(BuildContext context, StockInOrder o) {
|
||||
final readonly = WriteGuard.isReadonly(ref);
|
||||
return [
|
||||
TextButton(
|
||||
onPressed: () => _showDetail(context, o.id),
|
||||
@@ -390,13 +392,13 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
child: const Text('打标签',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
if (o.status == 'approved')
|
||||
if (!readonly && o.status == 'approved')
|
||||
TextButton(
|
||||
onPressed: () => _confirmSettle(context, o.id, 'stock_in'),
|
||||
child: const Text('结清',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.accent)),
|
||||
),
|
||||
if (o.status == 'draft') ...[
|
||||
if (!readonly && o.status == 'draft') ...[
|
||||
TextButton(
|
||||
onPressed: () => context.go('/stock-in/edit/${o.id}'),
|
||||
child: const Text('修改',
|
||||
@@ -413,7 +415,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
],
|
||||
if (o.status == 'pending') ...[
|
||||
if (!readonly && o.status == 'pending') ...[
|
||||
TextButton(
|
||||
key: Key('btn_approve_${o.id}'),
|
||||
onPressed: () => _confirmApprove(context, o),
|
||||
|
||||
@@ -20,6 +20,7 @@ import '../../providers/inventory_provider.dart';
|
||||
import '../../providers/tab_state_provider.dart';
|
||||
import '../../providers/product_provider.dart';
|
||||
import '../../providers/finance_provider.dart' show financeRepositoryProvider;
|
||||
import '../../widgets/write_guard.dart';
|
||||
|
||||
class StockOutListScreen extends ConsumerStatefulWidget {
|
||||
const StockOutListScreen({super.key});
|
||||
@@ -286,7 +287,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
ref.read(stockOutListProvider.notifier).setPageSize(s),
|
||||
toolbar: Row(
|
||||
children: [
|
||||
if (showNewButton)
|
||||
if (showNewButton && !WriteGuard.isReadonly(ref))
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.go('/stock-out/new'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
@@ -350,6 +351,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
|
||||
/// 操作按钮列表,表格与移动端卡片共用。
|
||||
List<Widget> _orderActions(BuildContext context, StockOutOrder o) {
|
||||
final readonly = WriteGuard.isReadonly(ref);
|
||||
return [
|
||||
TextButton(
|
||||
onPressed: () => _showDetail(context, o.id),
|
||||
@@ -366,13 +368,13 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
child: const Text('打印',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
if (o.status == 'approved')
|
||||
if (!readonly && o.status == 'approved')
|
||||
TextButton(
|
||||
onPressed: () => _confirmSettle(context, o.id, 'stock_out'),
|
||||
child: const Text('结清',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.accent)),
|
||||
),
|
||||
if (o.status == 'draft') ...[
|
||||
if (!readonly && o.status == 'draft') ...[
|
||||
TextButton(
|
||||
onPressed: () => context.go('/stock-out/edit/${o.id}'),
|
||||
child: const Text('修改',
|
||||
@@ -389,7 +391,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
],
|
||||
if (o.status == 'pending') ...[
|
||||
if (!readonly && o.status == 'pending') ...[
|
||||
TextButton(
|
||||
key: Key('btn_approve_${o.id}'),
|
||||
onPressed: () => _confirmApprove(context, o),
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/theme/app_theme.dart';
|
||||
import '../providers/connectivity_provider.dart';
|
||||
|
||||
/// 离线提示里的「重试」按钮:
|
||||
/// - 检测中显示转圈 +「重试中…」(过程可感知)
|
||||
/// - 重试结束用 SnackBar 反馈成功/失败(结果可感知)
|
||||
///
|
||||
/// [foreground] 适配不同底色(红色横幅用白字,琥珀框用深色)。
|
||||
class NetworkRetryButton extends ConsumerWidget {
|
||||
final Color foreground;
|
||||
const NetworkRetryButton({super.key, this.foreground = Colors.white});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final checking = ref.watch(connectivityCheckingProvider);
|
||||
|
||||
return TextButton(
|
||||
onPressed: checking
|
||||
? null
|
||||
: () async {
|
||||
final ok =
|
||||
await ref.read(connectivityProvider.notifier).retry();
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(
|
||||
content: Text(ok ? '已重新连接服务器' : '仍无法连接服务器,请稍后重试'),
|
||||
backgroundColor: ok ? AppTheme.success : AppTheme.danger,
|
||||
duration: const Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
));
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: foreground,
|
||||
minimumSize: const Size(0, 32),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (checking)
|
||||
SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(foreground),
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(Icons.refresh, size: 16, color: foreground),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
checking ? '重试中…' : '重试',
|
||||
style: TextStyle(
|
||||
color: foreground, fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/auth/auth_state.dart';
|
||||
|
||||
/// 写操作守卫:当前登录用户为只读角色(role == 'readonly')时,
|
||||
/// 隐藏被包裹的写操作控件(新增/编辑/删除/审核/提交/结清/导入…)。
|
||||
///
|
||||
/// 统一入口,避免在各页面散落 `if (!ref.watch(isReadonlyProvider))` 判断。
|
||||
/// 后端 `middleware.ReadOnly()` 仍会对只读用户的写请求兜底返回 403,
|
||||
/// 本控件只负责「不让按钮出现」,二者配合:UI 不误导 + 后端不可绕过。
|
||||
///
|
||||
/// 用法:
|
||||
/// ```dart
|
||||
/// WriteGuard(child: ElevatedButton(onPressed: _add, child: const Text('新建')))
|
||||
/// ```
|
||||
/// 列表 children 里可用 [hidden] 配合 collection-if 直接剔除分隔符:
|
||||
/// ```dart
|
||||
/// if (!WriteGuard.isReadonly(ref)) ...[button, const SizedBox(width: 8)]
|
||||
/// ```
|
||||
class WriteGuard extends ConsumerWidget {
|
||||
final Widget child;
|
||||
|
||||
/// 只读时显示的占位控件,默认完全隐藏(不占位)。
|
||||
final Widget placeholder;
|
||||
|
||||
const WriteGuard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.placeholder = const SizedBox.shrink(),
|
||||
});
|
||||
|
||||
/// 供需要在 collection-if / 复合条件中判断的场景直接调用。
|
||||
static bool isReadonly(WidgetRef ref) => ref.watch(isReadonlyProvider);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ref.watch(isReadonlyProvider) ? placeholder : child;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import charset_converter
|
||||
import file_picker
|
||||
import file_selector_macos
|
||||
import package_info_plus
|
||||
@@ -13,6 +14,7 @@ import shared_preferences_foundation
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
CharsetConverterPlugin.register(with: registry.registrar(forPlugin: "CharsetConverterPlugin"))
|
||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
|
||||
+12
-4
@@ -57,6 +57,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
charset_converter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: charset_converter
|
||||
sha256: af00b5b73b367101c661b8dfaa52abf3e533de7b3e247e3f65c535ebc4df820a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -513,10 +521,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
version: "2.1.6"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -545,10 +553,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
version: "2.1.3"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+3
-1
@@ -1,7 +1,7 @@
|
||||
name: jiu_client
|
||||
description: 酒库管理系统 - 酒店仓库管理
|
||||
publish_to: 'none'
|
||||
version: 1.0.3+1
|
||||
version: 1.0.52+1
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
@@ -29,6 +29,8 @@ dependencies:
|
||||
# Windows 热敏机 TSPL 裸发(winspool WritePrinter),仅 Windows 运行时调用
|
||||
win32: ^5.5.0
|
||||
ffi: ^2.1.3
|
||||
# GBK 编码(TSPL TEXT 内置中文字体需要),支持 macOS/Windows/Linux
|
||||
charset_converter: ^2.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -6,11 +6,14 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <charset_converter/charset_converter_plugin.h>
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <printing/printing_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
CharsetConverterPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("CharsetConverterPlugin"));
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
PrintingPluginRegisterWithRegistrar(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
charset_converter
|
||||
file_selector_windows
|
||||
printing
|
||||
url_launcher_windows
|
||||
|
||||
-1973
File diff suppressed because it is too large
Load Diff
-821
@@ -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_price;2) 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 CRUD(16 个方法);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 两个bug:1.devs,_ 错误被_吞掉DB故障时deviceCount=0;2.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.26(6/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+uniqueIndex,utf8mb4 下 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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user