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
@@ -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