feat(t241): improve freight admin views
This commit is contained in:
@@ -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(), "<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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
-6
@@ -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 原始单价和重新导入幂等性;不把真实数据或诊断响应提交到仓库。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
|
||||
+17
-8
@@ -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,控制台无警告或错误。
|
||||
|
||||
Reference in New Issue
Block a user