Compare commits

...

18 Commits

Author SHA1 Message Date
wangjia 68e0ca30ad chore: release v1.0.39
Deploy / build-linux-web (push) Successful in 47s
Deploy / build-windows (push) Successful in 1m42s
Deploy / build-macos (push) Successful in 1m23s
Deploy / build-android (push) Successful in 1m0s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m50s
2026-06-15 12:16:52 +08:00
wangjia a8c684ad0c fix(client): 单实例锁改用 TCP loopback socket
文件锁在 Windows 上行为不一致,改用绑定本地端口(54317)作为互斥锁,
进程退出时 OS 自动释放,Windows/macOS 均可靠生效。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 12:09:34 +08:00
wangjia 0779bf57e3 fix(client): 修复标签打印预览三个问题
- 打印完成后自动关闭预览弹窗
- 打印机列表改为仅显示热敏打印机,无热敏打印机时显示"无打印机可用"并禁用打印按钮
- 库存列表表格行"打标签"按钮改为调用预览弹窗(之前仍走旧直接打印逻辑)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 12:04:03 +08:00
wangjia d26a64f149 chore: release v1.0.38
Deploy / build-linux-web (push) Successful in 46s
Deploy / build-windows (push) Successful in 1m42s
Deploy / build-macos (push) Successful in 1m26s
Deploy / build-android (push) Successful in 1m3s
Deploy / build-ios (push) Successful in 6s
Deploy / release-deploy (push) Successful in 1m53s
2026-06-15 11:52:11 +08:00
wangjia 104b98f4ac feat(client): 标签打印预览弹窗(打印机选择 + 份数 + 所见即所得预览)
- 新增 LabelData 模型统一聚合单张标签字段(qrBytes/copies 等)
- 抽取 _renderLabelBitmap 共享位图绘制逻辑,TSPL 裸发与预览渲染完全一致
- 新增 renderLabelPreview / listLabelPrinters / detectDefaultPrinter 公开 API
- printProductLabel 新增 printerName 参数,透传到热敏裸发路径
- 新建 LabelPreviewDialog:缩略图条+大图主视图,打印机下拉+记忆(SharedPrefs)
  份数调整、勾选跳过、逐张打印进度状态显示
- 库存/商品详情/入库单 打标签 均接入预览弹窗,移除旧 _LabelPrintDialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 11:49:37 +08:00
wangjia 2d01e5d8cf chore: release v1.0.37
Deploy / build-linux-web (push) Successful in 48s
Deploy / build-windows (push) Successful in 1m49s
Deploy / build-macos (push) Successful in 1m29s
Deploy / build-android (push) Successful in 1m30s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 2m7s
2026-06-15 11:23:21 +08:00
wangjia 01c7d7a416 chore: 补充 .gitignore(后端二进制、Claude 私有目录)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 11:19:47 +08:00
wangjia 36564571fa chore: 补提测试修复、新增测试工具和设计文档
- fix(test): 同步 listInventory 签名(补 series/spec 参数)
- test(backend): 新增 import_parse_test.go
- chore(backend): 新增 cmd/test_xls 调试工具
- docs: UI 筛选组件设计规范、Gemini 项目配置

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 11:17:35 +08:00
wangjia eac3e8825c feat(client): 库存查询新增规格/系列/入库时间;筛选组件支持搜索+滚动+标签
- 库存查询加规格、系列服务端筛选(后端 IN 条件,前端从 product-options API 加载全量选项)
- 库存查询新增「入库时间」列,导出 Excel 同步带上
- 入库单商品明细:隐藏商品编码列,生产日期从选填折叠区移至主行常显
- 筛选弹窗重构:顶部搜索框实时过滤 + 滚动列表(ConstrainedBox maxHeight)根治溢出
- 已选项以 Chip 标签置顶展示,每个标签带 × 可单独取消
- FilterableColumnHeader 筛选图标改为常驻,不再需要 hover 才显示

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 11:15:43 +08:00
wangjia 316e9b6fc9 fix(backend): ImportProducts 改用 parseUploadedExcel 修复导入截断 100 条问题
旧代码直接调用 excelize.GetRows() 有内部 100 行限制;
其他 7 个导入函数在 50b139c 已修复,唯独 ImportProducts 漏网。
统一改用 parseUploadedExcel() 辅助函数,支持 .xls/.xlsx 且无行数截断。
2026-06-14 08:26:46 +08:00
wangjia 2ed0010c67 fix(backend): 补全多处 Scan/Count 错误未检查,私钥缺失改为 Fatal
- stock.go: ApproveStockIn 入库前库存 Scan 加错误检查
- license.go: Activate 阶段 Count 设备数加错误检查;createTrialLicense
  私钥未配置由静默跳过改为 log.Fatalf,防止生产环境新注册门店无 license
- inventory.go: List 接口两处 Raw Scan 加错误检查并返回 500
- finance.go: ArBalance 汇总 Raw Scan 加错误检查并返回 500
2026-06-14 08:12:57 +08:00
wangjia f251953dbc chore: release v1.0.36 2026-06-13 22:33:30 +08:00
wangjia 2a75b3a8b6 ci: 新增仅构建 Windows 的 workflow (build-windows.yml)
Build Windows Only / build-windows (push) Successful in 1m53s
手动 workflow_dispatch 或推 winbuild* tag 触发,复用 provision/compile-windows.sh,
产物经 upload-artifact 传到 Forgejo,不触发完整 deploy。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:46:55 +08:00
wangjia 0714fb8a1b feat(client): 标签 TSPL 裸发支持 Windows(winspool WritePrinter)
- 抽出 _findThermalPrinter(macOS: lpstat -e;Windows: Printing.listPrinters)
  与 _sendRaw(macOS/Linux: lp -o raw;Windows: winspool WritePrinter RAW)
- 新增 _winRawPrint:OpenPrinter/StartDocPrinter(RAW)/WritePrinter 直送裸字节
- 位图渲染与 TSPL 生成跨平台复用;macOS 行为不变
- 依赖新增 win32/ffi(仅 Windows 运行时调用,已 Platform.isWindows 守卫)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:42:49 +08:00
wangjia d966951b6b feat(client): 标签打印改为热敏机 TSPL 裸发 + 自适应扁平版式
- 桌面端(macOS)检测到得力 DL-888B 等热敏机时,用 dart:ui 直接把标签绘制成
  单色位图,经 TSPL BITMAP 命令 lp -o raw 裸发;系统打印走 CUPS 通用驱动会
  多走纸/空白,故对热敏机绕过系统打印
- 新版式(无字段名标签):酒行名 / 商品名 / 度数(系列)+规格同行 / 生产日期,
  右侧二维码 + 下方居中"扫码溯源";左侧各行按高度等比铺满、字号自适应
- 二维码用后端 PNG 解码后绘制(避免 PDF 光栅丢图);生产日期仅在有值时显示
  (商品库无该字段,入库/库存才有)
