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
+3
View File
@@ -66,6 +66,9 @@ type FreightOrder struct {
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,6 +281,11 @@ 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,
@@ -295,6 +300,10 @@ func freightOrderResponse(order domain.FreightOrder) gin.H {
"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),
}
}
@@ -935,6 +935,9 @@ func TestFreightPagesEscapeSourceDataAndCreateSynchronousOrderSync(t *testing.T)
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",
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"
@@ -24,6 +25,8 @@ func NewRenderer() (*Renderer, error) {
"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">
<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>
<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>
{{if .Item.ProductThumbRef}}<span class="secondary">图片引用:{{.Item.ProductThumbRef}}</span>{{end}}
</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="来源单号">
<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>
@@ -147,6 +147,9 @@ type FreightOrder struct {
ItemCount int
Revision int
UpdatedAt time.Time
PreviewTitle string
PreviewImageStatus string
PreviewImageURL string
}
type FreightOrderItem struct {
@@ -446,6 +446,12 @@ func freightOrderFrom(order domain.FreightOrder) FreightOrder {
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,控制台无警告或错误。