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
+15 -6
View File
@@ -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
View File
@@ -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,控制台无警告或错误。