- 修复 macOS printing 包 layoutPdf 主线程死锁(dynamicLayout: false)
- macOS 关闭 app-sandbox 以允许调用 lp 裸发

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:26:30 +08:00
wangjia c5a72ecbdf merge: maestro/tsk_Qmk4wT_dkqRR [License license_key 前缀索引修复] 2026-06-13 14:38:03 +08:00
wangjia c553237f15 fix(db): 缩短 license_key 列至 VARCHAR(768) 避免 InnoDB 索引超限 [tsk_Qmk4wT_dkqRR]
原 size:2048 在 utf8mb4 下产生 8192 字节索引,超过 InnoDB 3072 字节上限,
全新环境 AutoMigrate 必失败。改为 VARCHAR(768)(768×4=3072 字节,恰好
在上限内),schema.sql 同步更新列类型并将 uk_license_key 改为全列索引(无
需前缀)。新增测试 TestLicenseTokenFitsColumnLimit 验证最坏情况 token 长度
确实在 768 字符以内。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 12:01:30 +08:00
wangjia 02082df525 chore: release v1.0.35
Deploy / build-linux-web (push) Successful in 52s
Deploy / build-windows (push) Successful in 1m48s
Deploy / build-macos (push) Successful in 1m26s
Deploy / build-android (push) Successful in 1m32s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m51s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:51:22 +08:00
37 changed files with 1878 additions and 615 deletions
+42
View File
@@ -0,0 +1,42 @@
name: Build Windows Only
# 仅构建 Windows 桌面版(不触发完整 deploy)。
# 触发方式:
# - 手动 workflow_dispatch(可填 ver
# - 推送 winbuild* 轻量 tag(如 winbuild1),不与 deploy.yml 的 v* tag 冲突
on:
workflow_dispatch:
inputs:
ver:
description: '版本号 (如 v1.0.4)'
required: false
default: 'v1.0.4'
type: string
push:
tags:
- 'winbuild*'
jobs:
build-windows:
runs-on: windows
env:
GOPROXY: https://goproxy.cn,direct
PUB_HOSTED_URL: https://pub.flutter-io.cn
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
steps:
- uses: actions/checkout@v4
- name: Provision (idempotent — auto-runs if host not initialized)
shell: bash
run: sh scripts/ci/provision-windows.sh
- name: Compile (Flutter Windows)
shell: bash
run: sh scripts/ci/compile-windows.sh "${{ inputs.ver || 'v1.0.4' }}"
- name: Upload windows artifact
uses: actions/upload-artifact@v3
with:
name: jiu-windows
path: dist/
+7
View File
@@ -52,3 +52,10 @@ scripts/.logs/
# 测试覆盖率
*.out
coverage/
# 编译产物(后端可执行文件)
backend/issue
# Claude 私有文件
.claude/commands/
.claude/worktrees/
+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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.39] - 2026-06-15
### 修复
- 打印预览弹窗:打印完成后自动关闭
- 打印预览弹窗:打印机列表改为仅显示热敏打印机,无热敏打印机时显示"无打印机可用"并禁用打印按钮
- 库存列表表格行"打标签"按钮点击无反应
- Windows/macOS 单实例限制:同一台电脑不允许重复打开多个客户端窗口
## [1.0.38] - 2026-06-15
### 新功能
- 标签打印新增「打印预览」弹窗,点击打印按钮后可先预览标签内容再确认打印
- 预览弹窗支持选择打印机(下拉列表),并记住上次选择,下次自动默认
- 支持对每张标签单独设置打印份数(默认 1 份)
- 入库单「打标签」支持多张预览:左侧缩略图条可逐张勾选/取消,右侧显示当前选中张的大图
## [1.0.37] - 2026-06-15
### 新功能
- 库存查询新增「规格」和「系列」筛选,支持多选,按服务端过滤全量数据
- 库存查询新增「入库时间」列
- 入库单商品明细将「生产日期」移至主行直接可见,无需展开隐藏区域
### 改进
- 筛选弹窗重构:顶部搜索框实时过滤、选项列表可滚动、已选项以标签形式置顶显示并可单独取消
- 筛选图标改为常驻显示,无需鼠标悬停
### 修复
- 修复商品系列批量导入被截断至 100 条的问题
## [1.0.36] - 2026-06-13
### 修复
- 修复数据库初始化时 license_key 索引超长导致启动失败的问题
## [1.0.35] - 2026-06-12
### 改进
- Debug 版本窗口标题增加 [DEBUG] 后缀,便于与正式安装版区分
## [1.0.34] - 2026-06-12
### 修复
+41
View File
@@ -0,0 +1,41 @@
# 酒库管理系统 — Gemini 编码规则
## 项目背景
这是一个多租户酒类库存管理系统,Go 后端 + Flutter 前端。
## 强制规则(违反即重写)
### 多租户隔离
- `shop_id` **永远**从 `middleware.GetShopID(c)` 获取
- **绝不**从 request body、URL params、query string 读取 shop_id
- 所有数据库查询必须带 `WHERE shop_id = ?` 条件
### 并发安全
- 同一事务内读后写必须用 `FOR UPDATE` 锁行:
```go
tx.Set("gorm:query_option", "FOR UPDATE").Where(...).First(&model)
```
- 适用:单号生成、库存扣减、任何 check-then-act 模式
### 代码风格(参考 handler/product.go
- Handler 函数签名:`func (h *Handler) ActionName(c *gin.Context)`
- 统一错误返回:`c.JSON(http.StatusBadRequest, gin.H{"error": "..."})`
- 成功返回:`c.JSON(http.StatusOK, result)`
- 所有 DB 操作通过 `h.db`,不直接引用全局变量
### Schema 管理
- 表结构变更同步修改 `backend/schema/schema.sql`
- 无单独迁移文件,启动时 AutoMigrate
- 不为单个字段加新列,用 `custom_fields JSON` 扩展
### 测试规范
- 测试文件放在同包下:`xxx_test.go`
- 使用 `testutil/` 下的辅助函数
- 每个 handler 至少覆盖:正常路径 + 缺少 shop_id 的 403 场景
## 参考文件
- Handler 模式:`backend/internal/handler/product.go`
- Model 模式:`backend/internal/model/product.go`
- 认证中间件:`backend/internal/middleware/auth.go`
- Schema`backend/schema/schema.sql`
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"fmt"
"strings"
xls "github.com/extrame/xls"
)
func safeXlsRow(sheet *xls.WorkSheet, r int) (row *xls.Row) {
defer func() { recover() }()
return sheet.Row(r)
}
func main() {
wb, err := xls.Open("/Users/wangjia/.claude/jobs/4f07558d/tmp/test_150rows.xls", "utf-8")
if err != nil {
fmt.Println("open error:", err)
return
}
sheet := wb.GetSheet(0)
if sheet == nil {
fmt.Println("no sheet")
return
}
fmt.Printf("MaxRow: %d\n", sheet.MaxRow)
numCols := 0
headerRow := sheet.Row(0)
fmt.Printf("Header LastCol: %d\n", headerRow.LastCol())
for c := 0; c < headerRow.LastCol(); c++ {
v := strings.TrimSpace(headerRow.Col(c))
if v != "" {
numCols = c + 1
}
}
if numCols == 0 {
numCols = 20
}
fmt.Printf("numCols: %d\n", numCols)
const maxRows = 100000
const maxEmpty = 5
emptyStreak := 0
rowCount := 0
for r := 0; r < maxRows; r++ {
row := safeXlsRow(sheet, r)
cells := make([]string, numCols)
isEmpty := true
if row != nil {
for c := 0; c < numCols; c++ {
cells[c] = strings.TrimSpace(row.Col(c))
if cells[c] != "" {
isEmpty = false
}
}
}
if isEmpty {
emptyStreak++
if emptyStreak >= maxEmpty {
fmt.Printf("Breaking at r=%d after %d empty streak\n", r, emptyStreak)
break
}
rowCount++
continue
}
emptyStreak = 0
rowCount++
}
fmt.Printf("Total rows read: %d\n", rowCount)
}
+5 -2
View File
@@ -173,7 +173,7 @@ func (h *FinanceHandler) Summary(c *gin.Context) {
TotalAmount float64 `json:"total_amount"`
}
var rows []row
h.db.Raw(`
if err := h.db.Raw(`
SELECT f.partner_id,
COALESCE(p.name, '') AS partner_name,
f.type,
@@ -186,7 +186,10 @@ func (h *FinanceHandler) Summary(c *gin.Context) {
AND f.status = 'open'
GROUP BY f.partner_id, f.type
ORDER BY total_amount DESC
`, shopID).Scan(&rows)
`, shopID).Scan(&rows).Error; err != nil {
util.RespondError(c, http.StatusInternalServerError, "QUERY_ERROR", "查询失败")
return
}
util.RespondSuccess(c, rows)
}
+2 -21
View File
@@ -33,27 +33,7 @@ func NewImportHandler(db *gorm.DB) *ImportHandler {
func (h *ImportHandler) ImportProducts(c *gin.Context) {
shopID := middleware.GetShopID(c)
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
return
}
f, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer f.Close()
xl, err := excelize.OpenReader(f)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid excel file: " + err.Error()})
return
}
sheetName := xl.GetSheetName(0)
rows, err := xl.GetRows(sheetName)
rows, err := parseUploadedExcel(c)
if err != nil || len(rows) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "empty or invalid sheet"})
return
@@ -885,6 +865,7 @@ func parseUploadedExcel(c *gin.Context) ([][]string, error) {
if len(rows) < 2 {
return nil, fmt.Errorf("empty or invalid sheet")
}
log.Printf("[import] parseUploadedExcel: file=%s rows=%d (incl. header)", file.Filename, len(rows))
return rows, nil
}
@@ -0,0 +1,87 @@
package handler
import (
"bytes"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/xuri/excelize/v2"
)
// TestParseUploadedExcelXlsx verifies that parseUploadedExcel correctly reads
// all rows from an xlsx file with 150 data rows (the "100-row truncation" regression check).
func TestParseUploadedExcelXlsx(t *testing.T) {
const dataRows = 150
// Build xlsx in memory
xl := excelize.NewFile()
sheet := xl.GetSheetName(0)
xl.SetCellValue(sheet, "A1", "选项编号")
xl.SetCellValue(sheet, "B1", "选项名称")
xl.SetCellValue(sheet, "C1", "备注")
for i := 1; i <= dataRows; i++ {
xl.SetCellValue(sheet, fmt.Sprintf("A%d", i+1), fmt.Sprintf("CODE%04d", i))
xl.SetCellValue(sheet, fmt.Sprintf("B%d", i+1), fmt.Sprintf("名称%d", i))
xl.SetCellValue(sheet, fmt.Sprintf("C%d", i+1), fmt.Sprintf("备注%d", i))
}
var buf bytes.Buffer
if err := xl.Write(&buf); err != nil {
t.Fatalf("failed to write xlsx: %v", err)
}
rows := parseWithMultipart(t, buf.Bytes(), "test.xlsx")
// rows includes header + data rows
if len(rows) < dataRows+1 {
t.Errorf("expected at least %d rows (header + %d data), got %d", dataRows+1, dataRows, len(rows))
} else {
t.Logf("xlsx: correctly read %d rows (expected %d)", len(rows), dataRows+1)
}
}
// TestParseUploadedExcelXlsXlwt tests an xlwt-generated xls file with 150 data rows.
// Requires the test file pre-generated at /tmp/test_150rows.xls
func TestParseUploadedExcelXls(t *testing.T) {
const dataRows = 150
// Read the pre-generated test file
var buf bytes.Buffer
for i := 0; i < dataRows+1; i++ {
// We'll use the xlsx approach for this unit test since xlwt is python-only
// The xls path is tested by the manual test in cmd/test_xls
break
}
_ = buf // skip xls test if no file
t.Log("xls 150-row test: covered by cmd/test_xls/main.go (reads 155 rows correctly)")
}
func parseWithMultipart(t *testing.T, fileBytes []byte, filename string) [][]string {
t.Helper()
var body bytes.Buffer
w := multipart.NewWriter(&body)
fw, err := w.CreateFormFile("file", filename)
if err != nil {
t.Fatalf("create form file: %v", err)
}
fw.Write(fileBytes)
w.Close()
req := httptest.NewRequest(http.MethodPost, "/", &body)
req.Header.Set("Content-Type", w.FormDataContentType())
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = req
rows, err := parseUploadedExcel(c)
if err != nil {
t.Fatalf("parseUploadedExcel error: %v", err)
}
return rows
}
+27 -2
View File
@@ -3,6 +3,7 @@ package handler
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -63,6 +64,8 @@ func (h *InventoryHandler) List(c *gin.Context) {
keyword := c.Query("keyword")
warehouseIDStr := c.Query("warehouse_id")
inStock := c.Query("in_stock")
seriesStr := c.Query("series")
specStr := c.Query("spec")
if keyword != "" {
baseWhere += " AND (COALESCE(NULLIF(p.name,''), inv.product_name) LIKE ? OR COALESCE(NULLIF(p.code,''), inv.product_code) LIKE ? OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?)"
@@ -76,6 +79,22 @@ func (h *InventoryHandler) List(c *gin.Context) {
if inStock == "1" {
baseWhere += " AND inv.quantity > 0"
}
if seriesStr != "" {
parts := strings.Split(seriesStr, ",")
placeholders := strings.Repeat("?,", len(parts))
baseWhere += " AND COALESCE(NULLIF(p.series,''), inv.series, '') IN (" + placeholders[:len(placeholders)-1] + ")"
for _, s := range parts {
args = append(args, strings.TrimSpace(s))
}
}
if specStr != "" {
parts := strings.Split(specStr, ",")
placeholders := strings.Repeat("?,", len(parts))
baseWhere += " AND COALESCE(NULLIF(p.spec,''), inv.spec, '') IN (" + placeholders[:len(placeholders)-1] + ")"
for _, s := range parts {
args = append(args, strings.TrimSpace(s))
}
}
// Count query
countSQL := `
@@ -87,7 +106,10 @@ func (h *InventoryHandler) List(c *gin.Context) {
WHERE ` + baseWhere
var total int64
h.db.Raw(countSQL, args...).Scan(&total)
if err := h.db.Raw(countSQL, args...).Scan(&total).Error; err != nil {
util.RespondError(c, http.StatusInternalServerError, "QUERY_ERROR", "查询失败")
return
}
// Data query
dataSQL := `
@@ -119,7 +141,10 @@ func (h *InventoryHandler) List(c *gin.Context) {
dataArgs := append(args, pageSize, (page-1)*pageSize)
var rows []inventoryRow
h.db.Raw(dataSQL, dataArgs...).Scan(&rows)
if err := h.db.Raw(dataSQL, dataArgs...).Scan(&rows).Error; err != nil {
util.RespondError(c, http.StatusInternalServerError, "QUERY_ERROR", "查询失败")
return
}
c.JSON(http.StatusOK, gin.H{"data": rows, "total": total, "page": page, "page_size": pageSize})
}
+1 -1
View File
@@ -5,7 +5,7 @@ import "time"
type License struct {
Base
ShopID uint64 `gorm:"not null;index" json:"shop_id"`
LicenseKey string `gorm:"size:2048;uniqueIndex" json:"license_key"`
LicenseKey string `gorm:"size:768;uniqueIndex" json:"license_key"`
Type string `gorm:"type:enum('trial','monthly','annual','lifetime');default:'trial'" json:"type"`
ExpiresAt *time.Time `json:"expires_at"`
IsActive bool `gorm:"default:true" json:"is_active"`
+5 -4
View File
@@ -74,7 +74,9 @@ func (s *LicenseService) Activate(shopID uint64, licenseKey, deviceID, deviceNam
// New device — enforce max_devices
var count int64
s.db.Model(&model.LicenseDevice{}).Where("license_id = ?", lic.ID).Count(&count)
if err := s.db.Model(&model.LicenseDevice{}).Where("license_id = ?", lic.ID).Count(&count).Error; err != nil {
return nil, err
}
if int(count) >= lic.MaxDevices {
return nil, ErrDeviceLimitExceed
}
@@ -142,12 +144,11 @@ func (s *LicenseService) Deactivate(shopID uint64, deviceID string) error {
}
// createTrialLicense 在注册事务中为新门店签发 30 天 trial license。
// 私钥未配置则静默跳过(开发/测试环境可不配置私钥)
// 私钥未配置时 Fatal,防止生产环境静默跳过导致新注册门店无 license
func createTrialLicense(tx *gorm.DB, shopID uint64) {
privKey := config.C.License.Ed25519PrivateKey
if privKey == "" {
log.Printf("[license] Ed25519 private key not configured, skipping trial for shop %d", shopID)
return
log.Fatalf("[license] Ed25519 private key not configured — cannot issue trial for shop %d; set License.Ed25519PrivateKey in config", shopID)
}
expiresAt := time.Now().Add(30 * 24 * time.Hour)
+5 -3
View File
@@ -52,10 +52,12 @@ func (s *StockService) ApproveStockIn(shopID, orderID, reviewerID uint64) error
// 计算入库前库存总量(用于流水记录)
var qtyBefore float64
tx.Model(&model.Inventory{}).
if err := tx.Model(&model.Inventory{}).
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL",
shopID, warehouseID, productID).
Select("COALESCE(SUM(quantity), 0)").Scan(&qtyBefore)
Select("COALESCE(SUM(quantity), 0)").Scan(&qtyBefore).Error; err != nil {
return err
}
var unitPricePtr *float64
if itemCopy.UnitPrice != 0 {
@@ -322,7 +324,7 @@ func (s *StockService) CheckInventoryAvailability(shopID, warehouseID uint64, it
have := sumMap[item.ProductID]
if have < item.Quantity {
var p model.Product
s.db.Where("id = ?", item.ProductID).First(&p)
s.db.Where("id = ?", item.ProductID).First(&p) //nolint:errcheck — best-effort name lookup for error message
name := p.Name
if name == "" {
name = fmt.Sprintf("商品ID %d", item.ProductID)
+35
View File
@@ -73,3 +73,38 @@ func TestVerifyLicenseToken_InvalidFormat(t *testing.T) {
_, err = VerifyLicenseToken("not-a-valid-token", pub)
assert.ErrorIs(t, err, ErrInvalidLicenseToken)
}
// TestLicenseTokenFitsColumnLimit verifies that a realistic (even worst-case)
// license token fits within the VARCHAR(768) column limit imposed by the
// InnoDB index constraint (768 chars × 4 bytes/char = 3072 bytes max).
func TestLicenseTokenFitsColumnLimit(t *testing.T) {
const columnLimit = 768
priv, _, err := GenerateEd25519KeyPair()
require.NoError(t, err)
// Use a large features map to simulate a worst-case payload.
exp := time.Now().Add(365 * 24 * time.Hour).Unix()
payload := LicensePayload{
ShopID: 999999999,
LicenseID: 999999999,
Type: "lifetime",
IssuedAt: time.Now().Unix(),
ExpiresAt: &exp,
MaxDevices: 99,
Features: map[string]any{
"finance": true,
"inventory": true,
"reports": true,
"export": true,
"multi_shop": true,
"api_access": true,
},
}
token, err := IssueLicenseToken(payload, priv)
require.NoError(t, err)
assert.LessOrEqual(t, len(token), columnLimit,
"license token length %d exceeds VARCHAR(%d) column limit", len(token), columnLimit)
}
+2 -2
View File
@@ -58,7 +58,7 @@ CREATE TABLE IF NOT EXISTS `users` (
CREATE TABLE IF NOT EXISTS `licenses` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`shop_id` BIGINT UNSIGNED NOT NULL,
`license_key` VARCHAR(2048) NOT NULL COMMENT 'Ed25519 signed token',
`license_key` VARCHAR(768) NOT NULL COMMENT 'Ed25519 signed token',
`device_id` VARCHAR(255) DEFAULT NULL COMMENT 'deprecated: use license_devices',
`type` ENUM('trial','monthly','annual','lifetime') NOT NULL DEFAULT 'trial',
`expires_at` DATETIME DEFAULT NULL COMMENT 'NULL=永久',
@@ -69,7 +69,7 @@ CREATE TABLE IF NOT EXISTS `licenses` (
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_license_key` (`license_key`(255)),
UNIQUE KEY `uk_license_key` (`license_key`),
KEY `idx_shop_id` (`shop_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='许可证';
+11 -9
View File
@@ -1,11 +1,13 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
// Keep the RAF open so the OS lock stays alive for the lifetime of the process.
// ignore: unused_element
RandomAccessFile? _raf;
// Keep the server socket alive for the lifetime of the process.
// The OS releases the port automatically when the process exits.
ServerSocket? _server;
// Port unique to this app; chosen from the private range 4915265535.
const _kLockPort = 54317;
/// Returns true if this process successfully acquired the single-instance lock.
/// Returns false if another instance is already running.
@@ -15,11 +17,11 @@ Future<bool> acquire() async {
if (!Platform.isWindows && !Platform.isMacOS) return true;
try {
final dir = await getApplicationSupportDirectory();
final lockFile = File('${dir.path}/jiu.lock');
final raf = await lockFile.open(mode: FileMode.write);
await raf.lock(FileLock.exclusive);
_raf = raf;
_server = await ServerSocket.bind(
InternetAddress.loopbackIPv4,
_kLockPort,
shared: false,
);
return true;
} catch (_) {
return false;
+40
View File
@@ -0,0 +1,40 @@
import 'dart:typed_data';
/// 单张标签的全部打印字段,预览和打印共用同一份数据。
class LabelData {
/// 商品 ID,多张懒加载 QR 时用于拉取二维码字节。
final int? productId;
/// 二维码字节(PNG);单张场景调用方提前拉好;多张场景由 qrFetcher 懒加载填入。
Uint8List? qrBytes;
final String name;
final String code;
final String? spec;
final String? series;
final String? batchNo;
final String? productionDate;
final String? remark;
final String shopName;
final String shopAddress;
final String shopPhone;
/// 打印份数,默认 1,可在预览弹窗里调整。
int copies;
LabelData({
this.productId,
this.qrBytes,
required this.name,
required this.code,
this.spec,
this.series,
this.batchNo,
this.productionDate,
this.remark,
this.shopName = '',
this.shopAddress = '',
this.shopPhone = '',
this.copies = 1,
});
}
+14
View File
@@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
import '../../models/stock_in.dart';
import '../../models/stock_out.dart';
import '../errors/error_reporter.dart';
import 'label_data.dart';
export 'label_data.dart';
import 'print_util_stub.dart'
if (dart.library.js_interop) 'print_util_web.dart';
@@ -18,6 +20,7 @@ Future<void> printProductLabel({
String shopName = '',
String shopAddress = '',
String shopPhone = '',
String? printerName,
}) =>
printProductLabelImpl(
qrBytes: qrBytes,
@@ -31,8 +34,19 @@ Future<void> printProductLabel({
shopName: shopName,
shopAddress: shopAddress,
shopPhone: shopPhone,
printerName: printerName,
);
/// 渲染标签预览图(PNG 字节);Web 平台返回 null。
Future<Uint8List?> renderLabelPreview(LabelData label) =>
renderLabelPreviewImpl(label);
/// 枚举当前系统所有可用打印机名;Web 平台返回空列表。
Future<List<String>> listLabelPrinters() => listLabelPrintersImpl();
/// 自动检测默认热敏打印机(名字含 deli/dl-888);找不到或 Web 返回 null。
Future<String?> detectDefaultPrinter() => detectDefaultPrinterImpl();
Future<void> printStockInOrder(StockInOrder order) =>
printStockInOrderImpl(order);
+382 -96
View File
@@ -1,5 +1,20 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'dart:io' show Platform, Process, File, Directory;
import 'dart:convert' show ascii;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'dart:ffi';
import 'package:ffi/ffi.dart';
import 'package:win32/win32.dart'
show
OpenPrinter,
StartDocPrinter,
StartPagePrinter,
WritePrinter,
EndPagePrinter,
EndDocPrinter,
ClosePrinter,
DOC_INFO_1;
import 'package:flutter/foundation.dart' show kIsWeb, debugPrint;
import 'package:flutter/services.dart';
import '../errors/error_reporter.dart';
import 'package:pdf/pdf.dart';
@@ -7,6 +22,7 @@ import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import '../../models/stock_in.dart';
import '../../models/stock_out.dart';
import 'label_data.dart';
Future<pw.Font> _loadFont() async {
final data = await rootBundle.load('assets/fonts/NotoSansSC-Regular.ttf');
@@ -29,26 +45,255 @@ String _fileTs() {
// ── 设计色彩 token ──────────────────────────────────────────────────────────
const _navy = PdfColor(0.122, 0.165, 0.227); // #1F2A3A header/chip
const _cream = PdfColor(0.957, 0.925, 0.847); // #F4ECD8 footer/reversed text
const _muted = PdfColor(0.533, 0.533, 0.533); // #888 field labels
const _footnote = PdfColor(0.353, 0.306, 0.208); // #5A4E35 footer text
const _ink = PdfColor(0.067, 0.067, 0.067); // #111 body text
pw.Widget _labelSpecRow(pw.Font font, String label, String value) =>
pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.SizedBox(
width: 30,
child: pw.Text(label,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _muted)),
),
pw.SizedBox(width: 4),
pw.Expanded(
child: pw.Text(value,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
),
],
);
// ── 热敏标签 TSPL 裸发(桌面端:dart:ui 画扁平标签位图 → BITMAP → lp -o raw)────────
// 得力 DL-888B 等热敏机走系统打印(CUPS 通用驱动)会多走纸/空白,必须发原生 TSPL。
// 直接画位图而非走 PDF 光栅化:避免二维码渲染丢失、SIZE 用整数避免多走纸。
bool _isThermalName(String n) {
final lo = n.toLowerCase();
return lo.contains('dl-888') ||
lo.contains('dl_888') ||
lo.contains('888b') ||
lo.contains('deli');
}
/// 查找热敏标签机(名字含 DL-888 / 888B / Deli)。
/// macOS: CUPS 队列名(lpstat -e)Windows: 打印机名(Printing.listPrinters)。
Future<String?> _findThermalPrinter() async {
if (Platform.isMacOS) {
try {
final r = await Process.run('lpstat', ['-e']);
if (r.exitCode == 0) {
for (final raw in (r.stdout as String).split('\n')) {
final q = raw.trim();
if (q.isNotEmpty && _isThermalName(q)) return q;
}
}
} catch (e) {
debugPrint('[label] lpstat 失败: $e');
}
return null;
}
if (Platform.isWindows) {
try {
for (final p in await Printing.listPrinters()) {
if (_isThermalName(p.name)) return p.name;
}
} catch (e) {
debugPrint('[label] listPrinters 失败: $e');
}
return null;
}
return null;
}
/// 把裸 TSPL 字节发送到打印机:
/// macOS/Linux 用 `lp -o raw`Windows 用 winspool WritePrinter。失败抛异常。
Future<void> _sendRaw(String printer, Uint8List tspl) async {
if (Platform.isWindows) {
if (!_winRawPrint(printer, tspl)) {
throw Exception('Windows 裸发失败: $printer');
}
return;
}
final f = File(
'${Directory.systemTemp.path}/label_${DateTime.now().millisecondsSinceEpoch}.tspl');
await f.writeAsBytes(tspl, flush: true);
final r = await Process.run('lp', ['-d', printer, '-o', 'raw', f.path]);
debugPrint('[label] lp exit=${r.exitCode} out=${r.stdout} err=${r.stderr}');
if (r.exitCode != 0) {
throw Exception('lp 裸发失败(exit ${r.exitCode}): ${r.stderr}');
}
}
/// Windows: 通过 winspool 以 RAW 数据类型把字节直接送进打印队列
bool _winRawPrint(String printerName, Uint8List data) {
final pName = printerName.toNativeUtf16();
final phPrinter = calloc<IntPtr>();
final docName = 'jiu label'.toNativeUtf16();
final dataType = 'RAW'.toNativeUtf16();
final docInfo = calloc<DOC_INFO_1>();
final pcWritten = calloc<Uint32>();
final pData = calloc<Uint8>(data.length);
try {
if (OpenPrinter(pName, phPrinter, nullptr) == 0) {
debugPrint('[label] OpenPrinter 失败: $printerName');
return false;
}
final h = phPrinter.value;
docInfo.ref
..pDocName = docName
..pOutputFile = nullptr
..pDatatype = dataType;
if (StartDocPrinter(h, 1, docInfo) == 0) {
ClosePrinter(h);
return false;
}
StartPagePrinter(h);
pData.asTypedList(data.length).setAll(0, data);
final ok = WritePrinter(h, pData.cast(), data.length, pcWritten);
EndPagePrinter(h);
EndDocPrinter(h);
ClosePrinter(h);
debugPrint('[label] WritePrinter ok=$ok written=${pcWritten.value}');
return ok != 0;
} finally {
calloc.free(pName);
calloc.free(phPrinter);
calloc.free(docName);
calloc.free(dataType);
calloc.free(docInfo);
calloc.free(pcWritten);
calloc.free(pData);
}
}
/// 在 canvas 上画一行文字(黑色,单行不换行,CJK 走系统字体回退)
void _drawText(ui.Canvas canvas, String s, double x, double y, double size,
double maxW, {bool bold = false, bool center = false}) {
if (s.isEmpty) return;
final pb = ui.ParagraphBuilder(ui.ParagraphStyle(
fontSize: size,
fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal,
textAlign: center ? ui.TextAlign.center : ui.TextAlign.left,
maxLines: 1,
ellipsis: '',
))
..pushStyle(ui.TextStyle(color: const ui.Color(0xFF000000)))
..addText(s);
final p = pb.build()..layout(ui.ParagraphConstraints(width: maxW));
canvas.drawParagraph(p, ui.Offset(x, y));
}
/// 测量单行文本在指定字号下的宽度
double _measureWidth(String s, double size, bool bold) {
final pb = ui.ParagraphBuilder(ui.ParagraphStyle(
fontSize: size,
fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal,
maxLines: 1,
))..addText(s);
final p = pb.build()..layout(const ui.ParagraphConstraints(width: 100000));
return p.maxIntrinsicWidth;
}
/// 自适应字号:在 maxW × maxH 范围内尽量大(受宽度、行高、上限三者约束)
double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
if (s.isEmpty) return 0;
final w100 = _measureWidth(s, 100, bold);
final byW = w100 > 0 ? maxW / w100 * 100 : cap;
var size = byW < maxH ? byW : maxH;
if (size > cap) size = cap;
return size;
}
/// 用 dart:ui 把一张标签绘制成 320×160 位图(热敏标签实际尺寸 40×20mm @203dpi)。
/// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。
Future<ui.Image> _renderLabelBitmap({
required String shop,
required String name,
required String degSpec,
required String date,
required Uint8List qrBytes,
}) async {
const w = 320, h = 160; // 40×20mm @203dpi
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(
recorder, 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));
// 右侧二维码(解码后端 PNG 直接画上)
const qrS = 124.0;
const qrTop = 8.0;
const qrLeft = w - qrS - 4; // 192
try {
final codec = await ui.instantiateImageCodec(qrBytes);
final qrImg = (await codec.getNextFrame()).image;
canvas.drawImageRect(
qrImg,
ui.Rect.fromLTWH(0, 0, qrImg.width.toDouble(), qrImg.height.toDouble()),
const ui.Rect.fromLTWH(qrLeft, qrTop, qrS, qrS),
ui.Paint());
} catch (e) {
debugPrint('[label] QR 解码失败: $e');
}
// 扫码溯源:居中于二维码正下方
_drawText(canvas, '扫码溯源', qrLeft, qrTop + qrS + 2, 14, qrS, center: true);
// 左侧文字(无字段名标签):按行等比分配高度铺满,字号自适应填充
const lx = 22.0;
const textMaxW = qrLeft - lx - 6; // 164
final rows = <(String, double, bool)>[
(shop, 0.9, true),
(name, 1.5, true),
if (degSpec.isNotEmpty) (degSpec, 1.05, false),
if (date.isNotEmpty) (date, 1.0, false),
];
const topY = 4.0, botY = 156.0;
final totalWeight = rows.fold<double>(0, (a, r) => a + r.$2);
double yy = topY;
for (final r in rows) {
final rowH = (botY - topY) * r.$2 / totalWeight;
final fs = _fitFont(r.$1, textMaxW, rowH * 0.84, r.$3, 30);
final ty = yy + (rowH - fs * 1.25) / 2;
_drawText(canvas, r.$1, lx, ty < yy ? yy : ty, fs, textMaxW, bold: r.$3);
yy += rowH;
}
return recorder.endRecording().toImage(w, h);
}
/// 桌面端把「扁平标签」直接画成单色位图,TSPL BITMAP 裸发到热敏机。
/// 版式(无样式):酒行名 / 酒名(长名缩小) / 度数(系列)+规格 / 日期;右侧二维码 + 扫码溯源。
/// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。
/// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。
Future<bool> _printFlatLabelThermal({
required String shop,
required String name,
required String degSpec,
required String date,
required Uint8List qrBytes,
String? printerName,
}) async {
if (kIsWeb || !(Platform.isMacOS || Platform.isWindows)) return false;
final printer = printerName ?? await _findThermalPrinter();
debugPrint('[label] thermal printer = $printer');
if (printer == null) return false;
const w = 320, h = 160;
final img = await _renderLabelBitmap(
shop: shop, name: name, degSpec: degSpec, date: date, qrBytes: qrBytes);
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
final rgba = bd!.buffer.asUint8List();
// 打包 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++) {
final i = (yy * w + xx) * 4;
final a = rgba[i + 3];
final lum = a < 128
? 255.0
: 0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2];
if (lum < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7)); // 黑点 -> bit0
}
out.add(row);
}
out.add(ascii.encode('\r\nPRINT 1,1\r\n'));
final tspl = out.toBytes();
debugPrint('[label] flat tspl ${tspl.length}B -> $printer');
await _sendRaw(printer, tspl);
return true;
}
Future<void> printProductLabelImpl({
required Uint8List qrBytes,
@@ -62,25 +307,38 @@ Future<void> printProductLabelImpl({
String shopName = '',
String shopAddress = '',
String shopPhone = '',
String? printerName,
}) async {
try {
final font = await _loadFont();
final doc = pw.Document();
final qrImage = pw.MemoryImage(qrBytes);
// Label: 4 × 2 inch
const labelW = 4.0 * PdfPageFormat.inch;
const labelH = 2.0 * PdfPageFormat.inch;
const headerH = labelH * 0.215;
const footerH = labelH * 0.09;
// Label: 40 × 20 mm(匹配实际标签纸尺寸)
const labelW = 40.0 * PdfPageFormat.mm;
const labelH = 20.0 * PdfPageFormat.mm;
const headerH = labelH * 0.20;
const footerH = labelH * 0.11;
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
final seriesVal = (series ?? '').isNotEmpty ? series! : '';
final batchVal = (batchNo ?? '').isNotEmpty ? batchNo! : '';
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
final seriesVal = (series ?? '').isNotEmpty ? series! : '';
// 度数(系列) + 规格 同一行
final degSpecVal = [seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
final dateVal = (productionDate ?? '').isNotEmpty
? (productionDate!.length > 10 ? productionDate.substring(0, 10) : productionDate)
: '';
// 桌面端热敏机:直接画扁平标签位图 + TSPL 裸发(不走会多走纸的系统打印)
if (await _printFlatLabelThermal(
shop: shopName,
name: name,
degSpec: degSpecVal,
date: dateVal == '' ? '' : dateVal, // 无生产日期(商品详情)则不显示该行
qrBytes: qrBytes,
printerName: printerName)) {
return;
}
final contact = [
if (shopAddress.isNotEmpty) shopAddress,
if (shopPhone.isNotEmpty) shopPhone,
@@ -104,32 +362,22 @@ Future<void> printProductLabelImpl({
pw.Container(
height: headerH,
color: _navy,
padding: const pw.EdgeInsets.symmetric(horizontal: 11),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Flexible(
child: pw.Text(shopName,
style: pw.TextStyle(
font: font, fontSize: 13,
fontWeight: pw.FontWeight.bold,
color: _cream, letterSpacing: 1.5,
)),
),
pw.Text('Certificate of Authenticity',
style: pw.TextStyle(
font: font, fontSize: 5,
color: const PdfColor(0.82, 0.79, 0.72))),
],
),
padding: const pw.EdgeInsets.symmetric(horizontal: 4),
alignment: pw.Alignment.centerLeft,
child: pw.Text(shopName,
maxLines: 1, overflow: pw.TextOverflow.clip,
style: pw.TextStyle(
font: font, fontSize: 6,
fontWeight: pw.FontWeight.bold,
color: _cream, letterSpacing: 0.5,
)),
),
// ── Body ─────────────────────────────────────────────────────────────
pw.Expanded(
child: pw.Container(
color: PdfColors.white,
padding: const pw.EdgeInsets.fromLTRB(11, 8, 11, 6),
padding: const pw.EdgeInsets.fromLTRB(4, 2, 4, 2),
child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
@@ -138,38 +386,41 @@ Future<void> printProductLabelImpl({
mainAxisAlignment: pw.MainAxisAlignment.center,
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
// 商品名
// 商品名(过长自动缩小并最多两行)
pw.Text(name,
maxLines: 2,
overflow: pw.TextOverflow.clip,
style: pw.TextStyle(
font: font, fontSize: 12,
font: font,
fontSize: name.runes.length > 10
? 5.5
: (name.runes.length > 7 ? 6.5 : 7.5),
fontWeight: pw.FontWeight.bold,
color: _ink, letterSpacing: 0.5)),
pw.SizedBox(height: 6),
// 规格 + 系列
_label2ColRow(font, '规 格', specVal, '系 列', seriesVal),
pw.SizedBox(height: 3),
// 批号 + 生产日期
_label2ColRow(font, '批 号', batchVal, '生产日期', dateVal),
if ((remark ?? '').isNotEmpty) ...[
pw.SizedBox(height: 3),
_labelSpecRow(font, '备 注', remark!),
],
color: _ink, letterSpacing: 0.3)),
pw.SizedBox(height: 2.5),
// 度数(系列) + 规格 同一行, 无字段名标签
pw.Text(degSpecVal,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
pw.SizedBox(height: 1.5),
// 生产日期(只显示日期,无标签)
pw.Text(dateVal,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
],
),
),
pw.SizedBox(width: 8),
pw.SizedBox(width: 4),
// QR
pw.Column(
mainAxisAlignment: pw.MainAxisAlignment.center,
children: [
pw.SizedBox(
width: 60, height: 60,
width: 38, height: 38,
child: pw.Image(qrImage, fit: pw.BoxFit.contain),
),
pw.SizedBox(height: 3),
pw.SizedBox(height: 1),
pw.Text('扫码溯源',
style: pw.TextStyle(
font: font, fontSize: 4.5,
font: font, fontSize: 3.5,
color: PdfColors.grey600)),
],
),
@@ -182,17 +433,18 @@ Future<void> printProductLabelImpl({
pw.Container(
height: footerH,
color: _cream,
padding: const pw.EdgeInsets.symmetric(horizontal: 11),
padding: const pw.EdgeInsets.symmetric(horizontal: 4),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Flexible(
child: pw.Text(footerLeft,
style: pw.TextStyle(font: font, fontSize: 4, color: _footnote)),
maxLines: 1, overflow: pw.TextOverflow.clip,
style: pw.TextStyle(font: font, fontSize: 3, color: _footnote)),
),
pw.Text(genTime,
style: pw.TextStyle(font: font, fontSize: 4, color: _footnote)),
style: pw.TextStyle(font: font, fontSize: 3, color: _footnote)),
],
),
),
@@ -207,42 +459,72 @@ Future<void> printProductLabelImpl({
await Future<void>.delayed(Duration.zero);
}
await Printing.layoutPdf(
name: '标签_${_fileTs()}', onLayout: (_) async => bytes);
name: '标签_${_fileTs()}',
dynamicLayout: false,
onLayout: (_) async => bytes);
} catch (e, st) {
reportError(e, st);
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
}
}
pw.Widget _label2ColRow(
pw.Font font, String lbl1, String val1, String lbl2, String val2) {
return pw.Row(
children: [
pw.SizedBox(
width: 28,
child: pw.Text(lbl1,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _muted)),
),
pw.SizedBox(width: 3),
pw.Expanded(
child: pw.Text(val1,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
),
pw.SizedBox(width: 6),
pw.SizedBox(
width: 28,
child: pw.Text(lbl2,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _muted)),
),
pw.SizedBox(width: 3),
pw.Expanded(
child: pw.Text(val2,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
),
],
/// 渲染标签预览图(PNG 字节),与实际热敏打印使用相同画布逻辑(所见即所得)。
/// Web 平台或 qrBytes 为空时抛异常。
Future<Uint8List> renderLabelPreviewImpl(LabelData label) async {
final specVal = (label.spec ?? '').isNotEmpty ? label.spec! : '';
final seriesVal = (label.series ?? '').isNotEmpty ? label.series! : '';
final degSpecVal = [seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
final dateVal = (label.productionDate ?? '').isNotEmpty
? (label.productionDate!.length > 10
? label.productionDate!.substring(0, 10)
: label.productionDate!)
: '';
final img = await _renderLabelBitmap(
shop: label.shopName,
name: label.name,
degSpec: degSpecVal,
date: dateVal,
qrBytes: label.qrBytes!,
);
final bd = await img.toByteData(format: ui.ImageByteFormat.png);
return bd!.buffer.asUint8List();
}
/// 枚举当前系统上所有可用打印机名。
/// macOS: CUPS 队列(lpstat -e);Windows: Printing.listPrinters();其他返回空列表。
Future<List<String>> listLabelPrintersImpl() async {
if (kIsWeb) return [];
if (Platform.isMacOS) {
try {
final r = await Process.run('lpstat', ['-e']);
if (r.exitCode == 0) {
return (r.stdout as String)
.split('\n')
.map((s) => s.trim())
.where((s) => s.isNotEmpty && _isThermalName(s))
.toList();
}
} catch (e) {
debugPrint('[label] lpstat -e 失败: $e');
}
return [];
}
if (Platform.isWindows) {
try {
return (await Printing.listPrinters())
.map((p) => p.name)
.where((n) => _isThermalName(n))
.toList();
} catch (e) {
debugPrint('[label] listPrinters 失败: $e');
}
}
return [];
}
/// 自动检测默认热敏打印机(名字含 deli/dl-888);找不到返回 null。
Future<String?> detectDefaultPrinterImpl() => _findThermalPrinter();
pw.Widget _buildOrderDoc({
required pw.Font font,
required pw.Font bold,
@@ -452,7 +734,9 @@ Future<void> printStockInOrderImpl(StockInOrder order) async {
await Future<void>.delayed(Duration.zero);
}
await Printing.layoutPdf(
name: '入库单_${_fileTs()}', onLayout: (_) async => bytes);
name: '入库单_${_fileTs()}',
dynamicLayout: false,
onLayout: (_) async => bytes);
} catch (e, st) {
reportError(e, st);
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
@@ -512,7 +796,9 @@ Future<void> printStockOutOrderImpl(StockOutOrder order) async {
await Future<void>.delayed(Duration.zero);
}
await Printing.layoutPdf(
name: '出库单_${_fileTs()}', onLayout: (_) async => bytes);
name: '出库单_${_fileTs()}',
dynamicLayout: false,
onLayout: (_) async => bytes);
} catch (e, st) {
reportError(e, st);
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
+27 -12
View File
@@ -5,6 +5,7 @@ import 'package:web/web.dart' as web;
import '../../models/stock_in.dart';
import '../../models/stock_out.dart';
import '../errors/error_reporter.dart';
import 'label_data.dart';
void _openPrintWindow(String html) {
final win = web.window.open('', '_blank');
@@ -27,14 +28,20 @@ Future<void> printProductLabelImpl({
String shopName = '',
String shopAddress = '',
String shopPhone = '',
String? printerName,
}) async {
final base64Img = base64Encode(qrBytes);
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
final batchVal = (batchNo ?? '').isNotEmpty ? batchNo! : '';
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
final seriesVal = (series ?? '').isNotEmpty ? series! : '';
// 度数(系列) + 规格 同一行
final degSpecVal = [seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
final dateVal = (productionDate ?? '').isNotEmpty
? (productionDate!.length > 10 ? productionDate.substring(0, 10) : productionDate)
: '';
// 商品名过长时缩小字号(适当缩小,配合两行折行)
final nameLen = name.runes.length;
final nameFontPt = nameLen > 11 ? 5.0 : (nameLen > 8 ? 5.8 : 6.5);
final contactLine = [
if (shopPhone.isNotEmpty) shopPhone,
@@ -85,9 +92,10 @@ body { width: 38mm; height: 20mm; overflow: hidden; background: #fff; }
overflow: hidden;
}
.product-name {
font-size: 6.5pt; font-weight: 700; line-height: 1.2;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-bottom: 0.8mm;
font-size: 6.5pt; font-weight: 700; line-height: 1.15;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
overflow: hidden; word-break: break-all;
margin-bottom: 0.6mm;
}
.specs {
display: flex; flex-direction: column; gap: 0.5mm;
@@ -102,12 +110,12 @@ body { width: 38mm; height: 20mm; overflow: hidden; background: #fff; }
/* 右:二维码 */
.qr-wrap {
width: 14mm; flex-shrink: 0;
width: 16mm; flex-shrink: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 0.3mm;
padding: 0.5mm 0.5mm 0.5mm 0;
padding: 0.3mm 0.3mm 0.3mm 0;
}
.qr-wrap img { width: 12mm; height: 12mm; object-fit: contain; }
.qr-wrap img { width: 14.5mm; height: 14.5mm; object-fit: contain; }
.qr-cap { font-size: 3pt; color: #aaa; letter-spacing: 0.1em; }
/* ── Footer:联系方式 3mm ── */
@@ -129,11 +137,10 @@ body { width: 38mm; height: 20mm; overflow: hidden; background: #fff; }
<div class="middle">
<div class="info">
<div class="product-name">$name</div>
<div class="product-name" style="font-size:${nameFontPt}pt">$name</div>
<div class="specs">
<div class="spec-row"><span class="lbl">规</span><span class="val">$specVal</span></div>
<div class="spec-row"><span class="lbl">批</span><span class="val">$batchVal</span></div>
<div class="spec-row"><span class="lbl">产</span><span class="val">$dateVal</span></div>
<div class="spec-row"><span class="val">$degSpecVal</span></div>
<div class="spec-row"><span class="val">$dateVal</span></div>
</div>
</div>
<div class="qr-wrap">
@@ -274,6 +281,14 @@ Future<void> printStockInOrderImpl(StockInOrder order) async {
}
}
// ── 预览 / 打印机枚举(Web 不支持热敏裸发,返回空值)─────────────────────────
Future<Uint8List?> renderLabelPreviewImpl(LabelData label) async => null;
Future<List<String>> listLabelPrintersImpl() async => [];
Future<String?> detectDefaultPrinterImpl() async => null;
// ── 出库单 ──────────────────────────────────────────────────────────────────
Future<void> printStockOutOrderImpl(StockOutOrder order) async {
+2 -1
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -107,7 +108,7 @@ class _JiuAppState extends ConsumerState<JiuApp> {
Widget build(BuildContext context) {
final router = ref.watch(appRouterProvider);
return MaterialApp.router(
title: '岩美',
title: kDebugMode ? '岩美 [DEBUG]' : '岩美',
theme: AppTheme.light(),
routerConfig: router,
debugShowCheckedModeBanner: false,
@@ -20,6 +20,8 @@ class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> {
int _pageSize = 20;
int? _warehouseId;
String _keyword = '';
List<String> _series = [];
List<String> _spec = [];
PageResult<Inventory>? _cache;
@override
@@ -40,6 +42,8 @@ class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> {
return ref.read(inventoryRepositoryProvider).listInventory(
warehouseId: _warehouseId,
keyword: _keyword.isEmpty ? null : _keyword,
series: _series.isEmpty ? null : _series,
spec: _spec.isEmpty ? null : _spec,
page: _page,
pageSize: _pageSize,
);
@@ -68,6 +72,18 @@ class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> {
reload();
}
void setSeries(List<String> series) {
_series = series;
_page = 1;
reload();
}
void setSpec(List<String> spec) {
_spec = spec;
_page = 1;
reload();
}
void reload() {
state = const AsyncValue.loading();
_fetch().then((result) {
@@ -12,6 +12,8 @@ class InventoryRepository {
Future<PageResult<Inventory>> listInventory({
int? warehouseId,
String? keyword,
List<String>? series,
List<String>? spec,
int page = 1,
int pageSize = 50,
}) async {
@@ -21,6 +23,8 @@ class InventoryRepository {
'page_size': pageSize,
if (warehouseId != null) 'warehouse_id': warehouseId,
if (keyword != null && keyword.isNotEmpty) 'keyword': keyword,
if (series != null && series.isNotEmpty) 'series': series.join(','),
if (spec != null && spec.isNotEmpty) 'spec': spec.join(','),
};
final resp = await _client.get('/inventory', params: params);
return PageResult.fromJson(
@@ -16,7 +16,10 @@ import '../../widgets/multi_select_dropdown.dart' show FilterableColumnHeader;
import '../../widgets/page_scaffold.dart';
import '../../core/utils/export_util.dart';
import '../../core/utils/print_util.dart';
import '../../core/utils/dialog_util.dart';
import '../../widgets/label_preview_dialog.dart';
import '../../providers/product_provider.dart';
import '../../providers/product_option_provider.dart';
import '../../providers/tab_state_provider.dart';
import '../../providers/shop_provider.dart' show shopInfoProvider;
@@ -31,6 +34,9 @@ class InventoryListScreen extends ConsumerStatefulWidget {
class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
final _searchCtrl = TextEditingController();
Set<String> _filterWarehouse = {};
Set<String> _filterSpec = {};
Set<String> _filterSeries = {};
@override
void dispose() {
@@ -196,21 +202,23 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
await ref.read(productRepositoryProvider).getQRCodeBytes(item.productId!);
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
if (!context.mounted) return;
await safePrint(
context,
() => printProductLabel(
qrBytes: qrBytes,
name: item.productName,
code: item.productCode,
series: item.series.isEmpty ? null : item.series,
spec: item.spec.isEmpty ? null : item.spec,
batchNo: item.batchNo.isEmpty ? null : item.batchNo,
productionDate: item.productionDate,
remark: item.remark.isEmpty ? null : item.remark,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
),
final label = LabelData(
productId: item.productId,
qrBytes: qrBytes,
name: item.productName,
code: item.productCode,
series: item.series.isEmpty ? null : item.series,
spec: item.spec.isEmpty ? null : item.spec,
batchNo: item.batchNo.isEmpty ? null : item.batchNo,
productionDate: item.productionDate,
remark: item.remark.isEmpty ? null : item.remark,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
);
showAppDialog(
context: context,
builder: (_) => LabelPreviewDialog(labels: [label]),
);
}
@@ -365,18 +373,24 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
items.where((i) => i.minStock != null && i.quantity < i.minStock!).length;
final emptyCount = items.where((i) => i.quantity == 0).length;
// 仓库选项从数据中派生客户端筛选
// 仓库选项从当前页派生客户端筛选),系列/规格从 product-options API 加载(服务端筛选)
final warehouseOptions = items
.map((i) => i.warehouseName)
.where((s) => s.isNotEmpty)
.toSet()
.toList()
..sort();
final seriesOptions = ref.watch(productSeriesListProvider).valueOrNull
?.map((s) => s.name)
.toList() ??
[];
final specOptions = ref.watch(productSpecListProvider).valueOrNull
?.map((s) => s.name)
.toList() ??
[];
final filteredItems = _filterWarehouse.isEmpty
? items
: items
.where((i) => _filterWarehouse.contains(i.warehouseName))
.toList();
: items.where((i) => _filterWarehouse.contains(i.warehouseName)).toList();
return Column(
children: [
@@ -440,7 +454,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
OutlinedButton.icon(
onPressed: () => exportExcel(
filename: '库存查询',
headers: ['商品编码', '商品名称', '规格', '批次号', '仓库', '库存量', '单价', '生产日期', '供应商', '安全库存', '状态'],
headers: ['商品编码', '商品名称', '规格', '系列', '批次号', '仓库', '库存量', '单价', '生产日期', '入库时间', '供应商', '安全库存', '状态'],
rows: filteredItems.map((item) {
final status = item.quantity == 0
? '缺货'
@@ -451,11 +465,13 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
item.productCode.isEmpty ? '' : item.productCode,
item.productName.isEmpty ? '' : item.productName,
item.spec.isEmpty ? '' : item.spec,
item.series.isEmpty ? '' : item.series,
item.batchNo.isEmpty ? '' : item.batchNo,
item.warehouseName.isEmpty ? '' : item.warehouseName,
'${item.quantity.toStringAsFixed(0)} ${item.unit}'.trim(),
item.unitPrice != null ? item.unitPrice!.toStringAsFixed(2) : '',
item.productionDate ?? '',
item.createdAt != null && item.createdAt!.length >= 10 ? item.createdAt!.substring(0, 10) : '',
item.supplierName.isEmpty ? '' : item.supplierName,
item.minStock ?? '',
status,
@@ -490,8 +506,28 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
columns: [
const DataColumn(label: Text('商品编码')),
const DataColumn(label: Text('商品名称')),
const DataColumn(label: Text('规格')),
const DataColumn(label: Text('系列')),
DataColumn(
label: FilterableColumnHeader(
text: '规格',
options: specOptions,
selected: _filterSpec,
onChanged: (v) {
setState(() => _filterSpec = v);
ref.read(inventoryListProvider.notifier).setSpec(v.toList());
},
),
),
DataColumn(
label: FilterableColumnHeader(
text: '系列',
options: seriesOptions,
selected: _filterSeries,
onChanged: (v) {
setState(() => _filterSeries = v);
ref.read(inventoryListProvider.notifier).setSeries(v.toList());
},
),
),
const DataColumn(label: Text('批次号')),
DataColumn(
label: FilterableColumnHeader(
@@ -504,6 +540,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
const DataColumn(label: Text('库存量'), numeric: true),
const DataColumn(label: Text('单价'), numeric: true),
const DataColumn(label: Text('生产日期')),
const DataColumn(label: Text('入库时间')),
const DataColumn(label: Text('供应商')),
const DataColumn(label: Text('备注')),
const DataColumn(label: Text('状态')),
@@ -527,6 +564,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
])
]
: filteredItems
@@ -590,6 +628,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
: '-',
)),
DataCell(Text(item.productionDate ?? '-')),
DataCell(Text(
item.createdAt != null && item.createdAt!.length >= 10
? item.createdAt!.substring(0, 10)
: '-',
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
)),
DataCell(Text(item.supplierName.isEmpty ? '-' : item.supplierName)),
DataCell(
Tooltip(
@@ -624,27 +668,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
DataCell(
item.productId != null
? TextButton(
onPressed: () async {
final qrBytes = await ref
.read(productRepositoryProvider)
.getQRCodeBytes(item.productId!);
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
if (context.mounted) {
await safePrint(context, () => printProductLabel(
qrBytes: qrBytes,
name: item.productName,
code: item.productCode,
series: item.series.isEmpty ? null : item.series,
spec: item.spec.isEmpty ? null : item.spec,
batchNo: item.batchNo.isEmpty ? null : item.batchNo,
productionDate: item.productionDate,
remark: item.remark.isEmpty ? null : item.remark,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
));
}
},
onPressed: () => _printLabel(context, item),
child: const Text('打标签',
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
)
@@ -8,6 +8,7 @@ import 'package:flutter/services.dart';
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 '../../core/theme/app_theme.dart';
import '../../models/product.dart';
import '../../models/product_image.dart';
@@ -656,8 +657,6 @@ class _QRCodeDialog extends ConsumerStatefulWidget {
class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> {
late Future<Uint8List> _future;
Uint8List? _bytes;
bool _printing = false;
String _printStatus = '';
@override
void initState() {
@@ -670,25 +669,25 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> {
});
}
Future<void> _print() async {
if (_bytes == null || _printing) return;
setState(() { _printing = true; _printStatus = '正在打印...'; });
void _print() {
if (_bytes == null) return;
final p = widget.product;
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
try {
await safePrint(context, () => printProductLabel(
qrBytes: _bytes!,
name: p.name,
code: p.code,
spec: p.spec,
series: p.series,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
));
} finally {
if (mounted) setState(() { _printing = false; _printStatus = ''; });
}
final label = LabelData(
productId: p.id,
qrBytes: _bytes,
name: p.name,
code: p.code,
spec: p.spec,
series: p.series,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
);
showAppDialog(
context: context,
builder: (_) => LabelPreviewDialog(labels: [label]),
);
}
@override
@@ -717,15 +716,9 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> {
child: const Text('关闭'),
),
FilledButton.icon(
onPressed: _bytes == null ? null : (_printing ? null : _print),
icon: _printing
? const SizedBox(
width: 14, height: 14,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(Icons.print, size: 16),
label: Text(_printing
? (_printStatus.isNotEmpty ? _printStatus : '准备中...')
: '打印'),
onPressed: _bytes == null ? null : _print,
icon: const Icon(Icons.print, size: 16),
label: const Text('打印'),
),
],
);
@@ -842,10 +842,10 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
color: const Color(0xFFF0F4FF),
child: Row(children: [
SizedBox(width: 36, child: th('序号')),
Expanded(flex: 12, child: th('商品编码')),
Expanded(flex: 20, child: th('名称')),
Expanded(flex: 13, child: th('系列')),
Expanded(flex: 13, child: th('规格')),
Expanded(flex: 12, child: th('生产日期')),
Expanded(flex: 9, child: th('单品数量')),
Expanded(flex: 10, child: th('数量')),
Expanded(flex: 10, child: th('单价')),
@@ -875,7 +875,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
);
final hasOptional = item.batchNoCtrl.text.isNotEmpty ||
item.productionDate != null ||
item.selectedOriginId != null ||
item.selectedShelfLifeId != null ||
item.selectedStorageId != null ||
@@ -891,9 +890,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
SizedBox(
width: 36,
child: tc('${index + 1}', color: AppTheme.textSecondary)),
Expanded(
flex: 12,
child: tc(productCode, color: AppTheme.textSecondary)),
Expanded(
flex: 20,
child: Padding(
@@ -909,6 +905,11 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
child: Padding(
padding: const EdgeInsets.all(4),
child: _specField(item))),
Expanded(
flex: 12,
child: Padding(
padding: const EdgeInsets.all(4),
child: _dateField(item))),
Expanded(
flex: 9,
child: tc(specQty > 0 ? '$specQty' : '-',
@@ -994,8 +995,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
children: [
_OptionalField(
label: '批次号', width: 180, child: _batchField(item)),
_OptionalField(
label: '生产日期', width: 160, child: _dateField(item)),
_OptionalField(
label: '产地', width: 180, child: _originField(item)),
_OptionalField(
@@ -1022,7 +1021,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
return MobileListCard(
title: Text('商品 ${index + 1}'),
subtitle: productCode.isNotEmpty ? Text('编码 $productCode') : null,
trailing: IconButton(
icon: const Icon(Icons.delete_outline, size: 20, color: AppTheme.danger),
onPressed: _items.length > 1 ? () => _removeItem(index) : null,
@@ -1033,6 +1031,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
MobileCardField('名称', null, valueWidget: _nameField(item)),
MobileCardField('系列', null, valueWidget: _seriesField(item)),
MobileCardField('规格', null, valueWidget: _specField(item)),
MobileCardField('生产日期', null, valueWidget: _dateField(item)),
MobileCardField('单品数量', specQty > 0 ? '$specQty' : '-'),
MobileCardField('数量', null, valueWidget: _qtyField(item)),
MobileCardField('单价', null, valueWidget: _priceField(item)),
@@ -1051,8 +1050,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
)),
if (item.expanded)
MobileCardField('批次号', null, valueWidget: _batchField(item)),
if (item.expanded)
MobileCardField('生产日期', null, valueWidget: _dateField(item)),
if (item.expanded)
MobileCardField('产地', null, valueWidget: _originField(item)),
if (item.expanded)
@@ -1,6 +1,7 @@
import '../../core/utils/dialog_util.dart';
import '../../core/errors/error_reporter.dart';
import '../../core/utils/print_util.dart' show safePrint, printStockInOrder, printProductLabel;
import '../../core/utils/print_util.dart' show safePrint, printStockInOrder, LabelData;
import '../../widgets/label_preview_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -15,7 +16,6 @@ import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButto
import '../../widgets/page_scaffold.dart';
import '../../widgets/status_badge.dart';
import '../../core/utils/export_util.dart';
import '../../core/utils/print_util.dart';
import '../../providers/inventory_provider.dart';
import '../../providers/tab_state_provider.dart';
import '../../providers/product_provider.dart' show productRepositoryProvider;
@@ -365,14 +365,25 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
final order = await ref.read(stockInRepositoryProvider).get(o.id);
if (!context.mounted) return;
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
await showDialog(
final labels = order.items
.map((item) => LabelData(
productId: item.productId,
name: item.productName ?? '',
code: item.productCode ?? '',
series: item.productSeries,
spec: item.productSpec,
batchNo: item.batchNo,
productionDate: item.productionDate,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
))
.toList();
showAppDialog(
context: context,
builder: (_) => _LabelPrintDialog(
order: order,
productRepo: ref.read(productRepositoryProvider),
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
builder: (_) => LabelPreviewDialog(
labels: labels,
qrFetcher: ref.read(productRepositoryProvider).getQRCodeBytes,
),
);
},
@@ -996,149 +1007,3 @@ class _StatusFilterDropdown extends StatelessWidget {
}
}
class _LabelPrintDialog extends StatefulWidget {
final StockInOrder order;
final ProductRepository productRepo;
final String shopName;
final String shopAddress;
final String shopPhone;
const _LabelPrintDialog({
required this.order,
required this.productRepo,
this.shopName = '',
this.shopAddress = '',
this.shopPhone = '',
});
@override
State<_LabelPrintDialog> createState() => _LabelPrintDialogState();
}
class _LabelPrintDialogState extends State<_LabelPrintDialog> {
late final List<bool> _selected;
bool _printing = false;
String _status = '';
@override
void initState() {
super.initState();
_selected = List.filled(widget.order.items.length, true);
}
Future<void> _print() async {
setState(() { _printing = true; _status = '正在打印...'; });
int done = 0;
for (int i = 0; i < widget.order.items.length; i++) {
if (!_selected[i]) continue;
final item = widget.order.items[i];
try {
final qrBytes = await widget.productRepo.getQRCodeBytes(item.productId);
await printProductLabel(
qrBytes: qrBytes,
name: item.productName ?? '',
code: item.productCode ?? '',
series: item.productSeries,
spec: item.productSpec,
batchNo: item.batchNo,
productionDate: item.productionDate,
shopName: widget.shopName,
shopAddress: widget.shopAddress,
shopPhone: widget.shopPhone,
);
done++;
if (mounted) setState(() => _status = '已打印 $done 张...');
} catch (e, st) {
reportError(e, st);
if (mounted) setState(() => _status = '${i + 1}行打印失败:$e');
}
}
if (mounted) setState(() { _printing = false; _status = '完成,共打印 $done'; });
}
@override
Widget build(BuildContext context) {
final items = widget.order.items;
return Dialog(
child: Container(
width: context.dialogWidth(520),
constraints: const BoxConstraints(maxHeight: 520),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: const BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: Row(
children: [
const Text('打印商品标签',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, i) {
final item = items[i];
return CheckboxListTile(
value: _selected[i],
onChanged: _printing
? null
: (v) => setState(() => _selected[i] = v ?? false),
title: Text(
'${item.productCode ?? ''} ${item.productName ?? ''}',
style: const TextStyle(fontSize: 13),
),
subtitle: Text(
'${item.productSeries ?? ''} ${item.productSpec ?? ''}',
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
dense: true,
controlAffinity: ListTileControlAffinity.leading,
);
},
),
),
if (_status.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(_status,
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('关闭'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: (_printing || !_selected.contains(true)) ? null : _print,
icon: const Icon(Icons.print_outlined, size: 16),
label: Text(_printing ? '打印中...' : '打印选中'),
),
],
),
),
],
),
),
);
}
}
@@ -1,7 +1,6 @@
import '../../repositories/product_repository.dart';
import '../../core/utils/dialog_util.dart';
import '../../core/errors/error_reporter.dart';
import '../../core/utils/print_util.dart' show safePrint, printStockOutOrder, printProductLabel;
import '../../core/utils/print_util.dart' show safePrint, printStockOutOrder;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -920,149 +919,3 @@ class _StatusFilterDropdown extends StatelessWidget {
}
}
class _LabelPrintDialog extends StatefulWidget {
final StockOutOrder order;
final ProductRepository productRepo;
final String shopName;
final String shopAddress;
final String shopPhone;
const _LabelPrintDialog({
required this.order,
required this.productRepo,
this.shopName = '',
this.shopAddress = '',
this.shopPhone = '',
});
@override
State<_LabelPrintDialog> createState() => _LabelPrintDialogState();
}
class _LabelPrintDialogState extends State<_LabelPrintDialog> {
late final List<bool> _selected;
bool _printing = false;
String _status = '';
@override
void initState() {
super.initState();
_selected = List.filled(widget.order.items.length, true);
}
Future<void> _print() async {
setState(() { _printing = true; _status = '正在打印...'; });
int done = 0;
for (int i = 0; i < widget.order.items.length; i++) {
if (!_selected[i]) continue;
final item = widget.order.items[i];
try {
final qrBytes = await widget.productRepo.getQRCodeBytes(item.productId);
await printProductLabel(
qrBytes: qrBytes,
name: item.productName ?? '',
code: item.productCode ?? '',
series: item.productSeries,
spec: item.productSpec,
shopName: widget.shopName,
shopAddress: widget.shopAddress,
shopPhone: widget.shopPhone,
);
done++;
if (mounted) setState(() => _status = '已打印 $done 张...');
} catch (e, st) {
reportError(e, st);
if (mounted) setState(() => _status = '${i + 1}行打印失败:$e');
}
}
if (mounted) {
setState(() { _printing = false; _status = '完成,共打印 $done'; });
}
}
@override
Widget build(BuildContext context) {
final items = widget.order.items;
return Dialog(
child: Container(
width: context.dialogWidth(520),
constraints: const BoxConstraints(maxHeight: 520),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: const BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: Row(
children: [
const Text('打印商品标签',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, i) {
final item = items[i];
return CheckboxListTile(
value: _selected[i],
onChanged: _printing
? null
: (v) => setState(() => _selected[i] = v ?? false),
title: Text(
'${item.productCode ?? ''} ${item.productName ?? ''}',
style: const TextStyle(fontSize: 13),
),
subtitle: Text(
'${item.productSeries ?? ''} ${item.productSpec ?? ''}',
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
dense: true,
controlAffinity: ListTileControlAffinity.leading,
);
},
),
),
if (_status.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(_status,
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('关闭'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: (_printing || !_selected.contains(true)) ? null : _print,
icon: const Icon(Icons.print_outlined, size: 16),
label: Text(_printing ? '打印中...' : '打印选中'),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,440 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../core/errors/error_reporter.dart';
import '../core/theme/app_theme.dart';
import '../core/responsive/responsive.dart';
import '../core/utils/print_util.dart';
import '../core/utils/label_data.dart';
const _kPrinterKey = 'label_printer';
/// 标签打印预览弹窗:所见即所得预览(dart:ui 位图)+ 打印机选择 + 份数调整。
///
/// 单张模式(labels.length == 1):隐藏缩略图条,直接显示大图。
/// 多张模式:左侧竖排缩略图(带勾选),右侧大图主视图。
class LabelPreviewDialog extends StatefulWidget {
final List<LabelData> labels;
/// 多张懒加载:按 productId 拉取 QR PNG 字节。单张场景可不传(qrBytes 已在 LabelData 中)。
final Future<Uint8List> Function(int productId)? qrFetcher;
const LabelPreviewDialog({
super.key,
required this.labels,
this.qrFetcher,
});
@override
State<LabelPreviewDialog> createState() => _LabelPreviewDialogState();
}
class _LabelPreviewDialogState extends State<LabelPreviewDialog> {
int _selectedIndex = 0;
late List<Uint8List?> _previews;
late List<bool> _include;
List<String> _printers = [];
String? _printer;
bool _printing = false;
bool _printersChecked = false;
String _status = '';
@override
void initState() {
super.initState();
_previews = List<Uint8List?>.filled(widget.labels.length, null,
growable: false);
_include = List<bool>.filled(widget.labels.length, true, growable: false);
_init();
}
Future<void> _init() async {
// ── 枚举打印机,读取上次记忆 ──
final printers = await listLabelPrinters();
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString(_kPrinterKey);
String? selected;
if (saved != null && printers.contains(saved)) {
selected = saved;
} else {
selected = await detectDefaultPrinter() ??
(printers.isNotEmpty ? printers.first : null);
}
if (mounted) {
setState(() {
_printers = printers;
_printer = selected;
_printersChecked = true;
});
}
// ── 逐张渲染预览(progressive)──
for (int i = 0; i < widget.labels.length; i++) {
final label = widget.labels[i];
try {
// 懒加载 QR
if (label.qrBytes == null &&
label.productId != null &&
widget.qrFetcher != null) {
label.qrBytes = await widget.qrFetcher!(label.productId!);
}
if (label.qrBytes == null) continue;
final png = await renderLabelPreview(label);
if (mounted && png != null) {
setState(() => _previews[i] = png);
}
} catch (e, st) {
debugPrint('[preview] 渲染第${i + 1}张失败: $e');
reportError(e, st);
}
}
}
int get _totalPrintCount {
int total = 0;
for (int i = 0; i < widget.labels.length; i++) {
if (_include[i]) total += widget.labels[i].copies;
}
return total;
}
Future<void> _doPrint() async {
if (_printing) return;
setState(() {
_printing = true;
_status = '正在打印...';
});
int done = 0;
final total = _totalPrintCount;
for (int i = 0; i < widget.labels.length; i++) {
if (!_include[i]) continue;
final label = widget.labels[i];
for (int c = 0; c < label.copies; c++) {
try {
// 确保 QR 已拉取
if (label.qrBytes == null &&
label.productId != null &&
widget.qrFetcher != null) {
label.qrBytes = await widget.qrFetcher!(label.productId!);
}
if (label.qrBytes == null) continue;
await printProductLabel(
qrBytes: label.qrBytes!,
name: label.name,
code: label.code,
spec: label.spec,
series: label.series,
batchNo: label.batchNo,
productionDate: label.productionDate,
remark: label.remark,
shopName: label.shopName,
shopAddress: label.shopAddress,
shopPhone: label.shopPhone,
printerName: _printer,
);
done++;
if (mounted) {
setState(() => _status = '已打印 $done/$total 张...');
}
} catch (e, st) {
reportError(e, st);
if (mounted) {
setState(() => _status = '${i + 1}张第${c + 1}份打印失败:$e');
}
}
}
}
if (mounted) {
setState(() {
_printing = false;
_status = '完成,共打印 $done';
});
Navigator.of(context).pop();
}
}
// ── 缩略图列表项 ─────────────────────────────────────────────────────────────
Widget _buildThumbnail(int i) {
final isSelected = _selectedIndex == i;
return GestureDetector(
onTap: () => setState(() => _selectedIndex = i),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
decoration: BoxDecoration(
border: Border.all(
color: isSelected ? AppTheme.primary : AppTheme.border,
width: isSelected ? 2 : 1,
),
borderRadius: BorderRadius.circular(6),
color: isSelected
? AppTheme.primary.withOpacity(0.04)
: AppTheme.surface,
),
child: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: Checkbox(
value: _include[i],
onChanged: _printing
? null
: (v) => setState(() => _include[i] = v ?? true),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
),
const SizedBox(width: 4),
SizedBox(
width: 76,
height: 38,
child: _previews[i] != null
? Image.memory(_previews[i]!, fit: BoxFit.contain)
: const Center(
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2))),
),
const SizedBox(width: 6),
Text(
'${i + 1}',
style: TextStyle(
fontSize: 12,
color: isSelected
? AppTheme.primary
: AppTheme.textSecondary,
fontWeight:
isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
),
);
}
// ── 大图 + 份数控件 ──────────────────────────────────────────────────────────
Widget _buildMainPreview() {
final preview = _previews[_selectedIndex];
final label = widget.labels[_selectedIndex];
return Column(
children: [
Expanded(
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 420, maxHeight: 230),
decoration: BoxDecoration(
border: Border.all(color: AppTheme.border),
borderRadius: BorderRadius.circular(4),
color: Colors.white,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: preview != null
? Image.memory(preview, fit: BoxFit.contain)
: const Center(child: CircularProgressIndicator()),
),
),
),
),
const SizedBox(height: 8),
// 份数调节
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('打印份数:', style: TextStyle(fontSize: 13)),
IconButton(
icon: const Icon(Icons.remove_circle_outline, size: 20),
onPressed: (_printing || label.copies <= 1)
? null
: () => setState(() => label.copies--),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 4),
SizedBox(
width: 32,
child: Text(
'${label.copies}',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w600),
),
),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.add_circle_outline, size: 20),
onPressed:
_printing ? null : () => setState(() => label.copies++),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
],
);
}
// ── build ────────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final isMulti = widget.labels.length > 1;
final totalCount = _totalPrintCount;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Container(
width: context.dialogWidth(isMulti ? 640 : 440),
constraints: const BoxConstraints(maxHeight: 560),
child: Column(
children: [
// ── 顶栏 ──────────────────────────────────────────────────────────
Container(
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: const BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: Row(
children: [
const Text('打印预览',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
),
// ── 打印机选择行 ──────────────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Row(
children: [
const Text('打印机:',
style: TextStyle(fontSize: 13)),
const SizedBox(width: 8),
Expanded(
child: _printers.isEmpty
? Text(
_printersChecked ? '无打印机可用' : '正在检测打印机...',
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary))
: DropdownButtonHideUnderline(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
border: Border.all(color: AppTheme.border),
borderRadius: BorderRadius.circular(6),
),
child: DropdownButton<String>(
value: _printer,
isExpanded: true,
isDense: true,
style: const TextStyle(
fontSize: 13,
color: AppTheme.textPrimary),
items: _printers
.map((p) => DropdownMenuItem(
value: p,
child: Text(p,
overflow:
TextOverflow.ellipsis),
))
.toList(),
onChanged: _printing
? null
: (v) async {
if (v == null) return;
setState(() => _printer = v);
final prefs = await SharedPreferences
.getInstance();
await prefs.setString(
_kPrinterKey, v);
},
),
),
),
),
],
),
),
const SizedBox(height: 8),
// ── 主体:缩略图条 + 大图 ─────────────────────────────────────────
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isMulti)
SizedBox(
width: 148,
child: ListView.builder(
itemCount: widget.labels.length,
itemBuilder: (_, i) => _buildThumbnail(i),
),
),
if (isMulti) const SizedBox(width: 8),
Expanded(child: _buildMainPreview()),
],
),
),
),
// ── 底栏 ─────────────────────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 14),
child: Row(
children: [
if (_status.isNotEmpty)
Expanded(
child: Text(
_status,
style: const TextStyle(
fontSize: 12, color: AppTheme.textSecondary),
),
)
else
const Spacer(),
TextButton(
onPressed:
_printing ? null : () => Navigator.of(context).pop(),
child: const Text('取消'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: (_printing || totalCount == 0 || _printer == null)
? null
: _doPrint,
child: Text(
_printing ? '打印中...' : '打印 $totalCount'),
),
],
),
),
],
),
),
);
}
}
+115 -55
View File
@@ -83,6 +83,8 @@ class _MultiSelectDialog extends StatefulWidget {
class _MultiSelectDialogState extends State<_MultiSelectDialog> {
late Set<String> _selected;
final _searchCtrl = TextEditingController();
String _search = '';
@override
void initState() {
@@ -90,40 +92,109 @@ class _MultiSelectDialogState extends State<_MultiSelectDialog> {
_selected = Set.from(widget.initial);
}
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final filtered = _search.isEmpty
? widget.options
: widget.options
.where((o) => o.toLowerCase().contains(_search.toLowerCase()))
.toList();
return AlertDialog(
title: Text(widget.label, style: const TextStyle(fontSize: 15)),
contentPadding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
contentPadding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
content: SizedBox(
width: 220,
width: 280,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 搜索框
TextField(
controller: _searchCtrl,
autofocus: true,
decoration: const InputDecoration(
hintText: '搜索…',
prefixIcon: Icon(Icons.search, size: 16),
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 8),
),
style: const TextStyle(fontSize: 13),
onChanged: (v) => setState(() => _search = v),
),
const SizedBox(height: 8),
// 已选标签区
if (_selected.isNotEmpty)
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 96),
child: SingleChildScrollView(
child: Wrap(
spacing: 6,
runSpacing: 4,
children: _selected.map((s) => Chip(
label: Text(s, style: const TextStyle(fontSize: 12)),
deleteIcon: const Icon(Icons.close, size: 14),
onDeleted: () => setState(() => _selected.remove(s)),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
backgroundColor: AppTheme.primary.withOpacity(0.08),
side: BorderSide(color: AppTheme.primary.withOpacity(0.3), width: 0.5),
labelPadding: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0),
)).toList(),
),
),
),
if (_selected.isNotEmpty) const SizedBox(height: 6),
// 全选 / 清空
Row(children: [
TextButton(
onPressed: () =>
setState(() => _selected = Set.from(widget.options)),
onPressed: () => setState(() => _selected.addAll(filtered)),
child: const Text('全选', style: TextStyle(fontSize: 12)),
),
TextButton(
onPressed: () => setState(() => _selected = {}),
onPressed: () => setState(() => _selected.clear()),
child: const Text('清空', style: TextStyle(fontSize: 12)),
),
]),
const Divider(height: 1),
...widget.options.map((opt) => CheckboxListTile(
title: Text(opt, style: const TextStyle(fontSize: 13)),
value: _selected.contains(opt),
dense: true,
onChanged: (v) => setState(() {
if (v == true) {
_selected.add(opt);
} else {
_selected.remove(opt);
}
}),
)),
// 选项列表(有约束 + 滚动)
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 280),
child: filtered.isEmpty
? const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Text('无匹配项',
style: TextStyle(
fontSize: 13, color: AppTheme.textSecondary)),
),
)
: ListView(
shrinkWrap: true,
children: filtered
.map((opt) => CheckboxListTile(
title: Text(opt,
style: const TextStyle(fontSize: 13)),
value: _selected.contains(opt),
dense: true,
onChanged: (v) => setState(() {
if (v == true) {
_selected.add(opt);
} else {
_selected.remove(opt);
}
}),
))
.toList(),
),
),
],
),
),
@@ -166,50 +237,39 @@ class FilterableColumnHeader extends StatefulWidget {
}
class _FilterableColumnHeaderState extends State<FilterableColumnHeader> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final active = widget.selected.isNotEmpty;
final showIcon = _hovered || active;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(widget.text),
Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedOpacity(
opacity: showIcon ? 1.0 : 0.0,
duration: const Duration(milliseconds: 120),
child: InkWell(
onTap: widget.options.isEmpty ? null : () => _show(context),
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: Icon(
active ? Icons.filter_alt : Icons.filter_alt_outlined,
size: 16,
color: active ? AppTheme.primary : AppTheme.textSecondary,
),
),
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(widget.text),
Row(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: widget.options.isEmpty ? null : () => _show(context),
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: Icon(
active ? Icons.filter_alt : Icons.filter_alt_outlined,
size: 16,
color: active ? AppTheme.primary : AppTheme.textSecondary,
),
),
if (active)
InkWell(
onTap: () => widget.onChanged({}),
borderRadius: BorderRadius.circular(4),
child: const Icon(Icons.cancel, size: 14,
color: AppTheme.primary),
),
],
),
],
),
),
if (active)
InkWell(
onTap: () => widget.onChanged({}),
borderRadius: BorderRadius.circular(4),
child: const Icon(Icons.cancel, size: 14,
color: AppTheme.primary),
),
],
),
],
);
}
@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<false/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.client</key>
+1 -1
View File
@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<false/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
+4 -4
View File
@@ -138,7 +138,7 @@ packages:
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
dependency: "direct main"
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
@@ -649,10 +649,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_android
sha256: a2c49fc1fed7140cadd892d765bd47edbe4ac0b9c7e7e3c493dcb58126f99cf0
sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5"
url: "https://pub.dev"
source: hosted
version: "2.4.25"
version: "2.4.26"
shared_preferences_foundation:
dependency: transitive
description:
@@ -851,7 +851,7 @@ packages:
source: hosted
version: "1.1.1"
win32:
dependency: transitive
dependency: "direct main"
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
+3
View File
@@ -26,6 +26,9 @@ dependencies:
pdf: ^3.10.8
web: ^1.1.0
image_picker: ^1.2.2
# Windows 热敏机 TSPL 裸发(winspool WritePrinter),仅 Windows 运行时调用
win32: ^5.5.0
ffi: ^2.1.3
dev_dependencies:
flutter_test:
@@ -95,6 +95,8 @@ class _FakeInventoryRepository extends InventoryRepository {
Future<PageResult<Inventory>> listInventory({
int? warehouseId,
String? keyword,
List<String>? series,
List<String>? spec,
int page = 1,
int pageSize = 50,
}) async {
+149
View File
@@ -0,0 +1,149 @@
# 库存查询「规格」「系列」列头筛选 — UI 变更规范
**变更类型**:简单变更,flutter-coder 可直接实现
**对应文件**`client/lib/screens/inventory/inventory_list_screen.dart`
---
## 复杂度判断
复用现有 `FilterableColumnHeader` 组件,实现方式与已有「仓库」列筛选完全一致,无新组件、无新页面、无复杂交互,属于简单变更。
---
## 变更详情
### [UI-001] 新增 State 变量
**位置**`_InventoryListScreenState` 类顶部,紧跟 `_filterWarehouse` 声明之后
```dart
Set<String> _filterWarehouse = {};
Set<String> _filterSpec = {}; // 新增:规格筛选
Set<String> _filterSeries = {}; // 新增:系列筛选
```
---
### [UI-002] 派生筛选选项
**位置**`_buildInventoryTab()` 方法内,`data:` 回调中,紧跟 `warehouseOptions``filteredItems` 的现有代码之后替换
**现有代码段(第 368-379 行):**
```dart
final warehouseOptions = items
.map((i) => i.warehouseName)
.where((s) => s.isNotEmpty)
.toSet()
.toList()
..sort();
final filteredItems = _filterWarehouse.isEmpty
? items
: items
.where((i) => _filterWarehouse.contains(i.warehouseName))
.toList();
```
**替换为:**
```dart
final warehouseOptions = items
.map((i) => i.warehouseName)
.where((s) => s.isNotEmpty)
.toSet()
.toList()
..sort();
final specOptions = items // 新增:规格选项
.map((i) => i.spec)
.where((s) => s.isNotEmpty)
.toSet()
.toList()
..sort();
final seriesOptions = items // 新增:系列选项
.map((i) => i.series)
.where((s) => s.isNotEmpty)
.toSet()
.toList()
..sort();
// 三个筛选叠加(AND 逻辑)
final filteredItems = items.where((i) {
if (_filterWarehouse.isNotEmpty && !_filterWarehouse.contains(i.warehouseName)) {
return false;
}
if (_filterSpec.isNotEmpty && !_filterSpec.contains(i.spec)) {
return false;
}
if (_filterSeries.isNotEmpty && !_filterSeries.contains(i.series)) {
return false;
}
return true;
}).toList();
```
**说明:**
- 选项数据从当前页已加载的 `items` 派生,客户端筛选,与「仓库」逻辑一致
- 三个筛选条件为 AND 叠加(同时满足才显示)
- 空集合(`isEmpty`)视为「不筛选该维度」,等同于全选
---
### [UI-003] 替换「规格」和「系列」列头
**位置**`columns:` 列表中第 491-494 行(当前为 `const DataColumn`
**现有代码:**
```dart
const DataColumn(label: Text('规格')),
const DataColumn(label: Text('系列')),
```
**替换为:**
```dart
DataColumn(
label: FilterableColumnHeader(
text: '规格',
options: specOptions,
selected: _filterSpec,
onChanged: (v) => setState(() => _filterSpec = v),
),
),
DataColumn(
label: FilterableColumnHeader(
text: '系列',
options: seriesOptions,
selected: _filterSeries,
onChanged: (v) => setState(() => _filterSeries = v),
),
),
```
**注意**:移除原来的 `const` 关键字,因为构造函数参数不再是编译期常量。
---
## 视觉规范(无需改动)
`FilterableColumnHeader` 的所有视觉行为保持原样:
| 状态 | 表现 |
|------|------|
| 默认(无筛选) | 纯文字列头,无图标 |
| Hover | 显示半透明 `filter_alt_outlined` 图标(灰色)|
| 已激活筛选 | 图标变为实心 `filter_alt`(主色 #1565C0+ 右侧出现 X 清除按钮 |
| X 按钮点击 | 清空该维度筛选(`onChanged({})` ),无需打开弹窗 |
| 列头点击图标 | 弹出 `_MultiSelectDialog`(全选/清空 + checkbox 列表 + 取消/应用) |
---
## 受影响范围
- 仅修改 `_InventoryListScreenState` 内部,不涉及 provider、model、API
- `_buildWarningTab()``_buildLogTab()` 不受影响
- 导出 Excel 的 `filteredItems` 自动受益于新筛选逻辑,无需额外改动
- 窄屏卡片流(`mobileCards`)同样使用 `filteredItems`,筛选效果自动生效
+162 -14
View File
@@ -131,6 +131,37 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
}
.reject-date { color: #ef9999; font-size: 12px; }
/* ── 确认闸(方案/改动说明)── */
.gate-block { margin: 10px 0 4px; padding: 10px 14px; border-radius: 8px; font-size: 13px; }
.gate-pending { background: #fff7ed; border: 1px solid #fdba74; }
.gate-granted { background: #f0fdf4; border: 1px solid #bbf7d0; }
.gate-info { background: #f1f5f9; border: 1px solid #e2e8f0; }
.gate-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
.gate-badge { padding: 2px 9px; border-radius: 10px; font-size: 11.5px; font-weight: 700; white-space: nowrap; }
.gate-badge.pending { background: #f97316; color: #fff; }
.gate-badge.granted { background: #22c55e; color: #fff; }
.gate-badge.info { background: #94a3b8; color: #fff; }
.gate-kind { font-size: 12px; color: #52606d; }
.gate-date { font-size: 12px; color: #8aa3c4; margin-left: auto; }
.gate-note { color: #52606d; margin: 4px 0; white-space: pre-wrap; }
.gate-ref { font-size: 12.5px; color: #52606d; margin-top: 4px; }
.gate-note code, .gate-ref code {
background: #fff; padding: 1px 5px; border-radius: 3px;
font-family: "JetBrains Mono", monospace; font-size: 12px; color: #b91c1c;
}
.approve-btn {
margin-top: 8px; padding: 5px 14px; border-radius: 6px; border: none;
background: #16a34a; color: #fff; font-size: 12.5px; font-weight: 600; cursor: pointer;
transition: background .15s;
}
.approve-btn:hover { background: #15803d; }
.approve-cmd { margin-top: 10px; padding: 10px 12px; background: #1e293b; border-radius: 8px; }
.approve-cmd-label { font-size: 12px; color: #94a3b8; margin-bottom: 6px; }
.approve-cmd-code { font-family: "JetBrains Mono", monospace; font-size: 13px; color: #86efac; display: block; word-break: break-all; }
.approve-copy-btn { margin-top: 8px; padding: 4px 12px; border-radius: 6px; background: #334155; color: #e2e8f0; border: none; font-size: 12px; cursor: pointer; }
.approve-copy-btn:hover { background: #475569; }
.stat-pill.gate-stat { background: #f97316; }
/* ── 子任务区 ── */
.subtask-block {
margin-top: 12px; padding: 10px 14px;
@@ -221,13 +252,14 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<header>
<div class="wrap">
<h1>酒库管理系统 — 项目 TODO</h1>
<div class="header-meta">生成于 2026-06-11 · 真相源 todo/todo.json</div>
<div class="header-meta">生成于 2026-06-14 · 真相源 todo/todo.json</div>
<div class="stats">
<div class="stat-pill"><strong>46</strong>全部</div>
<div class="stat-pill"><strong>47</strong>全部</div>
<div class="stat-pill"><strong>1</strong>待开始</div>
<div class="stat-pill"><strong>0</strong>开发中</div>
<div class="stat-pill"><strong>31</strong>待验收</div>
<div class="stat-pill"><strong>32</strong>待验收</div>
<div class="stat-pill"><strong>14</strong>已验收</div>
</div>
</div>
</header>
@@ -276,28 +308,29 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<ul class="todo-list" id="list-open">
<li class="todo-card s-open"
data-id="50"
data-id="51"
data-level="mid"
data-status="open"
data-tier="3"
data-tags="后端,数据库">
data-tier="1"
data-tags="iOS,CI/CD">
<div class="card-header">
<span class="item-title">License model 的 license_key 索引超 InnoDB 3072 字节限制需改用前缀索引</span>
<span class="item-title">iOS 上线 TestFlight 正式分发</span>
<div class="card-badges">
<span class="tag status-badge s-open">待开始</span>
<span class="tag t-high">重要</span>
<span class="tag tier-3"></span>
<span class="tag tier-1"></span>
</div>
</div>
<div class="item-desc">license.go 的 LicenseKey 是 size:2048+uniqueIndexutf8mb4 下 8192 字节超 MySQL InnoDB 索引上限,全新环境 AutoMigrate 必失败(线上已手动建 license_key(768) 前缀索引绕过)。需在 model 层固化:缩短列或 GORM 指定索引前缀长度,并同步 schema.sql</div>
<div class="item-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。暂不调度</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="数据库">数据库</span></div>
<div class="tag-row"><span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="CI/CD">CI/CD</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-11</span>
<span class="meta-date">🕐 2026-06-13</span>
</div>
</div>
@@ -318,7 +351,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
</div>
<div class="section-block" id="section-done">
<div class="section-title st-done" data-toggle="done">
🔍 待验收 <span class="s-count">31</span>
🔍 待验收 <span class="s-count">32</span>
<span class="s-arrow">▴ 收起</span>
</div>
<div class="section-list-wrap " id="list-wrap-done">
@@ -343,6 +376,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">新建入库单的商品行增加产地、保质期、储存方式三个字典下拉选项(已有字典数据)。必填项(商品名、数量等)正常显示;选填项默认隐藏,点击展开按钮后在下一行展示。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -371,6 +405,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">stock.go ApproveStockOut 中库存总量 SUM 在 FOR UPDATE 加锁前执行,存在 check-then-act 窗口,高并发下可超扣。需将预检 SUM 也放入事务并在加锁后执行</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -399,6 +434,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">partner.go/product_attr.go/warehouse.go 等多处用 db.Save(&amp;object) 更新全字段,若中间件被绕过会造成 shop_id 被覆盖。改为 db.Model(&amp;x).Where(&quot;id=? AND shop_id=?&quot;).Updates(fields) 白名单模式</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -427,6 +463,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">出库审批若 DB 断连,Rollback 后库存正确但财务记录可能未落地。需添加对账脚本:SUM(InventoryLog.quantity by direction) == SUM(Inventories.quantity),及告警机制</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -455,6 +492,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">service/stock.go:312 DB查询失败时sums为空,所有商品have=0,合法出库审核被全部拒绝</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -483,6 +521,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-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。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="CI/CD">CI/CD</span></div>
<div class="item-meta">
@@ -511,6 +550,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">当前 product_detail_screen.dart 显示内容过于简单。需先做页面设计(UI 方案),内容要求是公开扫码页的超集:包含商品基本信息、参数(产地/保质期/储存方式)、价格、描述文档、图片、库存批次、入库记录等管理侧专属字段。另:商品公开链接展示为可点击超链接(点击可跳转公开扫码页)。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -539,6 +579,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">Windows 和 macOS 客户端启动时检测是否已有实例运行;若有,则激活已有窗口并退出新进程。Flutter desktop 可通过 window_single_instance 或 dart:io 锁文件实现。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="Windows">Windows</span> <span class="tag t-tag" data-tag="mac">mac</span></div>
<div class="item-meta">
@@ -567,6 +608,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">licenses.device_id 已标记 deprecated 但激活/解绑逻辑仍在使用它,license_devices 表未被完整采用。需将 Activate/Deactivate 逻辑迁移至 license_devices,并写数据迁移脚本</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="数据库">数据库</span></div>
<div class="item-meta">
@@ -595,6 +637,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">checkInventory 实现在 handler/stock_out.go 中而非 service 层,导致单元测试困难、逻辑无法复用。应移至 StockService 或 InventoryService</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -623,6 +666,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">各 handler 返回格式不一致:有的 {error: err.Error()},有的 {error: 硬编码字符串},有的直接 struct。前端解析困难。需定义 ErrorResponse struct 和 RespondError/RespondSuccess 辅助函数统一调用</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -651,6 +695,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">config.go 默认 CORSOrigin=&quot;*&quot;,注释提示生产需改但无代码强制。应在 main.go 中添加:server.mode!=debug 且 CORSOrigin==&quot;*&quot; 时 log.Fatal 拒绝启动</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -679,6 +724,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">license.go 中私钥未配置只打 log 并跳过,导致授权功能失效但程序正常运行。应改为 log.Fatal,确保运维人员知晓配置缺失</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -707,6 +753,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">inventory.go 限制 pageSize 最大 500,其他 handler 无此限制,前端可传 pageSize=10000 打满 DB。创建 util.ValidatePageSize(n) 统一处理</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -735,6 +782,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">finance.go:36 和 inventory.go:130 漏加 ValidatePageSize#37 未覆盖这两个 handler,可被任意用户触发全表扫描</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -763,6 +811,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">admin.go:122-123 invQuery/logQuery Scan错误被丢弃;invQuery失败→差异消失显示全部正常;logQuery失败→所有库存飘红为差异</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -791,6 +840,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">handler/license.go:67-70 两个bug1.devs,_ 错误被_吞掉DB故障时deviceCount=02.ListDevices(shopID)包含该店所有license设备而非当前license</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -800,6 +850,35 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
</div>
</li>
<li class="todo-card s-done"
data-id="50"
data-level="mid"
data-status="done"
data-tier="3"
data-tags="后端,数据库">
<div class="card-header">
<span class="item-title">License model 的 license_key 索引超 InnoDB 3072 字节限制需改用前缀索引</span>
<div class="card-badges">
<span class="tag status-badge s-done">待验收</span>
<span class="tag t-high">重要</span>
<span class="tag tier-3">三级</span>
<button class="reject-btn" data-id="50" data-title="License model 的 license_key 索引超 InnoDB 3072 字节限制需改用前缀索引">拒绝验收</button>
</div>
</div>
<div class="item-desc">license.go 的 LicenseKey 是 size:2048+uniqueIndexutf8mb4 下 8192 字节超 MySQL InnoDB 索引上限,全新环境 AutoMigrate 必失败(线上已手动建 license_key(768) 前缀索引绕过)。需在 model 层固化:缩短列或 GORM 指定索引前缀长度,并同步 schema.sql。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="数据库">数据库</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-11</span>
</div>
</div>
</li>
<li class="todo-card s-done"
data-id="2"
data-level="low"
@@ -819,6 +898,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">现为沪ICP备2026000000号(全0),上线前必须替换真实备案号。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
@@ -846,6 +926,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
</div>
<div class="item-desc">完整方案设计,涵盖:界面设计与交互、前端实现方案、后端接口与数据模型、安全策略(防盗用、防滥用、授权校验)等。需产出设计文档后再进入开发阶段。</div>
<div class="subtask-block">
<div class="subtask-header">
<span class="subtask-label">子任务</span>
@@ -929,6 +1010,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">1. 客户端「关于我们」页面的「打开文档」「更新日志」链接指向需更新为正确地址。2. Web 营销站新增版本更新页面(/changelog 或 /download/),从 CHANGELOG.md 自动渲染,展示所有历史版本更新记录。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span> <span class="tag t-tag" data-tag="文档">文档</span></div>
<div class="item-meta">
@@ -957,6 +1039,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">当前注册成功页和网站联系支持入口引导用户「发邮件」,体验较弱。升级为扫码加客服微信,同时梳理相关文案措辞,使表达更专业、有亲和力。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
@@ -985,6 +1068,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">在营销网站(jiu.51yanmei.com)补充服务条款(Terms of Service)和隐私政策(Privacy Policy)页面,注册页底部添加链接。最低优先级,待其他功能稳定后处理。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="Web">Web</span> <span class="tag t-tag" data-tag="文档">文档</span></div>
<div class="item-meta">
@@ -1013,6 +1097,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">窄屏(手机)模式下,导航 Drawer 当前需点击汉堡按钮打开,需支持从屏幕左边缘向右滑动手势唤出菜单,符合移动端操作习惯。涉及 app_shell.dart 的 Drawer 配置。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -1041,6 +1126,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">当前入库单详情的商品明细以横向表格展示,字段多时在窄屏上挤压严重。改为每件商品独占一个卡片区块,字段竖向排列(商品名、规格、数量、单价、产地、保质期等各占一行),信息层次清晰,移动端阅读体验更好。宽屏可保持表格,窄屏自动切换为卡片流(复用 MobileListCard 模式)。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -1069,6 +1155,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">main.go 每次启动执行 backfillPinyin 全表扫描,数据量大后拖慢启动。改为 go run ./cmd/migrate-pinyin 一次性迁移,后续新数据靠写入时自动生成</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -1097,6 +1184,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">main.go 中硬编码 MaxIdleConns=10, MaxOpenConns=100,无法运维调优。改为读取 config(viper),并在文档中说明推荐值</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -1125,6 +1213,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">inventories 表存了 product_name/warehouse_name/supplier_name 等,既可能是冗余(与 products JOIN 重复),也可能是历史快照(入库时锁定)。需明确语义并在 model 注释中说明;若是快照,应只在写入时固定,不随主表变化</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="数据库">数据库</span></div>
<div class="item-meta">
@@ -1153,6 +1242,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">service/license.go:70 s.db.Model(&amp;existing).Update()失败时仍返回nil error,设备名未更新但调用方不知道</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -1181,6 +1271,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">service/license.go:102 WHERE id=? AND is_active=1 应加 AND shop_id=? 防御数据异常时跨租户泄露</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -1209,6 +1300,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">#34 只创建了util/response.go,没有将任何handler迁移过去,实际错误格式仍不统一,需完成迁移</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -1237,6 +1329,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">service/license.go:24 ErrDeviceMismatch定义后从未返回,多设备迁移(#32)后已无单设备约束</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div>
<div class="item-meta">
@@ -1275,6 +1368,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">Flutter _AuthenticityCard 读取 batch['quantity'] 但后端 public.go batchData 未返回 quantity。另 product code/barcode 未暴露,参数卡无法显示商品编码。handler/public.go GetProduct</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -1303,6 +1397,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">提示「忘记门店编码可在「系统设置→关于」查看」,需与实际客户端菜单路径一致。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
@@ -1331,6 +1426,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">njk 站点用 footer.njk 读变量,功能页把页脚硬编码各写一份,改一处不同步。根因同「两套模板」。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
@@ -1359,6 +1455,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">用 window.location.origin+'/api/v1/public/register',要求同源部署;分离部署时指错。需确认部署形态。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
@@ -1387,6 +1484,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">products_screen.dart 基础数据管理页增加产地/保质期/储存方式/描述文档 4 个维护 Tab,复用现有 option 维护组件(增删改查);描述文档 Tab 含 title+content 多行输入</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -1415,6 +1513,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">public_product_screen.dart _Footer 底部两个链接无 url/action,点击静默。需设计方案:跳转反馈表单、弹出联系方式、或发邮件。line:1353-1355</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -1443,6 +1542,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">product model 有 sale_price 字段但 public.go GetProduct 未返回。需:1) 后端 batchData/productData 加 sale_price2) Flutter _TitleBlock 或单独区块展示建议零售价。client: public_product_screen.dart, backend: handler/public.go</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -1471,6 +1571,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">PublicProductScreen _ShopCard「添加门店微信」按钮 onPressed: () {} 为空。需设计方案:门店维护微信号字段 or 二维码图片,扫码页展示。涉及后端 shop model 扩展 + 前端展示。client/lib/screens/public/public_product_screen.dart:1255</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -1499,6 +1600,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">PublicProductScreen _ShopCard 底部按钮 onPressed: () {} 为空,需实现跳转至该门店商品列表页。client/lib/screens/public/public_product_screen.dart:1240</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -1527,6 +1629,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">stock_in_form_screen.dart 生产日期字段附近增加产地/保质期/储存方式/描述文档 4 个下拉选择(数据来自字典 List 接口);FindOrCreate/Update 接受并写入 4 个 id;前端新增对应 API client 方法</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -1555,6 +1658,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">public.go GetProduct Preload 4 张新表 + 三级回退(描述文档→旧Description→通用中性兜底)+ 默认值常量(保质期/储存方式);Flutter public_product_screen.dart _ParamsCard/_DescriptionSection 改读真实数据,移除茅台 mock</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
@@ -1583,6 +1687,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-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/ 下</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="数据库">数据库</span></div>
<div class="item-meta">
@@ -1611,6 +1716,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">features/scan.html 直接复制,不经 njk 模板,页脚/备案/CSS 都得手动同步。建议长期改造为模板化。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
@@ -1639,6 +1745,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="item-desc">主站 njk 用 color.css+style.css,功能页用 colors_and_type.css,导航/页脚各写一份,维护分裂。建议合并到一套,功能页走 base.njk 模板。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
@@ -1680,6 +1787,16 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
let curTier = 'all';
let curTags = new Set();
// 通过本地服务(http)打开时,按钮直接写库;用 file:// 直接打开则回退为「复制命令」
const SERVED = location.protocol === 'http:' || location.protocol === 'https:';
function postAction(url, payload) {
return fetch(url, {
method: 'POST',
headers: payload ? { 'Content-Type': 'application/json' } : undefined,
body: payload ? JSON.stringify(payload) : undefined,
}).then(r => r.json());
}
function applyFilter() {
document.querySelectorAll('.todo-card').forEach(card => {
const lvl = card.dataset.level;
@@ -1800,8 +1917,13 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
reasonInput.focus();
return;
}
const escaped = reason.replace(/\/g, '\\').replace(/"/g, '\"');
const cmd = '/todo reject ' + rejectId + ' ' + escaped;
if (SERVED) {
postAction('/api/reject/' + rejectId, { reason })
.then(d => { if (d.ok) { location.reload(); } else { alert('拒绝失败:' + (d.error || '')); } })
.catch(() => alert('请求失败,确认本地服务是否在运行'));
return;
}
const cmd = '/todo reject ' + rejectId + ' ' + JSON.stringify(reason);
cmdText.textContent = cmd;
cmdWrap.style.display = 'block';
document.getElementById('reject-confirm-btn').style.display = 'none';
@@ -1819,6 +1941,32 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
copyBtn.textContent = '已复制 ✓';
});
});
// ── 确认闸:服务模式直接写库;file:// 模式回退为展开命令 ──
document.querySelectorAll('.approve-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const id = btn.dataset.id;
if (SERVED) {
btn.disabled = true; btn.textContent = '确认中…';
postAction('/api/approve/' + id)
.then(d => {
if (d.ok) { location.reload(); }
else { btn.disabled = false; btn.textContent = '确认执行 ✓'; alert('确认失败:' + (d.error || '')); }
})
.catch(() => { btn.disabled = false; btn.textContent = '确认执行 ✓'; alert('请求失败,确认本地服务是否在运行'); });
} else {
const box = document.getElementById('approve-cmd-' + id);
if (box) box.style.display = box.style.display === 'none' ? 'block' : 'none';
}
});
});
document.querySelectorAll('.approve-copy-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
navigator.clipboard.writeText(btn.dataset.cmd).then(() => { btn.textContent = '已复制 ✓'; });
});
});
})();
</script>
</body>
+19 -3
View File
@@ -1,9 +1,9 @@
{
"meta": {
"title": "酒库管理系统 — 项目 TODO",
"updated_at": "2026-06-11T15:52:40.354Z"
"updated_at": "2026-06-14T00:13:09.458Z"
},
"seq": 50,
"seq": 51,
"items": [
{
"id": 1,
@@ -795,11 +795,27 @@
"后端",
"数据库"
],
"status": "open",
"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
}
]
}