diff --git a/backend-api/internal/domain/freight.go b/backend-api/internal/domain/freight.go index 38df43f..030f293 100644 --- a/backend-api/internal/domain/freight.go +++ b/backend-api/internal/domain/freight.go @@ -48,24 +48,27 @@ type FreightSyncRun struct { } type FreightOrder struct { - ID string - CreatorSubject string - SourceSystem string - ExternalStockID string - SourceCode string - PlatformOrderNo *string - ShopName *string - SourceCreatedAt *time.Time - OrderStatus *string - PurchaseStatus *string - IsCanceled *bool - CanonicalSHA256 string - Revision int - FirstSyncRunID string - LastSyncRunID string - CreatedAt time.Time - UpdatedAt time.Time - ItemCount int + ID string + CreatorSubject string + SourceSystem string + ExternalStockID string + SourceCode string + PlatformOrderNo *string + ShopName *string + SourceCreatedAt *time.Time + OrderStatus *string + PurchaseStatus *string + IsCanceled *bool + CanonicalSHA256 string + Revision int + FirstSyncRunID string + LastSyncRunID string + CreatedAt time.Time + UpdatedAt time.Time + ItemCount int + PreviewItemID string + PreviewTitle string + PreviewImageStatus FreightItemImageStatus } type FreightOrderItem struct { diff --git a/backend-api/internal/repository/sqlite/freight_image_repository_test.go b/backend-api/internal/repository/sqlite/freight_image_repository_test.go index 3ded4ec..1260d69 100644 --- a/backend-api/internal/repository/sqlite/freight_image_repository_test.go +++ b/backend-api/internal/repository/sqlite/freight_image_repository_test.go @@ -90,6 +90,12 @@ func TestFreightItemImagesAreCurrentRetryableAndRollbackGuarded( t.Fatalf("retry jobs = %+v, %v", jobs, err) } orders, _ := store.ListFreightOrders(ctx, "local-admin", 10) + if len(orders) != 1 || + orders[0].PreviewItemID != ready.FreightOrderItemID || + orders[0].PreviewTitle != "商品一" || + orders[0].PreviewImageStatus != domain.FreightItemImageReady { + t.Fatalf("freight order preview = %+v", orders) + } detail, err := store.GetFreightOrder( ctx, "local-admin", diff --git a/backend-api/internal/repository/sqlite/freight_repository.go b/backend-api/internal/repository/sqlite/freight_repository.go index 360eae6..d73e0f4 100644 --- a/backend-api/internal/repository/sqlite/freight_repository.go +++ b/backend-api/internal/repository/sqlite/freight_repository.go @@ -488,21 +488,80 @@ func (store *Store) ListFreightOrders( if err != nil { return nil, repositoryFailure(err) } - defer rows.Close() orders := make([]domain.FreightOrder, 0) for rows.Next() { order, err := scanFreightOrder(rows) if err != nil { + rows.Close() return nil, repositoryFailure(err) } orders = append(orders, order) } if err := rows.Err(); err != nil { + rows.Close() return nil, repositoryFailure(err) } + if err := rows.Close(); err != nil { + return nil, repositoryFailure(err) + } + for index := range orders { + if err := store.loadFreightOrderPreview( + ctx, + &orders[index], + ); err != nil { + return nil, err + } + } return orders, nil } +func (store *Store) loadFreightOrderPreview( + ctx context.Context, + order *domain.FreightOrder, +) error { + var itemID, title string + var itemThumb, imageThumb, imageStatus sql.NullString + err := store.db.QueryRowContext( + ctx, + `SELECT item.id, item.title, item.product_thumb_ref, + image.product_thumb_ref, image.status + FROM freight_order_items AS item + LEFT JOIN freight_item_images AS image + ON image.freight_order_item_id = item.id + WHERE item.freight_order_id = ? AND item.is_present = 1 + ORDER BY CAST(item.external_item_id AS INTEGER), + item.external_item_id + LIMIT 1`, + order.ID, + ).Scan( + &itemID, + &title, + &itemThumb, + &imageThumb, + &imageStatus, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return repositoryFailure(err) + } + order.PreviewItemID = itemID + order.PreviewTitle = title + switch { + case !itemThumb.Valid: + order.PreviewImageStatus = domain.FreightItemImageNone + case imageThumb.Valid && imageStatus.Valid && + imageThumb.String == itemThumb.String: + order.PreviewImageStatus = domain.FreightItemImageStatus( + imageStatus.String, + ) + default: + order.PreviewImageStatus = domain.FreightItemImagePending + } + return nil +} + func (store *Store) GetFreightOrder( ctx context.Context, creatorSubject, orderID string, diff --git a/backend-api/internal/transport/httpapi/admin_handlers_test.go b/backend-api/internal/transport/httpapi/admin_handlers_test.go index 380cae0..d53f9b5 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers_test.go +++ b/backend-api/internal/transport/httpapi/admin_handlers_test.go @@ -451,6 +451,9 @@ func TestAdminFreightAPIImportsAllItemsWithoutPII(t *testing.T) { ) if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"item_count":2`) || + !strings.Contains(list.Body.String(), `"preview_title":"商品一"`) || + !strings.Contains(list.Body.String(), `"preview_image_status":"READY"`) || + !strings.Contains(list.Body.String(), `"/api/v1/freight-items/`) || responseContainsKey(mustDecodeAny(t, list), "receiver") || responseContainsKey(mustDecodeAny(t, list), "receiverTel") || responseContainsKey(mustDecodeAny(t, list), "receiverAddr") { diff --git a/backend-api/internal/transport/httpapi/freight_handlers.go b/backend-api/internal/transport/httpapi/freight_handlers.go index e9bdf55..e1e5c63 100644 --- a/backend-api/internal/transport/httpapi/freight_handlers.go +++ b/backend-api/internal/transport/httpapi/freight_handlers.go @@ -281,20 +281,29 @@ func nullableResponseString(value string) any { } func freightOrderResponse(order domain.FreightOrder) gin.H { + var previewImageURL any + if order.PreviewImageStatus == domain.FreightItemImageReady { + previewImageURL = "/api/v1/freight-items/" + + order.PreviewItemID + "/image" + } return gin.H{ - "id": order.ID, - "source_system": order.SourceSystem, - "external_stock_id": order.ExternalStockID, - "source_code": order.SourceCode, - "platform_order_no": order.PlatformOrderNo, - "shop_name": order.ShopName, - "source_created_at": formatOptionalTime(order.SourceCreatedAt), - "order_status": order.OrderStatus, - "purchase_status": order.PurchaseStatus, - "is_canceled": order.IsCanceled, - "revision": order.Revision, - "canonical_sha256": order.CanonicalSHA256, - "item_count": order.ItemCount, - "updated_at": formatTime(order.UpdatedAt), + "id": order.ID, + "source_system": order.SourceSystem, + "external_stock_id": order.ExternalStockID, + "source_code": order.SourceCode, + "platform_order_no": order.PlatformOrderNo, + "shop_name": order.ShopName, + "source_created_at": formatOptionalTime(order.SourceCreatedAt), + "order_status": order.OrderStatus, + "purchase_status": order.PurchaseStatus, + "is_canceled": order.IsCanceled, + "revision": order.Revision, + "canonical_sha256": order.CanonicalSHA256, + "item_count": order.ItemCount, + "preview_item_id": nullableResponseString(order.PreviewItemID), + "preview_title": nullableResponseString(order.PreviewTitle), + "preview_image_status": order.PreviewImageStatus, + "preview_image_url": previewImageURL, + "updated_at": formatTime(order.UpdatedAt), } } diff --git a/backend-api/internal/transport/webui/handler_test.go b/backend-api/internal/transport/webui/handler_test.go index 0bc22fd..a00a26e 100644 --- a/backend-api/internal/transport/webui/handler_test.go +++ b/backend-api/internal/transport/webui/handler_test.go @@ -928,13 +928,16 @@ func TestFreightPagesEscapeSourceDataAndCreateSynchronousOrderSync(t *testing.T) service := &fakeFreightService{ fakeService: &fakeService{}, orders: []FreightOrder{{ - ID: testTaskID, - ExternalStockID: "12", - SourceCode: ``, - ShopName: "测试店铺", - ItemCount: 2, - Revision: 1, - UpdatedAt: now, + ID: testTaskID, + ExternalStockID: "12", + SourceCode: ``, + ShopName: "测试店铺", + ItemCount: 2, + Revision: 1, + UpdatedAt: now, + PreviewTitle: "脱敏商品标题", + PreviewImageStatus: "READY", + PreviewImageURL: "/api/v1/freight-items/preview/image", }}, createResult: FreightSync{ ID: testTaskID, @@ -947,7 +950,10 @@ func TestFreightPagesEscapeSourceDataAndCreateSynchronousOrderSync(t *testing.T) if list.Code != http.StatusOK || strings.Contains(list.Body.String(), ``) || !strings.Contains(list.Body.String(), "<script>private") || - !strings.Contains(list.Body.String(), "2 项") { + !strings.Contains(list.Body.String(), "共 2 项") || + !strings.Contains(list.Body.String(), `width="56" height="56"`) || + !strings.Contains(list.Body.String(), `loading="lazy"`) || + !strings.Contains(list.Body.String(), "脱敏商品标题") { t.Fatalf("freight list status/body = %d / %s", list.Code, list.Body) } assertSecurityHeaders(t, list) @@ -1208,6 +1214,7 @@ func TestFreightImportLogsUnknownFailureWithoutRawError(t *testing.T) { func TestFreightDetailCreatesProcurementTaskWithCSRF(t *testing.T) { const itemID = "00000000-0000-4000-8000-000000000002" + price := int64(12950) service := &fakeProcurementService{ fakeFreightService: &fakeFreightService{ fakeService: &fakeService{}, @@ -1216,14 +1223,20 @@ func TestFreightDetailCreatesProcurementTaskWithCSRF(t *testing.T) { ID: testTaskID, ExternalStockID: "12", SourceCode: "SOURCE-12", + ItemCount: 1, }, Items: []FreightItemReview{{ Item: FreightOrderItem{ - ID: itemID, - ExternalItemID: "88", - Title: "商品", - SKU: "BLACK-L", - Quantity: 2, + ID: itemID, + ExternalItemID: "88", + Title: "商品", + ProductSpec: "黑色,L", + SKU: "黑色,L", + Quantity: 2, + OriginalUnitPriceMinor: &price, + OriginalCurrency: "TWD", + ImageStatus: "READY", + ImageURL: "/api/v1/freight-items/" + itemID + "/image", }, Request: &ProcurementRequest{ ID: itemID, @@ -1250,7 +1263,12 @@ func TestFreightDetailCreatesProcurementTaskWithCSRF(t *testing.T) { ) if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), "生成采购任务") || - !strings.Contains(detail.Body.String(), "可以生成任务") { + !strings.Contains(detail.Body.String(), "可以生成任务") || + !strings.Contains(detail.Body.String(), "TWD 129.50") || + !strings.Contains(detail.Body.String(), `width="84" height="84"`) || + !strings.Contains(detail.Body.String(), `loading="lazy"`) || + strings.Contains(detail.Body.String(), "图片引用") || + strings.Count(detail.Body.String(), "黑色,L") != 1 { t.Fatalf("detail status/body = %d / %s", detail.Code, detail.Body) } cookie := csrfCookie(t, detail) @@ -1283,6 +1301,26 @@ func TestFreightDetailCreatesProcurementTaskWithCSRF(t *testing.T) { } } +func TestFreightPresentationFormatsMoneyAndImageStates(t *testing.T) { + value := int64(12905) + if got := formatMinorCurrency(&value, "TWD"); got != "TWD 129.05" { + t.Fatalf("formatMinorCurrency() = %q", got) + } + if got := formatMinorCurrency(nil, "TWD"); got != "未提供" { + t.Fatalf("nil formatMinorCurrency() = %q", got) + } + for status, want := range map[string]string{ + "": "未提供图片", + "PENDING": "图片待获取", + "MISSING": "ERP 无图片", + "FAILED": "图片获取失败", + } { + if got := freightImageStatusLabel(status); got != want { + t.Fatalf("freightImageStatusLabel(%q) = %q", status, got) + } + } +} + type fakeService struct { listInput ListTasksInput listResult TaskList diff --git a/backend-api/internal/transport/webui/renderer.go b/backend-api/internal/transport/webui/renderer.go index 48cc9b8..ed5c86c 100644 --- a/backend-api/internal/transport/webui/renderer.go +++ b/backend-api/internal/transport/webui/renderer.go @@ -3,6 +3,7 @@ package webui import ( "embed" "errors" + "fmt" "html/template" "io" "path" @@ -21,9 +22,11 @@ func NewRenderer() (*Renderer, error) { templates, err := template.New("admin"). Option("missingkey=error"). Funcs(template.FuncMap{ - "displayTime": displayTime, - "machineTime": machineTime, - "pathPart": pathPart, + "displayTime": displayTime, + "machineTime": machineTime, + "pathPart": pathPart, + "formatMoney": formatMinorCurrency, + "imageStatusLabel": freightImageStatusLabel, }). ParseFS(embeddedFiles, "templates/*.gohtml") if err != nil { @@ -32,6 +35,34 @@ func NewRenderer() (*Renderer, error) { return &Renderer{templates: templates}, nil } +func formatMinorCurrency(value *int64, currency string) string { + if value == nil { + return "未提供" + } + if currency == "" { + currency = "TWD" + } + return fmt.Sprintf( + "%s %d.%02d", + currency, + *value/100, + *value%100, + ) +} + +func freightImageStatusLabel(status string) string { + switch status { + case "PENDING": + return "图片待获取" + case "MISSING": + return "ERP 无图片" + case "FAILED": + return "图片获取失败" + default: + return "未提供图片" + } +} + func (r *Renderer) Execute( writer io.Writer, name string, diff --git a/backend-api/internal/transport/webui/static/admin.css b/backend-api/internal/transport/webui/static/admin.css index 17202cf..60ce18a 100644 --- a/backend-api/internal/transport/webui/static/admin.css +++ b/backend-api/internal/transport/webui/static/admin.css @@ -590,6 +590,130 @@ tbody tr:last-child td { border-bottom: 0; } +.freight-region { + border-radius: 8px; +} + +.freight-table-heading { + min-height: 52px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 14px; + border-bottom: 1px solid var(--line); +} + +.freight-table-heading h2 { + margin: 0; +} + +.freight-list-table th:first-child { + width: 25%; +} + +.freight-list-table th:nth-child(2) { + width: 22%; +} + +.freight-list-table th:nth-child(3) { + width: 14%; +} + +.freight-list-table th:nth-child(4) { + width: 13%; +} + +.freight-list-table th:nth-child(5) { + width: 15%; +} + +.freight-list-table th:last-child { + width: 11%; +} + +.freight-items-table th:first-child { + width: 30%; +} + +.freight-items-table th:nth-child(2) { + width: 14%; +} + +.freight-items-table th:nth-child(3) { + width: 7%; +} + +.freight-items-table th:nth-child(4) { + width: 13%; +} + +.freight-items-table th:nth-child(5) { + width: 12%; +} + +.freight-items-table th:last-child { + width: 24%; +} + +.freight-media { + min-width: 0; + display: flex; + align-items: center; + gap: 11px; +} + +.freight-item-media { + align-items: flex-start; +} + +.freight-media-copy { + min-width: 0; + display: block; +} + +.freight-product-title { + display: block; + overflow-wrap: anywhere; +} + +.freight-thumb { + display: block; + flex: 0 0 auto; + object-fit: contain; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface-soft); +} + +.freight-thumb-list { + width: 56px; + height: 56px; +} + +.freight-thumb-detail { + width: 84px; + height: 84px; +} + +.freight-thumb-placeholder { + display: grid; + place-items: center; + padding: 5px; + border-style: dashed; + color: var(--muted); + font-size: 10px; + font-weight: 700; + line-height: 1.25; + text-align: center; +} + +.freight-money, +.freight-number { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + .task-title, .secondary { display: block; @@ -1262,6 +1386,12 @@ tbody tr:last-child td { .image-preview { max-width: 320px; } + + .freight-list-table td:first-child, + .freight-items-table td:first-child, + .freight-items-table td:last-child { + grid-column: 1 / -1; + } } @media (max-width: 520px) { @@ -1369,4 +1499,9 @@ tbody tr:last-child td { .definition-list dd { margin-bottom: 10px; } + + .freight-thumb-detail { + width: 72px; + height: 72px; + } } diff --git a/backend-api/internal/transport/webui/templates/freight-detail.gohtml b/backend-api/internal/transport/webui/templates/freight-detail.gohtml index fe6dd0c..8d25a02 100644 --- a/backend-api/internal/transport/webui/templates/freight-detail.gohtml +++ b/backend-api/internal/transport/webui/templates/freight-detail.gohtml @@ -25,17 +25,20 @@
采购状态
{{if .Detail.Order.PurchaseStatus}}{{.Detail.Order.PurchaseStatus}}{{else}}未提供{{end}}
-
-

商品明细

+
+
+

商品明细

+ 共 {{.Detail.Order.ItemCount}} 项 +
{{if .Detail.Items}} - +
- - + + @@ -43,17 +46,32 @@ {{range .Detail.Items}} + + + - - -
商品 规格 / SKU 数量采购状态来源版本ERP 原始单价货运状态 采购处理
- {{if .Item.Title}}{{.Item.Title}}{{else}}缺少标题{{end}} - 明细 ID:{{.Item.ExternalItemID}} - {{if .Item.ProductThumbRef}}图片引用:{{.Item.ProductThumbRef}}{{end}} +
+ {{if .Item.ImageURL}} + {{if .Item.Title}}{{.Item.Title}}{{else}}货运商品{{end}}图片 + {{else}} + + {{imageStatusLabel .Item.ImageStatus}} + + {{end}} + + {{if .Item.Title}}{{.Item.Title}}{{else}}缺少标题{{end}} + 明细 ID:{{.Item.ExternalItemID}} + +
- {{if .Item.ProductSpec}}{{.Item.ProductSpec}}{{else}}未提供规格{{end}} - SKU:{{if .Item.SKU}}{{.Item.SKU}}{{else}}未提供{{end}} + {{if .Item.ProductSpec}}{{.Item.ProductSpec}}{{else}}未提供{{end}} + {{if .Item.Quantity}}{{.Item.Quantity}}{{else}}未提供{{end}}{{formatMoney .Item.OriginalUnitPriceMinor .Item.OriginalCurrency}} + {{if .Item.PurchaseStatus}}{{.Item.PurchaseStatus}}{{else}}未提供{{end}} + 来源版本 {{.Item.Revision}} {{if .Item.Quantity}}{{.Item.Quantity}}{{else}}未提供{{end}}{{if .Item.PurchaseStatus}}{{.Item.PurchaseStatus}}{{else}}未提供{{end}}{{.Item.Revision}} {{if .Request}} {{.Request.StatusLabel}} diff --git a/backend-api/internal/transport/webui/templates/freight.gohtml b/backend-api/internal/transport/webui/templates/freight.gohtml index 300bf0c..7721992 100644 --- a/backend-api/internal/transport/webui/templates/freight.gohtml +++ b/backend-api/internal/transport/webui/templates/freight.gohtml @@ -16,16 +16,16 @@ 导入货运单 {{if .Notice}}
{{.Notice}}
{{end}} -
+

货运单列表

{{if .Orders}} - +
- + + - @@ -33,16 +33,33 @@ {{range .Orders}} - + - diff --git a/backend-api/internal/transport/webui/types.go b/backend-api/internal/transport/webui/types.go index f8c5307..0cb73e1 100644 --- a/backend-api/internal/transport/webui/types.go +++ b/backend-api/internal/transport/webui/types.go @@ -136,17 +136,20 @@ type CreateFreightSyncInput struct { } type FreightOrder struct { - ID string - ExternalStockID string - SourceCode string - ShopName string - SourceCreatedAt time.Time - OrderStatus string - PurchaseStatus string - IsCanceled bool - ItemCount int - Revision int - UpdatedAt time.Time + ID string + ExternalStockID string + SourceCode string + ShopName string + SourceCreatedAt time.Time + OrderStatus string + PurchaseStatus string + IsCanceled bool + ItemCount int + Revision int + UpdatedAt time.Time + PreviewTitle string + PreviewImageStatus string + PreviewImageURL string } type FreightOrderItem struct { diff --git a/backend-api/internal/transport/webui/usecase_adapter.go b/backend-api/internal/transport/webui/usecase_adapter.go index 1cea79e..660df86 100644 --- a/backend-api/internal/transport/webui/usecase_adapter.go +++ b/backend-api/internal/transport/webui/usecase_adapter.go @@ -437,15 +437,21 @@ func (adapter *UsecaseAdapter) GetFreightWatermark( func freightOrderFrom(order domain.FreightOrder) FreightOrder { result := FreightOrder{ - ID: order.ID, - ExternalStockID: order.ExternalStockID, - SourceCode: order.SourceCode, - ShopName: stringValue(order.ShopName), - OrderStatus: stringValue(order.OrderStatus), - PurchaseStatus: stringValue(order.PurchaseStatus), - ItemCount: order.ItemCount, - Revision: order.Revision, - UpdatedAt: order.UpdatedAt, + ID: order.ID, + ExternalStockID: order.ExternalStockID, + SourceCode: order.SourceCode, + ShopName: stringValue(order.ShopName), + OrderStatus: stringValue(order.OrderStatus), + PurchaseStatus: stringValue(order.PurchaseStatus), + ItemCount: order.ItemCount, + Revision: order.Revision, + UpdatedAt: order.UpdatedAt, + PreviewTitle: order.PreviewTitle, + PreviewImageStatus: string(order.PreviewImageStatus), + } + if order.PreviewImageStatus == domain.FreightItemImageReady { + result.PreviewImageURL = "/api/v1/freight-items/" + + order.PreviewItemID + "/image" } if order.SourceCreatedAt != nil { result.SourceCreatedAt = *order.SourceCreatedAt diff --git a/docs/current-state.md b/docs/current-state.md index 89c7811..1d259f1 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,7 +5,7 @@ ## 当前快照 - 日期:2026-07-29 -- 阶段:T-238 已完成顺运宝详情分钟精度时间兼容 +- 阶段:T-241 已完成 ERP 货运列表与商品详情展示优化 - Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219 均按文档提交、实现提交的顺序纳入历史 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 @@ -19,7 +19,9 @@ `/freight/import`、`/freight/{id}` 与对应 JSON API。T-237 已将完整单号改为 55 秒 同步响应,成功后直接进入可见货运列表;超时/取消使用独立 cleanup context 写 `FAILED`,日期范围仍后台异步。HTTP `WriteTimeout` 为 70 秒。T-238 已兼容真实详情 - `created` 的分钟精度,仍按 Asia/Shanghai 严格解析并转 UTC。 + `created` 的分钟精度,仍按 Asia/Shanghai 严格解析并转 UTC。T-239 至 T-241 已保存 + `productSpec`、TWD 原始单价和数字图片引用,以有界并发缓存归一化 JPEG,并在鉴权 + 本地路由、货运列表和详情中显示图片、状态与采购字段。 - ERP Go 迁移:T-225 已用脱敏 fixture 固定 `internal/platform/shunyunbao` 的 header、 单号/日期查询、分页、详情批量和字段 allowlist,并使货运用例依赖来源中立错误。T-226 已增加受锁保护的 Go 内存 Cookie jar、验证码 ticket、登录和用户校验,以及 ADMIN 的 @@ -41,7 +43,7 @@ - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 - 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过; Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110 -- 后端测试:T-226 至 T-238 已运行 `go test ./...`、`go test -race ./...`、`go vet ./...` +- 后端测试:T-226 至 T-241 已运行 `go test ./...`、`go test -race ./...`、`go vet ./...` 和三个 Go 入口构建;T-227 增加 Go source 的伪 ERP 会话预检、完整单号、日期分页去重、 详情 allowlist 和稳定错误码覆盖;根 `init.ps1` 的 Android 测试/Debug APK 与 Go 标准 验证也通过,均未访问真实 ERP; @@ -53,6 +55,10 @@ - 管理 Web:真实 Gin/SQLite 流程已完成图片上传、任务创建、列表、详情参考图和 非终态取消;执行中只显示“请求安全停止”,ADMIN 登录/退出和安全返回路径已接入, 货运列表/导入/详情在 1440×900、390×844、360×800 无横向溢出或页头重叠 +- 货运管理展示:列表按外部商品 ID 确定性展示第一条当前商品的 56px 代表图、标题和 + 总项数;详情展示 84px/移动端 72px 本地图片、单一规格 SKU、数量、TWD 原始单价、 + 图片状态和采购操作。Playwright 已在 1440、768、390、375 四档宽度验证无横向溢出、 + 破图、遮挡和控制台错误。 - 鉴权:bcrypt 密码、8 小时管理 session、1 小时 App access token 和设备 secret 均不明文落库;设备首次绑定原子化,禁用/过期/撤销每次请求重新检查;管理/App 登录各自按来源地址执行内存有界限流,账号和设备支持 `authctl` 启停 @@ -203,6 +209,7 @@ | `docs/tasks/T-238.md` | DONE | 兼容顺运宝详情分钟精度时间 | | `docs/tasks/T-239.md` | DONE | 冻结货运规格 SKU、TWD 原始单价和图片引用契约 | | `docs/tasks/T-240.md` | DONE | 后端受控缓存并鉴权提供货运商品图片 | +| `docs/tasks/T-241.md` | DONE | 优化货运列表代表图与商品采购信息展示 | | `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | @@ -214,11 +221,13 @@ ## 任务摘要 - 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。 -- 已完成:另含 T-220 至 T-240 ERP 契约、货运存储、采购需求生成、日期增量同步、Go +- 已完成:另含 T-220 至 T-241 ERP 契约、货运存储、采购需求生成、日期增量同步、Go 直连协议、OCR 会话预检、稳定预检错误、安全诊断日志、直连 `FreightSource`、旧 Connector - 清理、受控本地凭证加载、同步单号导入、分钟时间兼容、货运商品元数据契约和本地图片缓存。 + 清理、受控本地凭证加载、同步单号导入、分钟时间兼容、货运商品元数据契约、本地图片缓存 + 和货运管理展示优化。 - 进行中:无。 -- 下一步:在货运列表和详情中展示本地缩略图、原始单价和清晰的图片状态。 +- 下一步:用真实 ERP 账号受控导入一个测试货运单,复核真实图片响应、缓存状态、规格、 + TWD 原始单价和重新导入幂等性;不把真实数据或诊断响应提交到仓库。 ## 当前可运行内容 diff --git a/docs/tasks/T-241.md b/docs/tasks/T-241.md index 8961a77..0160fb3 100644 --- a/docs/tasks/T-241.md +++ b/docs/tasks/T-241.md @@ -4,7 +4,7 @@ title: 优化 ERP 货运列表与商品详情展示 phase: 2 deps: - T-240 -status: TODO +status: DONE created: 2026-07-29 context_ref: c7e152a work_branch: null @@ -50,13 +50,13 @@ ERP 地址、不改变采购操作的前提下完成界面收敛。 ## 验收要点 -- [ ] 货运列表显示确定性的第一商品代表图/占位、商品标题和总项数。 -- [ ] 详情逐商品显示图片/占位、标题、规格/SKU、数量、TWD 原始单价和采购操作。 -- [ ] 页面不再显示 `productThumb` 原始引用,规格与 SKU 相同时不重复两行。 -- [ ] 图片有固定尺寸、懒加载和 alt;非 READY 状态不发出图片请求、不显示破图。 -- [ ] 既有 CSRF、采购需求、参考图上传和生成采购任务操作不回归。 -- [ ] 1440×900、768×1024、390×844 和 375×667 无横向溢出、遮挡或不可用操作。 -- [ ] 标准 Go 测试、race、vet 和三个入口构建通过。 +- [x] 货运列表显示确定性的第一商品代表图/占位、商品标题和总项数。 +- [x] 详情逐商品显示图片/占位、标题、规格/SKU、数量、TWD 原始单价和采购操作。 +- [x] 页面不再显示 `productThumb` 原始引用,规格与 SKU 相同时不重复两行。 +- [x] 图片有固定尺寸、懒加载和 alt;非 READY 状态不发出图片请求、不显示破图。 +- [x] 既有 CSRF、采购需求、参考图上传和生成采购任务操作不回归。 +- [x] 1440×900、768×1024、390×844 和 375×667 无横向溢出、遮挡或不可用操作。 +- [x] 标准 Go 测试、race、vet 和三个入口构建通过。 ## 边界 @@ -69,3 +69,12 @@ ERP 地址、不改变采购操作的前提下完成界面收敛。 - 2026-07-29:创建任务。采用现有安静、工具型管理后台样式,新增货运专属高密度表格和固定 图片尺寸;保持既有语义色、系统字体、44px 操作目标和 SSR 交互。 +- 2026-07-29:列表查询在关闭主结果集后按订单读取第一条当前商品,避免 SQLite 单连接 + 嵌套查询阻塞;API/SSR 返回代表商品标题、数量、图片状态和仅限 READY 的本地图片 URL。 +- 2026-07-29:详情收敛为图片、单一规格/SKU、数量、`TWD 0.00` 原始单价、状态和采购 + 操作;未提供、排队、缺失和失败图片使用同尺寸状态占位,不请求 ERP 图片地址。 +- 2026-07-29:`go test ./...`、`go test -race ./...`、`go vet ./...` 和 API、authctl、 + migrate 三个入口构建通过。Playwright 连接隔离 SQLite 和脱敏运行时图片,在 + 1440×900、768×1024、390×844、375×667 验证列表/详情:页面横向溢出和越界元素均为 + 0;缓存图自然尺寸 320×320,列表渲染 56×56,详情桌面 84×84、手机 72×72;图片请求 + 返回 200,控制台无警告或错误。
来源单号货运单商品摘要 店铺 状态商品 更新时间 操作
- {{if .SourceCode}}{{.SourceCode}}{{else}}{{.ExternalStockID}}{{end}} - ERP ID:{{.ExternalStockID}} + +
+ {{if .PreviewImageURL}} + {{if .PreviewTitle}}{{.PreviewTitle}}{{else}}货运商品{{end}}图片 + {{else}} + + {{imageStatusLabel .PreviewImageStatus}} + + {{end}} + + {{if .SourceCode}}{{.SourceCode}}{{else}}{{.ExternalStockID}}{{end}} + ERP ID:{{.ExternalStockID}} + +
+
+ {{if .PreviewTitle}}{{.PreviewTitle}}{{else}}未提供商品标题{{end}} + 共 {{.ItemCount}} 项 {{if .ShopName}}{{.ShopName}}{{else}}未提供{{end}} 订单:{{if .OrderStatus}}{{.OrderStatus}}{{else}}未提供{{end}} 采购:{{if .PurchaseStatus}}{{.PurchaseStatus}}{{else}}未提供{{end}} {{.ItemCount}} 项 查看详情