From 7e3aaac982339b0e130da449b0d3ff73ea93c2f3 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 29 Jul 2026 15:34:51 +0800 Subject: [PATCH] feat(t241): improve freight admin views --- backend-api/internal/domain/freight.go | 39 ++--- .../sqlite/freight_image_repository_test.go | 6 + .../repository/sqlite/freight_repository.go | 61 +++++++- .../transport/httpapi/admin_handlers_test.go | 3 + .../transport/httpapi/freight_handlers.go | 37 +++-- .../internal/transport/webui/handler_test.go | 66 +++++++-- .../internal/transport/webui/renderer.go | 37 ++++- .../internal/transport/webui/static/admin.css | 135 ++++++++++++++++++ .../webui/templates/freight-detail.gohtml | 44 ++++-- .../transport/webui/templates/freight.gohtml | 33 +++-- backend-api/internal/transport/webui/types.go | 25 ++-- .../transport/webui/usecase_adapter.go | 24 ++-- docs/current-state.md | 21 ++- docs/tasks/T-241.md | 25 ++-- 14 files changed, 451 insertions(+), 105 deletions(-) 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 @@
| 商品 | 规格 / SKU | 数量 | -采购状态 | -来源版本 | +ERP 原始单价 | +货运状态 | 采购处理 | |||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
- {{if .Item.Title}}{{.Item.Title}}{{else}}缺少标题{{end}}
- 明细 ID:{{.Item.ExternalItemID}}
- {{if .Item.ProductThumbRef}}图片引用:{{.Item.ProductThumbRef}}{{end}}
+
+ {{if .Item.ImageURL}}
+
|
- {{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}} -
|