feat(t241): improve freight admin views

This commit is contained in:
QiuSW
2026-07-29 15:34:51 +08:00
parent 8b0752dc53
commit 7e3aaac982
14 changed files with 451 additions and 105 deletions
+21 -18
View File
@@ -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 {
@@ -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",
@@ -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,
@@ -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") {
@@ -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),
}
}
@@ -928,13 +928,16 @@ func TestFreightPagesEscapeSourceDataAndCreateSynchronousOrderSync(t *testing.T)
service := &fakeFreightService{
fakeService: &fakeService{},
orders: []FreightOrder{{
ID: testTaskID,
ExternalStockID: "12",
SourceCode: `<script>private</script>`,
ShopName: "测试店铺",
ItemCount: 2,
Revision: 1,
UpdatedAt: now,
ID: testTaskID,
ExternalStockID: "12",
SourceCode: `<script>private</script>`,
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(), `<script>private</script>`) ||
!strings.Contains(list.Body.String(), "&lt;script&gt;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
@@ -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,
@@ -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;
}
}
@@ -25,17 +25,20 @@
<div><dt>采购状态</dt><dd>{{if .Detail.Order.PurchaseStatus}}{{.Detail.Order.PurchaseStatus}}{{else}}未提供{{end}}</dd></div>
</dl>
</section>
<section class="table-region" aria-labelledby="freight-items-title">
<h2 id="freight-items-title">商品明细</h2>
<section class="table-region freight-region" aria-labelledby="freight-items-title">
<div class="freight-table-heading">
<h2 id="freight-items-title">商品明细</h2>
<span class="secondary">共 {{.Detail.Order.ItemCount}} 项</span>
</div>
{{if .Detail.Items}}
<table>
<table class="freight-table freight-items-table">
<thead>
<tr>
<th scope="col">商品</th>
<th scope="col">规格 / SKU</th>
<th scope="col">数量</th>
<th scope="col">采购状态</th>
<th scope="col">来源版本</th>
<th scope="col">ERP 原始单价</th>
<th scope="col">货运状态</th>
<th scope="col">采购处理</th>
</tr>
</thead>
@@ -43,17 +46,32 @@
{{range .Detail.Items}}
<tr>
<td data-label="商品">
<strong>{{if .Item.Title}}{{.Item.Title}}{{else}}缺少标题{{end}}</strong>
<span class="secondary">明细 ID:{{.Item.ExternalItemID}}</span>
{{if .Item.ProductThumbRef}}<span class="secondary">图片引用:{{.Item.ProductThumbRef}}</span>{{end}}
<div class="freight-media freight-item-media">
{{if .Item.ImageURL}}
<img class="freight-thumb freight-thumb-detail" src="{{.Item.ImageURL}}"
width="84" height="84" loading="lazy" decoding="async"
alt="{{if .Item.Title}}{{.Item.Title}}{{else}}货运商品{{end}}图片">
{{else}}
<span class="freight-thumb freight-thumb-detail freight-thumb-placeholder"
aria-label="{{imageStatusLabel .Item.ImageStatus}}">
{{imageStatusLabel .Item.ImageStatus}}
</span>
{{end}}
<span class="freight-media-copy">
<strong class="freight-product-title">{{if .Item.Title}}{{.Item.Title}}{{else}}缺少标题{{end}}</strong>
<span class="secondary">明细 ID:{{.Item.ExternalItemID}}</span>
</span>
</div>
</td>
<td data-label="规格 / SKU">
<span>{{if .Item.ProductSpec}}{{.Item.ProductSpec}}{{else}}未提供规格{{end}}</span>
<span class="secondary">SKU:{{if .Item.SKU}}{{.Item.SKU}}{{else}}未提供{{end}}</span>
<strong>{{if .Item.ProductSpec}}{{.Item.ProductSpec}}{{else}}未提供{{end}}</strong>
</td>
<td data-label="数量"><span class="freight-number">{{if .Item.Quantity}}{{.Item.Quantity}}{{else}}未提供{{end}}</span></td>
<td data-label="ERP 原始单价"><span class="freight-money">{{formatMoney .Item.OriginalUnitPriceMinor .Item.OriginalCurrency}}</span></td>
<td data-label="货运状态">
<span>{{if .Item.PurchaseStatus}}{{.Item.PurchaseStatus}}{{else}}未提供{{end}}</span>
<span class="secondary">来源版本 {{.Item.Revision}}</span>
</td>
<td data-label="数量">{{if .Item.Quantity}}{{.Item.Quantity}}{{else}}未提供{{end}}</td>
<td data-label="采购状态">{{if .Item.PurchaseStatus}}{{.Item.PurchaseStatus}}{{else}}未提供{{end}}</td>
<td data-label="来源版本">{{.Item.Revision}}</td>
<td data-label="采购处理" class="procurement-actions">
{{if .Request}}
<strong>{{.Request.StatusLabel}}</strong>
@@ -16,16 +16,16 @@
<a class="button primary" href="/freight/import">导入货运单</a>
</div>
{{if .Notice}}<div class="notice" role="status">{{.Notice}}</div>{{end}}
<section class="table-region" aria-labelledby="freight-table-title">
<section class="table-region freight-region" aria-labelledby="freight-table-title">
<h2 id="freight-table-title" class="visually-hidden">货运单列表</h2>
{{if .Orders}}
<table>
<table class="freight-table freight-list-table">
<thead>
<tr>
<th scope="col">来源单号</th>
<th scope="col">货运单</th>
<th scope="col">商品摘要</th>
<th scope="col">店铺</th>
<th scope="col">状态</th>
<th scope="col">商品</th>
<th scope="col">更新时间</th>
<th scope="col"><span class="visually-hidden">操作</span></th>
</tr>
@@ -33,16 +33,33 @@
<tbody>
{{range .Orders}}
<tr>
<td data-label="来源单号">
<strong>{{if .SourceCode}}{{.SourceCode}}{{else}}{{.ExternalStockID}}{{end}}</strong>
<span class="secondary">ERP ID:{{.ExternalStockID}}</span>
<td data-label="货运单">
<div class="freight-media">
{{if .PreviewImageURL}}
<img class="freight-thumb freight-thumb-list" src="{{.PreviewImageURL}}"
width="56" height="56" loading="lazy" decoding="async"
alt="{{if .PreviewTitle}}{{.PreviewTitle}}{{else}}货运商品{{end}}图片">
{{else}}
<span class="freight-thumb freight-thumb-list freight-thumb-placeholder"
aria-label="{{imageStatusLabel .PreviewImageStatus}}">
{{imageStatusLabel .PreviewImageStatus}}
</span>
{{end}}
<span class="freight-media-copy">
<strong>{{if .SourceCode}}{{.SourceCode}}{{else}}{{.ExternalStockID}}{{end}}</strong>
<span class="secondary">ERP ID:{{.ExternalStockID}}</span>
</span>
</div>
</td>
<td data-label="商品摘要">
<strong class="freight-product-title">{{if .PreviewTitle}}{{.PreviewTitle}}{{else}}未提供商品标题{{end}}</strong>
<span class="secondary">共 {{.ItemCount}} 项</span>
</td>
<td data-label="店铺">{{if .ShopName}}{{.ShopName}}{{else}}未提供{{end}}</td>
<td data-label="状态">
<span class="secondary">订单:{{if .OrderStatus}}{{.OrderStatus}}{{else}}未提供{{end}}</span>
<span class="secondary">采购:{{if .PurchaseStatus}}{{.PurchaseStatus}}{{else}}未提供{{end}}</span>
</td>
<td data-label="商品">{{.ItemCount}} 项</td>
<td data-label="更新时间"><time datetime="{{machineTime .UpdatedAt}}">{{displayTime .UpdatedAt}}</time></td>
<td data-label="操作"><a class="detail-link" href="/freight/{{pathPart .ID}}">查看详情</a></td>
</tr>
+14 -11
View File
@@ -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 {
@@ -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