Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b02a848c0 | |||
| bde2d35756 | |||
| 4ac5adac3e | |||
| 8c2dcf49a8 | |||
| f400459497 | |||
| 64ac17d6b5 | |||
| a5bdb79f81 | |||
| c4b4259a3d | |||
| 68e0ca30ad | |||
| a8c684ad0c | |||
| 0779bf57e3 | |||
| d26a64f149 | |||
| 104b98f4ac | |||
| 2d01e5d8cf | |||
| 01c7d7a416 | |||
| 36564571fa | |||
| eac3e8825c | |||
| 316e9b6fc9 | |||
| 2ed0010c67 | |||
| f251953dbc |
@@ -52,3 +52,10 @@ scripts/.logs/
|
||||
# 测试覆盖率
|
||||
*.out
|
||||
coverage/
|
||||
|
||||
# 编译产物(后端可执行文件)
|
||||
backend/issue
|
||||
|
||||
# Claude 私有文件
|
||||
.claude/commands/
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -5,6 +5,83 @@ 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.46] - 2026-06-15
|
||||
|
||||
### 修复
|
||||
- 微信内打开商品页时顶部标题栏显示商品名称,不再显示"岩美"
|
||||
|
||||
## [1.0.45] - 2026-06-15
|
||||
|
||||
### 修复
|
||||
- 修复微信分享卡片标题仍显示"岩美"的问题(HTML 缩进导致字符串匹配失败)
|
||||
- 微信分享卡片描述去掉"扫码验真"后缀
|
||||
|
||||
## [1.0.44] - 2026-06-15
|
||||
|
||||
### 改进
|
||||
- 微信分享卡片标题改为商品名称(之前仍显示"岩美")
|
||||
- 微信分享卡片描述去掉"扫码验真"后缀
|
||||
|
||||
## [1.0.43] - 2026-06-15
|
||||
|
||||
### 修复
|
||||
- 修复微信/飞书分享商品链接时卡片只显示"岩美"而不显示商品名称和图片的问题
|
||||
|
||||
## [1.0.42] - 2026-06-15
|
||||
|
||||
### 修复
|
||||
- 修复商品二维码扫码页报"index.html not found"的错误
|
||||
|
||||
## [1.0.41] - 2026-06-15
|
||||
|
||||
### 改进
|
||||
- 标签打印布局优化:编号字体与度数/日期统一大小,内容整体下移并填满标签底部,店名适当放大
|
||||
- 标签度数与规格之间加宽间距,两个字段更易区分
|
||||
|
||||
## [1.0.40] - 2026-06-15
|
||||
|
||||
### 新功能
|
||||
- 商品标签打印新增编号行(显示「编号:xxxx」),位于度数上方,方便核对入库单
|
||||
- 扫码分享商品链接时,微信/企业微信/飞书/钉钉等平台的分享卡片现可显示商品名、规格描述及商品图片,不再只显示「岩美」
|
||||
|
||||
### 改进
|
||||
- 库存页表格支持横向滚动,列过多时不再截断;支持显示/隐藏列,按需定制显示内容
|
||||
|
||||
## [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
|
||||
|
||||
### 改进
|
||||
|
||||
@@ -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`
|
||||
@@ -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)
|
||||
}
|
||||
@@ -43,6 +43,7 @@ type StorageConfig struct {
|
||||
UploadDir string `mapstructure:"upload_dir"`
|
||||
BaseURL string `mapstructure:"base_url"`
|
||||
PublicURL string `mapstructure:"public_url"` // 商品公开页基础 URL,用于生成二维码
|
||||
WebDir string `mapstructure:"web_dir"` // Flutter web 构建产物目录,用于 OG 标签注入
|
||||
}
|
||||
|
||||
var C Config
|
||||
@@ -66,6 +67,7 @@ func Load() {
|
||||
_ = viper.BindEnv("storage.upload_dir", "STORAGE_UPLOAD_DIR")
|
||||
_ = viper.BindEnv("storage.base_url", "STORAGE_BASE_URL")
|
||||
_ = viper.BindEnv("storage.public_url", "STORAGE_PUBLIC_URL")
|
||||
_ = viper.BindEnv("storage.web_dir", "STORAGE_WEB_DIR")
|
||||
|
||||
// 默认值
|
||||
viper.SetDefault("server.port", "8080")
|
||||
@@ -78,6 +80,7 @@ func Load() {
|
||||
viper.SetDefault("storage.upload_dir", "./uploads/images")
|
||||
viper.SetDefault("storage.base_url", "http://localhost:8080/images")
|
||||
viper.SetDefault("storage.public_url", "http://localhost:8081")
|
||||
viper.SetDefault("storage.web_dir", "./web")
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
log.Println("[config] no config file found, using defaults and env vars")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -3,13 +3,17 @@ package handler
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
@@ -274,3 +278,103 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) {
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// ProductPage GET /product/:public_id
|
||||
// 返回注入了基础 Open Graph 标签的 Flutter index.html,供微信/飞书等社交平台爬虫生成分享卡片。
|
||||
// 找不到商品时原样返回 index.html,让 Flutter 自行展示"商品不存在";爬虫拿不到 OG 标签但页面不报错。
|
||||
func (h *PublicHandler) ProductPage(c *gin.Context) {
|
||||
publicID := c.Param("public_id")
|
||||
|
||||
// 读取 Flutter 构建产物 index.html
|
||||
idxPath := config.C.Storage.WebDir + "/index.html"
|
||||
idxBytes, err := os.ReadFile(idxPath)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "index.html not found: %s", idxPath)
|
||||
return
|
||||
}
|
||||
idxHTML := string(idxBytes)
|
||||
|
||||
// 查商品(只取 OG 所需字段,轻量查询)
|
||||
var product model.Product
|
||||
if err := h.db.Select("id, public_id, name, brand, series, spec, shop_id").
|
||||
Where("public_id = ? AND deleted_at IS NULL", publicID).
|
||||
Preload("Images").
|
||||
First(&product).Error; err != nil {
|
||||
// 查不到商品:原样返回 index.html,让 Flutter 展示"商品不存在"
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", idxBytes)
|
||||
return
|
||||
}
|
||||
|
||||
// 查门店名
|
||||
var shop model.Shop
|
||||
shopName := ""
|
||||
if err := h.db.Select("name").Where("id = ?", product.ShopID).First(&shop).Error; err == nil {
|
||||
shopName = shop.Name
|
||||
}
|
||||
|
||||
// 构造 OG 标签并注入 </head> 前
|
||||
ogTags := buildProductOG(product, shopName, config.C.Storage.PublicURL)
|
||||
ogTitle := buildOGTitle(product)
|
||||
// 同时替换 <title> 标签,微信/飞书等平台有时优先读 <title> 而非 og:title
|
||||
// 用正则替换避免缩进空格导致字符串不匹配
|
||||
titleRe := regexp.MustCompile(`<title>[^<]*</title>`)
|
||||
out := titleRe.ReplaceAllString(idxHTML, "<title>"+html.EscapeString(ogTitle)+"</title>")
|
||||
out = strings.Replace(out, "</head>", ogTags+"</head>", 1)
|
||||
// 禁止缓存,确保爬虫每次都能拿到最新 OG 标签
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(out))
|
||||
}
|
||||
|
||||
// buildOGTitle 生成商品的分享标题:品牌 + 商品名(品牌已含在名字中时不重复)+ 系列。
|
||||
func buildOGTitle(product model.Product) string {
|
||||
title := product.Name
|
||||
if product.Brand != "" && !strings.Contains(product.Name, product.Brand) {
|
||||
title = product.Brand + title
|
||||
}
|
||||
if product.Series != "" {
|
||||
title = title + "(" + product.Series + ")"
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
// buildProductOG 构造基础 Open Graph meta 标签字符串。
|
||||
// 所有值经 html.EscapeString 转义,防止 XSS 或破坏 HTML 结构。
|
||||
// 无图片时不输出 og:image 行。
|
||||
func buildProductOG(product model.Product, shopName, publicURL string) string {
|
||||
title := buildOGTitle(product)
|
||||
|
||||
// og:description:规格(度数/香型/容量)| 门店名正品
|
||||
desc := product.Spec
|
||||
suffix := "正品"
|
||||
if shopName != "" {
|
||||
suffix = shopName + suffix
|
||||
}
|
||||
if desc != "" {
|
||||
desc = desc + " | " + suffix
|
||||
} else {
|
||||
desc = suffix
|
||||
}
|
||||
|
||||
// og:site_name
|
||||
siteName := "岩美酒库"
|
||||
if shopName != "" {
|
||||
siteName = shopName + " · " + siteName
|
||||
}
|
||||
|
||||
// og:url
|
||||
pageURL := publicURL + "/product/" + product.PublicID
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(` <meta property="og:type" content="website">` + "\n")
|
||||
sb.WriteString(` <meta property="og:site_name" content="` + html.EscapeString(siteName) + `">` + "\n")
|
||||
sb.WriteString(` <meta property="og:url" content="` + html.EscapeString(pageURL) + `">` + "\n")
|
||||
sb.WriteString(` <meta property="og:title" content="` + html.EscapeString(title) + `">` + "\n")
|
||||
sb.WriteString(` <meta property="og:description" content="` + html.EscapeString(desc) + `">` + "\n")
|
||||
// og:image:仅有图片时输出
|
||||
if len(product.Images) > 0 && product.Images[0].URL != "" {
|
||||
imgURL := publicURL + product.Images[0].URL
|
||||
sb.WriteString(` <meta property="og:image" content="` + html.EscapeString(imgURL) + `">` + "\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
// 版本信息(无需认证,用于客户端更新检查)
|
||||
r.GET("/version", handler.GetVersion)
|
||||
|
||||
// 公开商品详情页(注入 OG 标签的 Flutter index.html,供社交分享爬虫读取)
|
||||
r.GET("/product/:public_id", publicH.ProductPage)
|
||||
|
||||
v1 := r.Group("/api/v1")
|
||||
|
||||
// 公开路由(无需登录)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 49152–65535.
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -22,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');
|
||||
@@ -188,21 +189,16 @@ double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
|
||||
return size;
|
||||
}
|
||||
|
||||
/// 桌面端把「扁平标签」直接画成单色位图,TSPL BITMAP 裸发到热敏机。
|
||||
/// 版式(无样式):酒行名 / 酒名(长名缩小) / 度数(系列)+规格 / 日期;右侧二维码 + 扫码溯源。
|
||||
/// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。
|
||||
Future<bool> _printFlatLabelThermal({
|
||||
/// 用 dart:ui 把一张标签绘制成 320×160 位图(热敏标签实际尺寸 40×20mm @203dpi)。
|
||||
/// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。
|
||||
Future<ui.Image> _renderLabelBitmap({
|
||||
required String shop,
|
||||
required String name,
|
||||
required String code,
|
||||
required String degSpec,
|
||||
required String date,
|
||||
required Uint8List qrBytes,
|
||||
}) async {
|
||||
if (kIsWeb || !(Platform.isMacOS || Platform.isWindows)) return false;
|
||||
final printer = await _findThermalPrinter();
|
||||
debugPrint('[label] thermal printer = $printer');
|
||||
if (printer == null) return false;
|
||||
|
||||
const w = 320, h = 160; // 40×20mm @203dpi
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = ui.Canvas(
|
||||
@@ -228,27 +224,64 @@ Future<bool> _printFlatLabelThermal({
|
||||
// 扫码溯源:居中于二维码正下方
|
||||
_drawText(canvas, '扫码溯源', qrLeft, qrTop + qrS + 2, 14, qrS, center: true);
|
||||
|
||||
// 左侧文字(无字段名标签):按行等比分配高度铺满,字号自适应填充
|
||||
// 左侧文字:固定字号 + 自适应行间距(与二维码上下对齐,行距均匀分配剩余空间)
|
||||
const lx = 22.0;
|
||||
const textMaxW = qrLeft - lx - 6; // 164
|
||||
|
||||
// 字号固定:只有商品名按宽度自适应,其余行均固定,杜绝自动放大
|
||||
const double kShopSize = 17.0; // 酒行名,粗体
|
||||
const double kSmallSize = 13.0; // 编号 / 度数 / 日期,统一小字
|
||||
// 商品名:仅按宽度适配,上限 26px,下限 14px
|
||||
final double nameSize = _fitFont(name, textMaxW, 1000, true, 26.0).clamp(14.0, 26.0);
|
||||
|
||||
final codeText = code.isNotEmpty ? '编号:$code' : '';
|
||||
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),
|
||||
if (shop.isNotEmpty) (shop, kShopSize, true),
|
||||
if (name.isNotEmpty) (name, nameSize, true),
|
||||
if (codeText.isNotEmpty) (codeText, kSmallSize, false),
|
||||
if (degSpec.isNotEmpty) (degSpec, kSmallSize, false),
|
||||
if (date.isNotEmpty) (date, kSmallSize, false),
|
||||
];
|
||||
const topY = 4.0, botY = 156.0;
|
||||
final totalWeight = rows.fold<double>(0, (a, r) => a + r.$2);
|
||||
double yy = topY;
|
||||
|
||||
// 文字区域:整体略低于二维码顶部,底部延伸至扫码溯源文字下方
|
||||
const double kTextTop = 16.0; // 比二维码顶部(8)低一些,整体下移
|
||||
const double kTextBot = 150.0; // 延伸至底部,减少留白
|
||||
const double kLineH = 1.35; // 行高倍数(字号 × 行高 = 该行占用高度)
|
||||
final double totalTextH = rows.fold(0.0, (s, r) => s + r.$2 * kLineH);
|
||||
final int n = rows.length;
|
||||
final double rawGap = n > 0 ? (kTextBot - kTextTop - totalTextH) / (n + 1) : 4.0;
|
||||
final double gap = rawGap < 1.5 ? 1.5 : rawGap;
|
||||
|
||||
double yy = kTextTop + gap;
|
||||
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;
|
||||
_drawText(canvas, r.$1, lx, yy, r.$2, textMaxW, bold: r.$3);
|
||||
yy += r.$2 * kLineH + gap;
|
||||
}
|
||||
|
||||
final img = await recorder.endRecording().toImage(w, h);
|
||||
return recorder.endRecording().toImage(w, h);
|
||||
}
|
||||
|
||||
/// 桌面端把「扁平标签」直接画成单色位图,TSPL BITMAP 裸发到热敏机。
|
||||
/// 版式(无样式):酒行名 / 酒名(长名缩小) / 度数(系列)+规格 / 日期;右侧二维码 + 扫码溯源。
|
||||
/// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。
|
||||
/// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。
|
||||
Future<bool> _printFlatLabelThermal({
|
||||
required String shop,
|
||||
required String name,
|
||||
required String code,
|
||||
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, code: code, degSpec: degSpec, date: date, qrBytes: qrBytes);
|
||||
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||
final rgba = bd!.buffer.asUint8List();
|
||||
|
||||
@@ -290,6 +323,7 @@ Future<void> printProductLabelImpl({
|
||||
String shopName = '',
|
||||
String shopAddress = '',
|
||||
String shopPhone = '',
|
||||
String? printerName,
|
||||
}) async {
|
||||
try {
|
||||
final font = await _loadFont();
|
||||
@@ -305,7 +339,7 @@ Future<void> printProductLabelImpl({
|
||||
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
|
||||
final seriesVal = (series ?? '').isNotEmpty ? series! : '';
|
||||
// 度数(系列) + 规格 同一行
|
||||
final degSpecVal = [seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
|
||||
final degSpecVal = [seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
|
||||
final dateVal = (productionDate ?? '').isNotEmpty
|
||||
? (productionDate!.length > 10 ? productionDate.substring(0, 10) : productionDate)
|
||||
: '—';
|
||||
@@ -314,9 +348,11 @@ Future<void> printProductLabelImpl({
|
||||
if (await _printFlatLabelThermal(
|
||||
shop: shopName,
|
||||
name: name,
|
||||
code: code,
|
||||
degSpec: degSpecVal,
|
||||
date: dateVal == '—' ? '' : dateVal, // 无生产日期(商品详情)则不显示该行
|
||||
qrBytes: qrBytes)) {
|
||||
qrBytes: qrBytes,
|
||||
printerName: printerName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -379,6 +415,12 @@ Future<void> printProductLabelImpl({
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: _ink, letterSpacing: 0.3)),
|
||||
pw.SizedBox(height: 2.5),
|
||||
// 商品编号(度数上方)
|
||||
if (code.isNotEmpty) ...[
|
||||
pw.Text('编号:$code',
|
||||
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
|
||||
pw.SizedBox(height: 1.5),
|
||||
],
|
||||
// 度数(系列) + 规格 同一行, 无字段名标签
|
||||
pw.Text(degSpecVal,
|
||||
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
|
||||
@@ -449,6 +491,64 @@ Future<void> printProductLabelImpl({
|
||||
}
|
||||
}
|
||||
|
||||
/// 渲染标签预览图(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,
|
||||
code: label.code,
|
||||
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,
|
||||
|
||||
@@ -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,6 +28,7 @@ Future<void> printProductLabelImpl({
|
||||
String shopName = '',
|
||||
String shopAddress = '',
|
||||
String shopPhone = '',
|
||||
String? printerName,
|
||||
}) async {
|
||||
final base64Img = base64Encode(qrBytes);
|
||||
|
||||
@@ -279,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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -12,11 +12,14 @@ import '../../core/auth/auth_state.dart';
|
||||
import '../../providers/inventory_provider.dart';
|
||||
import '../../widgets/data_table_card.dart';
|
||||
import '../../widgets/mobile_list_card.dart';
|
||||
import '../../widgets/multi_select_dropdown.dart' show FilterableColumnHeader;
|
||||
import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, 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,27 @@ class InventoryListScreen extends ConsumerStatefulWidget {
|
||||
class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
final _searchCtrl = TextEditingController();
|
||||
Set<String> _filterWarehouse = {};
|
||||
Set<String> _filterSpec = {};
|
||||
Set<String> _filterSeries = {};
|
||||
Set<String> _hiddenCols = {};
|
||||
|
||||
static const _colDefs = [
|
||||
ColDef('code', '商品编码', required: true),
|
||||
ColDef('name', '商品名称', required: true),
|
||||
ColDef('spec', '规格'),
|
||||
ColDef('series', '系列'),
|
||||
ColDef('batch', '批次号'),
|
||||
ColDef('warehouse', '仓库'),
|
||||
ColDef('qty', '库存量'),
|
||||
ColDef('price', '单价'),
|
||||
ColDef('prodDate', '生产日期'),
|
||||
ColDef('inTime', '入库时间'),
|
||||
ColDef('supplier', '供应商'),
|
||||
ColDef('remark', '备注'),
|
||||
ColDef('status', '状态'),
|
||||
ColDef('actions', '操作', required: true),
|
||||
];
|
||||
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -196,22 +220,109 @@ 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]),
|
||||
);
|
||||
}
|
||||
|
||||
/// 库存查询:宽屏表格单元格(按列 key 返回对应 DataCell)
|
||||
DataCell _buildInventoryCell(String key, Inventory item, BuildContext context) {
|
||||
return switch (key) {
|
||||
'code' => DataCell(Text(
|
||||
item.productCode.isEmpty ? '-' : item.productCode,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))),
|
||||
'name' => DataCell(item.productId != null
|
||||
? GestureDetector(
|
||||
onTap: () => context.push('/products/${item.productId}'),
|
||||
child: SizedBox(
|
||||
width: 180,
|
||||
child: Text(
|
||||
item.productName.isEmpty ? '-' : item.productName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: AppTheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(item.productName.isEmpty ? '-' : item.productName,
|
||||
overflow: TextOverflow.ellipsis)),
|
||||
'spec' => DataCell(Text(item.spec.isEmpty ? '-' : item.spec)),
|
||||
'series' => DataCell(Text(item.series.isEmpty ? '-' : item.series)),
|
||||
'batch' => DataCell(Text(item.batchNo.isEmpty ? '-' : item.batchNo,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12))),
|
||||
'warehouse' => DataCell(Text(item.warehouseName.isEmpty ? '-' : item.warehouseName)),
|
||||
'qty' => DataCell(Text(
|
||||
'${item.quantity.toStringAsFixed(0)} ${item.unit}'.trim(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: item.quantity == 0
|
||||
? AppTheme.danger
|
||||
: (item.minStock != null && item.quantity < item.minStock!)
|
||||
? AppTheme.accent
|
||||
: AppTheme.textPrimary))),
|
||||
'price' => DataCell(Text(
|
||||
item.unitPrice != null ? '¥${item.unitPrice!.toStringAsFixed(2)}' : '-')),
|
||||
'prodDate' => DataCell(Text(item.productionDate ?? '-')),
|
||||
'inTime' => DataCell(Text(
|
||||
item.createdAt != null && item.createdAt!.length >= 10
|
||||
? item.createdAt!.substring(0, 10)
|
||||
: '-',
|
||||
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary))),
|
||||
'supplier' => DataCell(Text(item.supplierName.isEmpty ? '-' : item.supplierName)),
|
||||
'remark' => DataCell(Tooltip(
|
||||
message: item.remark.isEmpty ? '' : item.remark,
|
||||
waitDuration: const Duration(milliseconds: 300),
|
||||
child: GestureDetector(
|
||||
onTap: () => _editRemark(context, item),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
item.remark.isEmpty
|
||||
? '—'
|
||||
: item.remark.length > 4
|
||||
? '${item.remark.substring(0, 4)}…'
|
||||
: item.remark,
|
||||
style: TextStyle(
|
||||
color: item.remark.isEmpty
|
||||
? AppTheme.textSecondary
|
||||
: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.edit_outlined, size: 12, color: AppTheme.textSecondary),
|
||||
],
|
||||
),
|
||||
))),
|
||||
'status' => DataCell(_InventoryStatusBadge(item)),
|
||||
'actions' => DataCell(item.productId != null
|
||||
? TextButton(
|
||||
onPressed: () => _printLabel(context, item),
|
||||
child: const Text('打标签',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
)
|
||||
: const SizedBox()),
|
||||
_ => const DataCell(SizedBox()),
|
||||
};
|
||||
}
|
||||
|
||||
/// 库存查询:窄屏卡片
|
||||
@@ -365,18 +476,27 @@ 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();
|
||||
|
||||
final visibleCols =
|
||||
_colDefs.where((c) => !_hiddenCols.contains(c.key)).toList();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -440,7 +560,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => exportExcel(
|
||||
filename: '库存查询',
|
||||
headers: ['商品编码', '商品名称', '规格', '批次号', '仓库', '库存量', '单价', '生产日期', '供应商', '安全库存', '状态'],
|
||||
headers: ['商品编码', '商品名称', '规格', '系列', '批次号', '仓库', '库存量', '单价', '生产日期', '入库时间', '供应商', '安全库存', '状态'],
|
||||
rows: filteredItems.map((item) {
|
||||
final status = item.quantity == 0
|
||||
? '缺货'
|
||||
@@ -451,11 +571,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,
|
||||
@@ -465,6 +587,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
icon: const Icon(Icons.download, size: 16),
|
||||
label: const Text('导出'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ColumnToggleButton(
|
||||
columns: _colDefs,
|
||||
hidden: _hiddenCols,
|
||||
onChanged: (v) => setState(() => _hiddenCols = v),
|
||||
),
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
width: 220,
|
||||
@@ -487,46 +615,48 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
),
|
||||
mobileCards:
|
||||
filteredItems.map(_inventoryCard).toList(),
|
||||
columns: [
|
||||
const DataColumn(label: Text('商品编码')),
|
||||
const DataColumn(label: Text('商品名称')),
|
||||
const DataColumn(label: Text('规格')),
|
||||
const DataColumn(label: Text('系列')),
|
||||
const DataColumn(label: Text('批次号')),
|
||||
DataColumn(
|
||||
label: FilterableColumnHeader(
|
||||
text: '仓库',
|
||||
options: warehouseOptions,
|
||||
selected: _filterWarehouse,
|
||||
onChanged: (v) => setState(() => _filterWarehouse = v),
|
||||
),
|
||||
),
|
||||
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('操作')),
|
||||
],
|
||||
columns: visibleCols.map((c) {
|
||||
final label = switch (c.key) {
|
||||
'spec' => FilterableColumnHeader(
|
||||
text: c.label,
|
||||
options: specOptions,
|
||||
selected: _filterSpec,
|
||||
onChanged: (v) {
|
||||
setState(() => _filterSpec = v);
|
||||
ref.read(inventoryListProvider.notifier).setSpec(v.toList());
|
||||
},
|
||||
),
|
||||
'series' => FilterableColumnHeader(
|
||||
text: c.label,
|
||||
options: seriesOptions,
|
||||
selected: _filterSeries,
|
||||
onChanged: (v) {
|
||||
setState(() => _filterSeries = v);
|
||||
ref.read(inventoryListProvider.notifier).setSeries(v.toList());
|
||||
},
|
||||
),
|
||||
'warehouse' => FilterableColumnHeader(
|
||||
text: c.label,
|
||||
options: warehouseOptions,
|
||||
selected: _filterWarehouse,
|
||||
onChanged: (v) => setState(() => _filterWarehouse = v),
|
||||
),
|
||||
_ => Text(c.label),
|
||||
};
|
||||
return DataColumn(
|
||||
label: label,
|
||||
numeric: c.key == 'qty' || c.key == 'price',
|
||||
);
|
||||
}).toList(),
|
||||
rows: items.isEmpty
|
||||
? [
|
||||
const DataRow(cells: [
|
||||
DataCell(SizedBox()),
|
||||
DataCell(Text('暂无库存数据',
|
||||
DataRow(cells: [
|
||||
const DataCell(SizedBox()),
|
||||
const DataCell(Text('暂无库存数据',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textSecondary))),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
for (int i = 2; i < visibleCols.length; i++)
|
||||
const DataCell(SizedBox()),
|
||||
])
|
||||
]
|
||||
: filteredItems
|
||||
@@ -534,123 +664,17 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
color: WidgetStateProperty.resolveWith(
|
||||
(states) {
|
||||
if (item.quantity == 0) {
|
||||
return AppTheme.danger.withOpacity(0.04);
|
||||
return AppTheme.danger.withValues(alpha: 0.04);
|
||||
}
|
||||
if (item.minStock != null &&
|
||||
item.quantity < item.minStock!) {
|
||||
return AppTheme.accent.withOpacity(0.04);
|
||||
return AppTheme.accent.withValues(alpha: 0.04);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
cells: [
|
||||
DataCell(Text(
|
||||
item.productCode.isEmpty ? '-' : item.productCode,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary))),
|
||||
DataCell(item.productId != null
|
||||
? GestureDetector(
|
||||
onTap: () => context.push('/products/${item.productId}'),
|
||||
child: SizedBox(
|
||||
width: 180,
|
||||
child: Text(
|
||||
item.productName.isEmpty ? '-' : item.productName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: AppTheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(item.productName.isEmpty ? '-' : item.productName,
|
||||
overflow: TextOverflow.ellipsis)),
|
||||
DataCell(Text(item.spec.isEmpty ? '-' : item.spec)),
|
||||
DataCell(Text(item.series.isEmpty ? '-' : item.series)),
|
||||
DataCell(Text(item.batchNo.isEmpty ? '-' : item.batchNo,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12))),
|
||||
DataCell(Text(item.warehouseName.isEmpty ? '-' : item.warehouseName)),
|
||||
DataCell(Text(
|
||||
'${item.quantity.toStringAsFixed(0)} ${item.unit}'.trim(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: item.quantity == 0
|
||||
? AppTheme.danger
|
||||
: (item.minStock != null &&
|
||||
item.quantity <
|
||||
item.minStock!)
|
||||
? AppTheme.accent
|
||||
: AppTheme.textPrimary),
|
||||
)),
|
||||
DataCell(Text(
|
||||
item.unitPrice != null
|
||||
? '¥${item.unitPrice!.toStringAsFixed(2)}'
|
||||
: '-',
|
||||
)),
|
||||
DataCell(Text(item.productionDate ?? '-')),
|
||||
DataCell(Text(item.supplierName.isEmpty ? '-' : item.supplierName)),
|
||||
DataCell(
|
||||
Tooltip(
|
||||
message: item.remark.isEmpty ? '' : item.remark,
|
||||
waitDuration: const Duration(milliseconds: 300),
|
||||
child: GestureDetector(
|
||||
onTap: () => _editRemark(context, item),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
item.remark.isEmpty
|
||||
? '—'
|
||||
: item.remark.length > 4
|
||||
? '${item.remark.substring(0, 4)}…'
|
||||
: item.remark,
|
||||
style: TextStyle(
|
||||
color: item.remark.isEmpty
|
||||
? AppTheme.textSecondary
|
||||
: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.edit_outlined,
|
||||
size: 12, color: AppTheme.textSecondary),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
DataCell(_InventoryStatusBadge(item)),
|
||||
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 ?? '',
|
||||
));
|
||||
}
|
||||
},
|
||||
child: const Text('打标签',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
)
|
||||
: const SizedBox(),
|
||||
),
|
||||
],
|
||||
cells: visibleCols
|
||||
.map((c) => _buildInventoryCell(c.key, item, context))
|
||||
.toList(),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
|
||||
@@ -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('打印'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:math' as math;
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
@@ -54,6 +55,13 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
|
||||
);
|
||||
final data = (resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>;
|
||||
setState(() { _data = data; _loading = false; });
|
||||
// 更新浏览器标签标题(Flutter Web 会写 document.title)
|
||||
final name = data['name'] as String? ?? '';
|
||||
if (name.isNotEmpty) {
|
||||
SystemChrome.setApplicationSwitcherDescription(
|
||||
ApplicationSwitcherDescription(label: name, primaryColor: 0xFF8B2331),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() { _error = e.toString(); _loading = false; });
|
||||
}
|
||||
|
||||
@@ -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 ? '打印中...' : '打印选中'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,10 +36,14 @@ class DataTableCard extends StatefulWidget {
|
||||
class _DataTableCardState extends State<DataTableCard> {
|
||||
// 当前悬停行的索引(-1 表示无悬停)
|
||||
final _hoveredRow = ValueNotifier<int>(-1);
|
||||
final _vCtrl = ScrollController();
|
||||
final _hCtrl = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hoveredRow.dispose();
|
||||
_vCtrl.dispose();
|
||||
_hCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -89,24 +93,35 @@ class _DataTableCardState extends State<DataTableCard> {
|
||||
|
||||
Widget _buildTable() {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
builder: (context, constraints) => Scrollbar(
|
||||
controller: _vCtrl,
|
||||
thumbVisibility: true,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
||||
child: Table(
|
||||
defaultColumnWidth: const IntrinsicColumnWidth(),
|
||||
border: TableBorder(
|
||||
horizontalInside: BorderSide(
|
||||
color: Colors.grey.shade200,
|
||||
width: 0.5,
|
||||
controller: _vCtrl,
|
||||
child: Scrollbar(
|
||||
controller: _hCtrl,
|
||||
thumbVisibility: true,
|
||||
scrollbarOrientation: ScrollbarOrientation.bottom,
|
||||
child: SingleChildScrollView(
|
||||
controller: _hCtrl,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
||||
child: Table(
|
||||
defaultColumnWidth: const IntrinsicColumnWidth(),
|
||||
border: TableBorder(
|
||||
horizontalInside: BorderSide(
|
||||
color: Colors.grey.shade200,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
_buildHeaderRow(),
|
||||
for (int i = 0; i < widget.rows.length; i++)
|
||||
_buildDataRow(i, widget.rows[i]),
|
||||
],
|
||||
),
|
||||
),
|
||||
children: [
|
||||
_buildHeaderRow(),
|
||||
for (int i = 0; i < widget.rows.length; i++)
|
||||
_buildDataRow(i, widget.rows[i]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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 张'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+107
-107
@@ -6,7 +6,7 @@ packages:
|
||||
description:
|
||||
name: archive
|
||||
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.6.1"
|
||||
args:
|
||||
@@ -14,7 +14,7 @@ packages:
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
@@ -22,7 +22,7 @@ packages:
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
barcode:
|
||||
@@ -30,7 +30,7 @@ packages:
|
||||
description:
|
||||
name: barcode
|
||||
sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.9"
|
||||
bidi:
|
||||
@@ -38,7 +38,7 @@ packages:
|
||||
description:
|
||||
name: bidi
|
||||
sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.13"
|
||||
boolean_selector:
|
||||
@@ -46,7 +46,7 @@ packages:
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
@@ -54,7 +54,7 @@ packages:
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
@@ -62,7 +62,7 @@ packages:
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
@@ -70,7 +70,7 @@ packages:
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
collection:
|
||||
@@ -78,7 +78,7 @@ packages:
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
cross_file:
|
||||
@@ -86,7 +86,7 @@ packages:
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5+2"
|
||||
crypto:
|
||||
@@ -94,7 +94,7 @@ packages:
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
dio:
|
||||
@@ -102,7 +102,7 @@ packages:
|
||||
description:
|
||||
name: dio
|
||||
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.9.2"
|
||||
dio_web_adapter:
|
||||
@@ -110,7 +110,7 @@ packages:
|
||||
description:
|
||||
name: dio_web_adapter
|
||||
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
equatable:
|
||||
@@ -118,7 +118,7 @@ packages:
|
||||
description:
|
||||
name: equatable
|
||||
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.8"
|
||||
excel:
|
||||
@@ -126,7 +126,7 @@ packages:
|
||||
description:
|
||||
name: excel
|
||||
sha256: "1a15327dcad260d5db21d1f6e04f04838109b39a2f6a84ea486ceda36e468780"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.6"
|
||||
fake_async:
|
||||
@@ -134,7 +134,7 @@ packages:
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
@@ -142,7 +142,7 @@ packages:
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
@@ -150,7 +150,7 @@ packages:
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
file_picker:
|
||||
@@ -158,7 +158,7 @@ packages:
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.7"
|
||||
file_selector_linux:
|
||||
@@ -166,7 +166,7 @@ packages:
|
||||
description:
|
||||
name: file_selector_linux
|
||||
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.4"
|
||||
file_selector_macos:
|
||||
@@ -174,7 +174,7 @@ packages:
|
||||
description:
|
||||
name: file_selector_macos
|
||||
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.5"
|
||||
file_selector_platform_interface:
|
||||
@@ -182,7 +182,7 @@ packages:
|
||||
description:
|
||||
name: file_selector_platform_interface
|
||||
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
file_selector_windows:
|
||||
@@ -190,7 +190,7 @@ packages:
|
||||
description:
|
||||
name: file_selector_windows
|
||||
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.3+5"
|
||||
flutter:
|
||||
@@ -203,7 +203,7 @@ packages:
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
flutter_localizations:
|
||||
@@ -216,7 +216,7 @@ packages:
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.35"
|
||||
flutter_riverpod:
|
||||
@@ -224,7 +224,7 @@ packages:
|
||||
description:
|
||||
name: flutter_riverpod
|
||||
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
flutter_test:
|
||||
@@ -242,7 +242,7 @@ packages:
|
||||
description:
|
||||
name: go_router
|
||||
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.8.1"
|
||||
hooks:
|
||||
@@ -250,7 +250,7 @@ packages:
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
http:
|
||||
@@ -258,7 +258,7 @@ packages:
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_mock_adapter:
|
||||
@@ -266,7 +266,7 @@ packages:
|
||||
description:
|
||||
name: http_mock_adapter
|
||||
sha256: "46399c78bd4a0af071978edd8c502d7aeeed73b5fb9860bca86b5ed647a63c1b"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.1"
|
||||
http_parser:
|
||||
@@ -274,7 +274,7 @@ packages:
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
@@ -282,7 +282,7 @@ packages:
|
||||
description:
|
||||
name: image
|
||||
sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
image_picker:
|
||||
@@ -290,7 +290,7 @@ packages:
|
||||
description:
|
||||
name: image_picker
|
||||
sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
image_picker_android:
|
||||
@@ -298,7 +298,7 @@ packages:
|
||||
description:
|
||||
name: image_picker_android
|
||||
sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.13+19"
|
||||
image_picker_for_web:
|
||||
@@ -306,7 +306,7 @@ packages:
|
||||
description:
|
||||
name: image_picker_for_web
|
||||
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
image_picker_ios:
|
||||
@@ -314,7 +314,7 @@ packages:
|
||||
description:
|
||||
name: image_picker_ios
|
||||
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.13+6"
|
||||
image_picker_linux:
|
||||
@@ -322,7 +322,7 @@ packages:
|
||||
description:
|
||||
name: image_picker_linux
|
||||
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
image_picker_macos:
|
||||
@@ -330,7 +330,7 @@ packages:
|
||||
description:
|
||||
name: image_picker_macos
|
||||
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2+1"
|
||||
image_picker_platform_interface:
|
||||
@@ -338,7 +338,7 @@ packages:
|
||||
description:
|
||||
name: image_picker_platform_interface
|
||||
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.1"
|
||||
image_picker_windows:
|
||||
@@ -346,7 +346,7 @@ packages:
|
||||
description:
|
||||
name: image_picker_windows
|
||||
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
intl:
|
||||
@@ -354,7 +354,7 @@ packages:
|
||||
description:
|
||||
name: intl
|
||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.2"
|
||||
jni:
|
||||
@@ -362,7 +362,7 @@ packages:
|
||||
description:
|
||||
name: jni
|
||||
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
jni_flutter:
|
||||
@@ -370,7 +370,7 @@ packages:
|
||||
description:
|
||||
name: jni_flutter
|
||||
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
leak_tracker:
|
||||
@@ -378,7 +378,7 @@ packages:
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
@@ -386,7 +386,7 @@ packages:
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
@@ -394,7 +394,7 @@ packages:
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
@@ -402,7 +402,7 @@ packages:
|
||||
description:
|
||||
name: lints
|
||||
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
logger:
|
||||
@@ -410,7 +410,7 @@ packages:
|
||||
description:
|
||||
name: logger
|
||||
sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
logging:
|
||||
@@ -418,7 +418,7 @@ packages:
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
lpinyin:
|
||||
@@ -426,7 +426,7 @@ packages:
|
||||
description:
|
||||
name: lpinyin
|
||||
sha256: "0bb843363f1f65170efd09fbdfc760c7ec34fc6354f9fcb2f89e74866a0d814a"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
matcher:
|
||||
@@ -434,7 +434,7 @@ packages:
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
@@ -442,7 +442,7 @@ packages:
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
@@ -450,7 +450,7 @@ packages:
|
||||
description:
|
||||
name: meta
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
mime:
|
||||
@@ -458,7 +458,7 @@ packages:
|
||||
description:
|
||||
name: mime
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
objective_c:
|
||||
@@ -466,7 +466,7 @@ packages:
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.1"
|
||||
package_config:
|
||||
@@ -474,7 +474,7 @@ packages:
|
||||
description:
|
||||
name: package_config
|
||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
package_info_plus:
|
||||
@@ -482,7 +482,7 @@ packages:
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.1"
|
||||
package_info_plus_platform_interface:
|
||||
@@ -490,7 +490,7 @@ packages:
|
||||
description:
|
||||
name: package_info_plus_platform_interface
|
||||
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
path:
|
||||
@@ -498,7 +498,7 @@ packages:
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_parsing:
|
||||
@@ -506,7 +506,7 @@ packages:
|
||||
description:
|
||||
name: path_parsing
|
||||
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
path_provider:
|
||||
@@ -514,7 +514,7 @@ packages:
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
path_provider_android:
|
||||
@@ -522,7 +522,7 @@ packages:
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
path_provider_foundation:
|
||||
@@ -530,7 +530,7 @@ packages:
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
path_provider_linux:
|
||||
@@ -538,7 +538,7 @@ packages:
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
path_provider_platform_interface:
|
||||
@@ -546,7 +546,7 @@ packages:
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
path_provider_windows:
|
||||
@@ -554,7 +554,7 @@ packages:
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
pdf:
|
||||
@@ -562,7 +562,7 @@ packages:
|
||||
description:
|
||||
name: pdf
|
||||
sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.12.0"
|
||||
pdf_widget_wrapper:
|
||||
@@ -570,7 +570,7 @@ packages:
|
||||
description:
|
||||
name: pdf_widget_wrapper
|
||||
sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
petitparser:
|
||||
@@ -578,7 +578,7 @@ packages:
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
platform:
|
||||
@@ -586,7 +586,7 @@ packages:
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
plugin_platform_interface:
|
||||
@@ -594,7 +594,7 @@ packages:
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
printing:
|
||||
@@ -602,7 +602,7 @@ packages:
|
||||
description:
|
||||
name: printing
|
||||
sha256: "689170c9ddb1bda85826466ba80378aa8993486d3c959a71cd7d2d80cb606692"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.14.3"
|
||||
pub_semver:
|
||||
@@ -610,7 +610,7 @@ packages:
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
qr:
|
||||
@@ -618,7 +618,7 @@ packages:
|
||||
description:
|
||||
name: qr
|
||||
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
record_use:
|
||||
@@ -626,7 +626,7 @@ packages:
|
||||
description:
|
||||
name: record_use
|
||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
riverpod:
|
||||
@@ -634,7 +634,7 @@ packages:
|
||||
description:
|
||||
name: riverpod
|
||||
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
shared_preferences:
|
||||
@@ -642,7 +642,7 @@ packages:
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.5"
|
||||
shared_preferences_android:
|
||||
@@ -650,7 +650,7 @@ packages:
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.26"
|
||||
shared_preferences_foundation:
|
||||
@@ -658,7 +658,7 @@ packages:
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6"
|
||||
shared_preferences_linux:
|
||||
@@ -666,7 +666,7 @@ packages:
|
||||
description:
|
||||
name: shared_preferences_linux
|
||||
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_platform_interface:
|
||||
@@ -674,7 +674,7 @@ packages:
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
shared_preferences_web:
|
||||
@@ -682,7 +682,7 @@ packages:
|
||||
description:
|
||||
name: shared_preferences_web
|
||||
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
shared_preferences_windows:
|
||||
@@ -690,7 +690,7 @@ packages:
|
||||
description:
|
||||
name: shared_preferences_windows
|
||||
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
sky_engine:
|
||||
@@ -703,7 +703,7 @@ packages:
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
@@ -711,7 +711,7 @@ packages:
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
state_notifier:
|
||||
@@ -719,7 +719,7 @@ packages:
|
||||
description:
|
||||
name: state_notifier
|
||||
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
stream_channel:
|
||||
@@ -727,7 +727,7 @@ packages:
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
@@ -735,7 +735,7 @@ packages:
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
@@ -743,7 +743,7 @@ packages:
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
@@ -751,7 +751,7 @@ packages:
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
typed_data:
|
||||
@@ -759,7 +759,7 @@ packages:
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher:
|
||||
@@ -767,7 +767,7 @@ packages:
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_android:
|
||||
@@ -775,7 +775,7 @@ packages:
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.32"
|
||||
url_launcher_ios:
|
||||
@@ -783,7 +783,7 @@ packages:
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.1"
|
||||
url_launcher_linux:
|
||||
@@ -791,7 +791,7 @@ packages:
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
url_launcher_macos:
|
||||
@@ -799,7 +799,7 @@ packages:
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.5"
|
||||
url_launcher_platform_interface:
|
||||
@@ -807,7 +807,7 @@ packages:
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
@@ -815,7 +815,7 @@ packages:
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
url_launcher_windows:
|
||||
@@ -823,7 +823,7 @@ packages:
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
vector_math:
|
||||
@@ -831,7 +831,7 @@ packages:
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
@@ -839,7 +839,7 @@ packages:
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
web:
|
||||
@@ -847,7 +847,7 @@ packages:
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
win32:
|
||||
@@ -855,7 +855,7 @@ packages:
|
||||
description:
|
||||
name: win32
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
xdg_directories:
|
||||
@@ -863,7 +863,7 @@ packages:
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
@@ -871,7 +871,7 @@ packages:
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
@@ -879,7 +879,7 @@ packages:
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -47,10 +47,12 @@ server {
|
||||
expires 0;
|
||||
}
|
||||
|
||||
# 公开商品详情页(扫码跳转)→ 交给 Flutter Web 路由处理
|
||||
# 公开商品详情页(扫码跳转)→ 后端注入 OG 标签后返回 Flutter index.html
|
||||
location ~ ^/product/ {
|
||||
root /opt/jiu/web;
|
||||
try_files $uri /app/index.html;
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# 桌面客户端安装包下载(Windows .exe / macOS .zip)
|
||||
|
||||
@@ -9,4 +9,5 @@ LICENSE_HMAC_SECRET=CHANGE_ME_RANDOM_32CHARS
|
||||
STORAGE_UPLOAD_DIR=/opt/jiu/images
|
||||
STORAGE_BASE_URL=https://jiu.51yanmei.com/images
|
||||
STORAGE_PUBLIC_URL=https://jiu.51yanmei.com
|
||||
STORAGE_WEB_DIR=/opt/jiu/web
|
||||
DB_PASSWORD=CHANGE_ME_DB_PASS
|
||||
|
||||
@@ -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
@@ -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+uniqueIndex,utf8mb4 下 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(&object) 更新全字段,若中间件被绕过会造成 shop_id 被覆盖。改为 db.Model(&x).Where("id=? AND shop_id=?").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.26(6/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="*",注释提示生产需改但无代码强制。应在 main.go 中添加:server.mode!=debug 且 CORSOrigin=="*" 时 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 两个bug:1.devs,_ 错误被_吞掉DB故障时deviceCount=0;2.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+uniqueIndex,utf8mb4 下 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(&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_price;2) 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 CRUD(16 个方法);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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user