diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md
index 90c3cf6..45567df 100644
--- a/docs/02-architecture-and-code-map.md
+++ b/docs/02-architecture-and-code-map.md
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
-wiki_revision: 76854d900b2372ddedd46a2b82b5677d8bf5cb31
-synchronized_at: 2026-10-09T07:45:10Z
+wiki_revision: 08da88a4f106004cb5f02448260d2fc22a83e9cc
+synchronized_at: 2026-10-10T00:59:09Z
@@ -673,3 +673,12 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- `PddProductDetailCollector.collectColors` 只基于既有解析帧记录颜色阶段 UP 派发次数、空发现轮次和横向探索三态;不增加抓取、手势或等待。标签及签名只在内存使用,持久化仍为安全聚合。
- 不新增 Server/Web API 字段、服务端迁移或权限;普通结果 payload/missing 与采购探测失败策略未改变。完整遍历与不完整候选拦截仍属 #370 后续待证据实施范围,不能把本地诊断值作为已实现的业务门禁。
- 读取与三态限制见 Troubleshooting;旧版不保证降级打开 v5。JDBC 测试与实际 Android 安装升级分别验收,测试不代替真实竖向颜色采集验证。
+
+## Admin 列表按当前筛选导出全部页(#372,待验收)
+
+实现绑定 `a4a793f`(分支 `feat/372-list-export`,未合并、未发布)。
+
+- 采购管理与 SYB 商品页各增「导出」按钮:`web/src/utils/list-export.js` 先按当前筛选读第 1 页取总数,超过 5000 条不导出,否则逐页读取(采购每页 100、SYB 每页 500)再用 `web/src/vendor/Export2Excel.js`(经 `utils/save-excel.js` 按需加载)生成 xlsx;任一页或批次失败不生成文件;筛选条件以点击时复制的参数为准。无服务端导出接口、无异步导出任务。
+- `GET /api/admin/v1/purchase-tasks` 的 `AdminListRequest`(`server/app/goauto/purchase/admin_query.go`)新增可选 `orderSubmittedFrom` / `orderSubmittedTo`(`YYYY-MM-DD`,含首尾两天,按 `order_submitted_at`,UTC+8 与 SYB 创建时间筛选一致);格式错误或起止倒置返回 `INVALID_REQUEST`;无下单时间的任务在设置该筛选时不出现。采购页筛选栏用日期范围选择器。
+- `GET /api/admin/v1/return-matches` 的 `returnmatch.ListItem` 新增只读 `yeekeQuantity`(`server/app/goauto/returnmatch/service.go`,来自既有 `yeeke_return_item` join,不参与匹配);该接口原本就支持一次传多个 `sybProductId`,SYB 导出按每批 200 个商品 ID 调用,不逐行请求,且只采用 `matched`/`confirmed` 的匹配。
+- 无数据库迁移、无新接口、无新权限;导出按钮不单独鉴权,能打开列表的用户(含采购员)都可导出。
diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md
index bf9b892..601ddee 100644
--- a/docs/03-business-rules-and-glossary.md
+++ b/docs/03-business-rules-and-glossary.md
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
-wiki_revision: 7cdfe55383055e0f7611cd153e8d813d32f08d91
-synchronized_at: 2026-10-09T10:28:15Z
+wiki_revision: 1c57a8670034fe08420bcac48b58a9c4b2755f23
+synchronized_at: 2026-10-10T00:59:12Z
@@ -946,3 +946,14 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
- 设置页区分同步中、真实心跳失败、已连接但有拒收;新说明采用固定文案,不展示任意服务端错误消息。本单只修复明确拒收后的持续阻塞,不代表最初心跳中断的原因已查明,历史诊断仍属于 #378。
- 拒收后只在执行锁空闲、当前运行标记属于同一采购任务且本地当前 attempt 已拒收时清理恢复占位,不清除其他采集/采购或新 attempt;手动当前页采集与同步共用既有 working/任务锁门禁。服务停止写 STOPPED,原服务实例的迟到状态更新不覆盖它。
+
+## Admin 列表导出 Excel(#372,待验收)
+
+实现绑定 `a4a793f`,分支 `feat/372-list-export`,未合并、未发布。
+
+- 范围:采购管理和 SYB 商品页的「导出」导出**当前筛选条件下的全部页**,不是只导出当前页;筛选以点击导出时的值为准。总数超过 **5000** 条时提示缩小筛选范围并不导出;任一页读取失败不生成文件;无数据提示且不生成文件。分页读取期间数据变化可能有少量重复或遗漏,以导出时刻近似结果为准。
+- 格式:时间 `YYYY-MM-DD HH:mm:ss`(浏览器本地时区);金额单位元,保留两位小数;空值留空;文件名 `采购导出_YYYYMMDD-HHmmss.xlsx`、`SYB商品导出_YYYYMMDD-HHmmss.xlsx`。不导出收货人、地址、账号等个人信息。
+- 采购导出列(按顺序):SYB订单号、虾皮商品ID、颜色、尺码(均为 SYB 目标规格快照)、数量、状态(页面同款中文)、PDD单号、下单时间、价格(元)。价格取 `pddOrderAmountCent / 100`,即 Agent 在 PDD 待付款页读到的应付金额;旧版 Agent 或人工补录前为空则留空。要导出成功采购,先把状态筛为「订单已创建」并按需选择下单日期。
+- 采购管理新增「下单日期」筛选:含首尾两天,按 `order_submitted_at`,日期边界 UTC+8,与 SYB 商品页创建时间筛选一致;没有下单时间的任务在设置该筛选时不出现。
+- SYB 商品导出列(按顺序):SYB订单、虾皮商品ID、SYB颜色、SYB尺码、SYB数量、yeeke颜色、yeeke尺码、yeeke数量、匹配时间。yeeke 颜色/尺码来自该商品有效(`matched` 或 `confirmed`)退货匹配的 `variationName`,按第一个英文或中文逗号拆分,没有逗号时整段放 yeeke 颜色;yeeke 数量为对应退货商品数量;没有有效匹配时 yeeke 列与匹配时间留空。要导出已用退货,先把处理阶段筛为「已用退货」。
+- 权限:不新增按钮权限,能打开列表的用户(含采购员)都可导出。
diff --git a/server/app/goauto/purchase/admin_query.go b/server/app/goauto/purchase/admin_query.go
index cf2af1f..942152b 100644
--- a/server/app/goauto/purchase/admin_query.go
+++ b/server/app/goauto/purchase/admin_query.go
@@ -25,6 +25,46 @@ type AdminListRequest struct {
ShopeeOrderNo string
ShopeeOrderNos []string
PDDOrderNo string
+ // OrderSubmittedFrom/To are inclusive YYYY-MM-DD days (UTC+8, same as the
+ // SYB products createdFrom/createdTo filter) over order_submitted_at.
+ OrderSubmittedFrom string
+ OrderSubmittedTo string
+}
+
+// adminOrderDayLocation matches the SYB products page's createdFrom/createdTo
+// day boundary (sybimport.sybStockLocation) so both pages filter the same way.
+var adminOrderDayLocation = time.FixedZone("SYB UTC+8", 8*60*60)
+
+// adminOrderSubmittedRange returns [start, endExclusive); a nil bound means
+// that side is open. Both days are inclusive for the caller.
+func adminOrderSubmittedRange(from, to string) (*time.Time, *time.Time, error) {
+ from, to = strings.TrimSpace(from), strings.TrimSpace(to)
+ parse := func(value, label string) (*time.Time, error) {
+ if value == "" {
+ return nil, nil
+ }
+ parsed, err := time.ParseInLocation("2006-01-02", value, adminOrderDayLocation)
+ if err != nil {
+ return nil, fail(CodeInvalidRequest, label+" 必须是 YYYY-MM-DD")
+ }
+ return &parsed, nil
+ }
+ start, err := parse(from, "orderSubmittedFrom")
+ if err != nil {
+ return nil, nil, err
+ }
+ endDay, err := parse(to, "orderSubmittedTo")
+ if err != nil {
+ return nil, nil, err
+ }
+ if start != nil && endDay != nil && start.After(*endDay) {
+ return nil, nil, fail(CodeInvalidRequest, "orderSubmittedFrom 不能晚于 orderSubmittedTo")
+ }
+ if endDay != nil {
+ end := endDay.AddDate(0, 0, 1)
+ endDay = &end
+ }
+ return start, endDay, nil
}
type AdminTaskItem struct {
@@ -175,7 +215,17 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
if req.TaskType != "" && req.TaskType != models.PurchaseTaskTypeSYBOrder && req.TaskType != models.PurchaseTaskTypeStock {
return AdminListResponse{}, fail(CodeInvalidRequest, "taskType 无效")
}
+ submittedFrom, submittedTo, err := adminOrderSubmittedRange(req.OrderSubmittedFrom, req.OrderSubmittedTo)
+ if err != nil {
+ return AdminListResponse{}, err
+ }
query := s.DB.WithContext(ctx).Model(&models.PurchaseTask{})
+ if submittedFrom != nil {
+ query = query.Where("order_submitted_at >= ?", *submittedFrom)
+ }
+ if submittedTo != nil {
+ query = query.Where("order_submitted_at < ?", *submittedTo)
+ }
if req.TaskID > 0 {
query = query.Where("id = ?", req.TaskID)
}
diff --git a/server/app/goauto/purchase/admin_submitted_range_test.go b/server/app/goauto/purchase/admin_submitted_range_test.go
new file mode 100644
index 0000000..858e9cb
--- /dev/null
+++ b/server/app/goauto/purchase/admin_submitted_range_test.go
@@ -0,0 +1,129 @@
+package purchase
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
+ "github.com/google/uuid"
+ "go-admin/app/goauto/models"
+ "go-admin/app/goauto/purchasecontract"
+)
+
+// #372: order_submitted_at day filter, inclusive on both ends, UTC+8 days.
+func TestAdminOrderSubmittedRange(t *testing.T) {
+ db := testDB(t)
+ f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
+ s := testService(db)
+ base := createAdminQueryTask(t, s, f)
+ loc := time.FixedZone("UTC+8", 8*3600)
+ at := func(day string, h, m int) *time.Time {
+ d, _ := time.ParseInLocation("2006-01-02", day, loc)
+ v := d.Add(time.Duration(h)*time.Hour + time.Duration(m)*time.Minute)
+ return &v
+ }
+ cases := []struct {
+ name string
+ status string
+ at *time.Time
+ }{
+ {"before", models.PurchaseTaskStatusOrderCreated, at("2026-10-01", 23, 59)},
+ {"first-day-start", models.PurchaseTaskStatusOrderCreated, at("2026-10-02", 0, 0)},
+ {"middle", models.PurchaseTaskStatusOrderCreated, at("2026-10-03", 12, 0)},
+ {"last-day-end", models.PurchaseTaskStatusOrderCreated, at("2026-10-04", 23, 59)},
+ {"after", models.PurchaseTaskStatusOrderCreated, at("2026-10-05", 0, 0)},
+ {"middle-failed", models.PurchaseTaskStatusFailed, at("2026-10-03", 13, 0)},
+ {"no-time", models.PurchaseTaskStatusOrderCreated, nil},
+ }
+ for _, tc := range cases {
+ row := base
+ row.ID = 0
+ row.CreateRequestID = uuid.NewString()
+ row.ShopeeOrderNoSnapshot = tc.name
+ row.Status = tc.status
+ row.OrderSubmittedAt = tc.at
+ if err := db.Create(&row).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ names := func(req AdminListRequest) map[string]bool {
+ t.Helper()
+ out, err := s.AdminList(context.Background(), req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := map[string]bool{}
+ for _, item := range out.Items {
+ got[item.ShopeeOrderNoSnapshot] = true
+ }
+ if int(out.Total) != len(got) {
+ t.Fatalf("total %d != items %d", out.Total, len(got))
+ }
+ return got
+ }
+ both := names(AdminListRequest{OrderSubmittedFrom: "2026-10-02", OrderSubmittedTo: "2026-10-04"})
+ if len(both) != 4 || !both["first-day-start"] || !both["middle"] || !both["last-day-end"] || !both["middle-failed"] {
+ t.Fatalf("both days must be inclusive: %v", both)
+ }
+ combined := names(AdminListRequest{OrderSubmittedFrom: "2026-10-02", OrderSubmittedTo: "2026-10-04", Status: models.PurchaseTaskStatusOrderCreated})
+ if len(combined) != 3 || combined["middle-failed"] {
+ t.Fatalf("must combine with status: %v", combined)
+ }
+ if only := names(AdminListRequest{OrderSubmittedFrom: "2026-10-04"}); len(only) != 2 || !only["last-day-end"] || !only["after"] {
+ t.Fatalf("from only: %v", only)
+ }
+ if only := names(AdminListRequest{OrderSubmittedTo: "2026-10-02"}); len(only) != 2 || !only["before"] || !only["first-day-start"] {
+ t.Fatalf("to only: %v", only)
+ }
+ if one := names(AdminListRequest{OrderSubmittedFrom: "2026-10-03", OrderSubmittedTo: "2026-10-03"}); len(one) != 2 {
+ t.Fatalf("single day: %v", one)
+ }
+ if none := names(AdminListRequest{}); !none["no-time"] {
+ t.Fatalf("no filter must keep rows without order time: %v", none)
+ }
+}
+
+func TestAdminOrderSubmittedRangeInvalid(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ db := testDB(t)
+ f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
+ s := testService(db)
+ createAdminQueryTask(t, s, f)
+ for _, req := range []AdminListRequest{
+ {OrderSubmittedFrom: "2026/10/02"},
+ {OrderSubmittedTo: "10-02"},
+ {OrderSubmittedFrom: "2026-13-01"},
+ {OrderSubmittedFrom: "2026-10-05", OrderSubmittedTo: "2026-10-04"},
+ } {
+ if _, err := s.AdminList(context.Background(), req); code(err) != CodeInvalidRequest {
+ t.Fatalf("%+v: expected invalid request, got %v", req, err)
+ }
+ }
+ for _, query := range []string{"orderSubmittedFrom=abc", "orderSubmittedTo=2026-02-30"} {
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": "purchaser"})
+ c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/v1/purchase-tasks?"+query, nil)
+ (Handler{DB: db}).AdminList(c)
+ if !strings.Contains(w.Body.String(), CodeInvalidRequest) {
+ t.Fatalf("handler accepted %s: %s", query, w.Body.String())
+ }
+ }
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": "purchaser"})
+ c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/v1/purchase-tasks?orderSubmittedFrom=2026-10-01&orderSubmittedTo=2026-10-02", nil)
+ (Handler{DB: db}).AdminList(c)
+ var result struct {
+ Data AdminListResponse `json:"data"`
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil || w.Code != http.StatusOK {
+ t.Fatalf("valid range: %d %s", w.Code, w.Body.String())
+ }
+}
diff --git a/server/app/goauto/purchase/handler.go b/server/app/goauto/purchase/handler.go
index d10b0eb..2534311 100644
--- a/server/app/goauto/purchase/handler.go
+++ b/server/app/goauto/purchase/handler.go
@@ -52,10 +52,12 @@ func (h Handler) AdminList(c *gin.Context) {
response, err := service.AdminList(c.Request.Context(), AdminListRequest{
Page: page, PageSize: pageSize, TaskID: taskID, SYBProductID: sybProductID,
Status: strings.TrimSpace(c.Query("status")), ExecutionMode: strings.TrimSpace(c.Query("executionMode")),
- TaskType: strings.TrimSpace(c.Query("taskType")),
- ShopeeOrderNo: strings.TrimSpace(c.Query("shopeeOrderNo")),
- ShopeeOrderNos: c.Request.URL.Query()["shopeeOrderNos"],
- PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")),
+ TaskType: strings.TrimSpace(c.Query("taskType")),
+ ShopeeOrderNo: strings.TrimSpace(c.Query("shopeeOrderNo")),
+ ShopeeOrderNos: c.Request.URL.Query()["shopeeOrderNos"],
+ PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")),
+ OrderSubmittedFrom: c.Query("orderSubmittedFrom"),
+ OrderSubmittedTo: c.Query("orderSubmittedTo"),
})
if err != nil {
writeError(c, err)
diff --git a/server/app/goauto/returnmatch/service.go b/server/app/goauto/returnmatch/service.go
index 9300269..016147e 100644
--- a/server/app/goauto/returnmatch/service.go
+++ b/server/app/goauto/returnmatch/service.go
@@ -495,9 +495,12 @@ type ListFilter struct {
// sn, spec) so it never has to issue a second request per row.
type ListItem struct {
models.ReturnMatch
- OrderSN string `json:"orderSn,omitempty"`
- Image string `json:"image,omitempty"`
- VariationName string `json:"variationName,omitempty"`
+ OrderSN string `json:"orderSn,omitempty"`
+ Image string `json:"image,omitempty"`
+ VariationName string `json:"variationName,omitempty"`
+ // YeekeQuantity is the yeeke return item's own quantity (read-only, #372
+ // export); it comes from the same join and is never used for matching.
+ YeekeQuantity int64 `json:"yeekeQuantity"`
DestroyDeadline *time.Time `json:"destroyDeadline,omitempty"`
// SyncStatus/MissingSince (#338) surface the yeeke return item's own
// current availability ("ok"/"missing") so the SYB products match column
@@ -538,6 +541,7 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro
OrderSN string
Image string
VariationName string
+ Quantity int64
DestroyDeadLine *time.Time
SyncStatus string
MissingSince *time.Time
@@ -545,7 +549,7 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro
PackageMissingAt *time.Time `gorm:"column:package_missing_since"`
}
if err := s.DB.WithContext(ctx).Table("yeeke_return_item AS i").
- Select("i.id AS id, p.order_sn AS order_sn, i.image AS image, i.variation_name AS variation_name, p.destroy_dead_line AS destroy_dead_line, "+
+ Select("i.id AS id, p.order_sn AS order_sn, i.image AS image, i.variation_name AS variation_name, i.quantity AS quantity, p.destroy_dead_line AS destroy_dead_line, "+
"i.sync_status AS sync_status, i.missing_since AS missing_since, p.sync_status AS package_sync_status, p.missing_since AS package_missing_since").
Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id").
Where("i.id IN ?", returnIDs).
@@ -556,6 +560,7 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro
OrderSN string
Image string
VariationName string
+ Quantity int64
DestroyDeadLine *time.Time
SyncStatus string
MissingSince *time.Time
@@ -569,13 +574,14 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro
if j.PackageSyncStat != "ok" {
syncStatus, missingSince = j.PackageSyncStat, j.PackageMissingAt
}
- byID[j.ID] = extraFields{j.OrderSN, j.Image, j.VariationName, j.DestroyDeadLine, syncStatus, missingSince}
+ byID[j.ID] = extraFields{j.OrderSN, j.Image, j.VariationName, j.Quantity, j.DestroyDeadLine, syncStatus, missingSince}
}
for i := range items {
if extra, ok := byID[items[i].YeekeReturnItemID]; ok {
items[i].OrderSN = extra.OrderSN
items[i].Image = extra.Image
items[i].VariationName = extra.VariationName
+ items[i].YeekeQuantity = extra.Quantity
items[i].DestroyDeadline = extra.DestroyDeadLine
items[i].SyncStatus = extra.SyncStatus
items[i].MissingSince = extra.MissingSince
diff --git a/server/app/goauto/returnmatch/service_test.go b/server/app/goauto/returnmatch/service_test.go
index 95e02b8..d20edd2 100644
--- a/server/app/goauto/returnmatch/service_test.go
+++ b/server/app/goauto/returnmatch/service_test.go
@@ -2,6 +2,7 @@ package returnmatch
import (
"context"
+ "encoding/json"
"errors"
"fmt"
"strings"
@@ -484,3 +485,27 @@ func TestList_EnrichesWithYeekeReturnDisplayFields(t *testing.T) {
t.Fatalf("expected enriched list item: %+v", items)
}
}
+
+// #372: List exposes the yeeke return item's quantity (read-only export field).
+func TestList_ExposesYeekeQuantity(t *testing.T) {
+ db := testDB(t)
+ s := NewService(db)
+ s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
+ syb := seedSYB(t, db, "SYB-Q", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
+ deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
+ item := seedReturn(t, db, "白色,L", &deadline)
+ if err := db.Model(&models.YeekeReturnItem{}).Where("id = ?", item.ID).Update("quantity", 3).Error; err != nil {
+ t.Fatal(err)
+ }
+ if resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}}); err != nil || resp.MatchedCount != 1 {
+ t.Fatalf("setup failed: %v %+v", err, resp)
+ }
+ items, err := s.List(context.Background(), ListFilter{SYBProductIDs: []uint64{syb.ID}})
+ if err != nil || len(items) != 1 || items[0].YeekeQuantity != 3 {
+ t.Fatalf("expected yeekeQuantity 3: %+v %v", items, err)
+ }
+ raw, _ := json.Marshal(items[0])
+ if !strings.Contains(string(raw), `"yeekeQuantity":3`) {
+ t.Fatalf("json missing yeekeQuantity: %s", raw)
+ }
+}
diff --git a/web/src/utils/list-export.js b/web/src/utils/list-export.js
new file mode 100644
index 0000000..3e88474
--- /dev/null
+++ b/web/src/utils/list-export.js
@@ -0,0 +1,136 @@
+// #372: export every page of an admin list (current filters) to xlsx.
+// Pure helpers with injected dependencies so they can be unit tested without a
+// browser; the pages inject the API calls and the Export2Excel saver.
+
+export const EXPORT_MAX_ROWS = 5000
+
+const pad = (value, width = 2) => String(value).padStart(width, '0')
+
+// YYYY-MM-DD HH:mm:ss in the browser's local time zone; empty for blank/invalid.
+export function formatExportTime(value) {
+ if (!value) return ''
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) return ''
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
+}
+
+// YYYYMMDD-HHmmss for file names.
+export function formatExportStamp(date = new Date()) {
+ return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
+}
+
+// Cents -> yuan text with two decimals; null/undefined/'' stay empty.
+export function centToYuan(cent) {
+ if (cent === null || cent === undefined || cent === '') return ''
+ const value = Number(cent)
+ if (!Number.isFinite(value)) return ''
+ const sign = value < 0 ? '-' : ''
+ const abs = Math.abs(Math.round(value))
+ return `${sign}${Math.floor(abs / 100)}.${pad(abs % 100)}`
+}
+
+// yeeke only has one variation_name ("color,size"); split at the first English
+// or Chinese comma. Without a comma the whole text is the color.
+export function splitYeekeVariation(name) {
+ const text = String(name ?? '').trim()
+ if (!text) return { color: '', size: '' }
+ const index = text.search(/[,,]/)
+ if (index < 0) return { color: text, size: '' }
+ return { color: text.slice(0, index).trim(), size: text.slice(index + 1).trim() }
+}
+
+const blank = value => (value === null || value === undefined ? '' : value)
+
+export const PURCHASE_EXPORT_HEADER = ['SYB订单号', '虾皮商品ID', '颜色', '尺码', '数量', '状态', 'PDD单号', '下单时间', '价格(元)']
+
+// statusLabel: the page's own status -> Chinese label function.
+export function purchaseExportRow(task, statusLabel = value => value) {
+ return [
+ blank(task.shopeeOrderNoSnapshot), blank(task.shopeeItemIdSnapshot), blank(task.targetColorSnapshot), blank(task.targetSizeSnapshot),
+ blank(task.quantity), task.status ? statusLabel(task.status) : '', blank(task.pddOrderNo),
+ formatExportTime(task.orderSubmittedAt), centToYuan(task.pddOrderAmountCent)
+ ]
+}
+
+export const SYB_PRODUCT_EXPORT_HEADER = ['SYB订单', '虾皮商品ID', 'SYB颜色', 'SYB尺码', 'SYB数量', 'yeeke颜色', 'yeeke尺码', 'yeeke数量', '匹配时间']
+
+export const isActiveReturnMatch = match => Boolean(match) && (match.status === 'matched' || match.status === 'confirmed')
+
+// match: the active (matched/confirmed) return match of this product, or null.
+export function sybProductExportRow(product, match) {
+ const active = isActiveReturnMatch(match) ? match : null
+ const yeeke = active ? splitYeekeVariation(active.variationName) : { color: '', size: '' }
+ return [
+ blank(product.orderCode), blank(product.shopeeItemId), blank(product.targetColor), blank(product.targetSize), blank(product.quantity),
+ yeeke.color, yeeke.size, active ? blank(active.yeekeQuantity) : '', active ? formatExportTime(active.matchedAt) : ''
+ ]
+}
+
+export function exportFailureMessage(result, maxRows = EXPORT_MAX_ROWS) {
+ switch (result.reason) {
+ case 'empty': return '当前筛选没有数据,未生成文件'
+ case 'too_many': return `共 ${result.total} 条,超过 ${maxRows} 条上限,请缩小筛选范围`
+ case 'page_failed': return `导出失败:第 ${result.page} 页读取失败,未生成文件`
+ case 'enrich_failed': return '导出失败:匹配信息读取失败,未生成文件'
+ default: return '导出失败,未生成文件'
+ }
+}
+
+function chunk(items, size) {
+ const result = []
+ for (let i = 0; i < items.length; i += size) result.push(items.slice(i, i + size))
+ return result
+}
+
+// Reads page 1 for the total, refuses above maxRows, then reads the remaining
+// pages. Any failure returns { ok: false } and nothing is saved.
+// fetchPage(page, pageSize) -> { items, total }
+// enrich(items) -> decorated items of the same length (optional, per batch)
+// mapRow(item) -> array of cells
+// save({ header, data, filename }) -> writes the file (only after success)
+export async function runListExport({
+ fetchPage, pageSize, maxRows = EXPORT_MAX_ROWS, enrich = null, enrichBatchSize = 200,
+ header, mapRow, filename, save, onProgress = () => {}
+}) {
+ let first
+ try {
+ first = await fetchPage(1, pageSize)
+ } catch (error) {
+ return { ok: false, reason: 'page_failed', page: 1, error }
+ }
+ const total = Number(first?.total) || 0
+ if (total === 0) return { ok: false, reason: 'empty', total: 0 }
+ if (total > maxRows) return { ok: false, reason: 'too_many', total }
+ const pages = Math.ceil(total / pageSize)
+ const enrichSteps = enrich ? Math.ceil(total / enrichBatchSize) : 0
+ const steps = pages + enrichSteps
+ let done = 1
+ onProgress(done, steps)
+ let items = [...(first.items || [])]
+ for (let page = 2; page <= pages; page++) {
+ let response
+ try {
+ response = await fetchPage(page, pageSize)
+ } catch (error) {
+ return { ok: false, reason: 'page_failed', page, error }
+ }
+ items = items.concat(response?.items || [])
+ onProgress(++done, steps)
+ }
+ if (enrich) {
+ const decorated = []
+ for (const part of chunk(items, enrichBatchSize)) {
+ try {
+ decorated.push(...await enrich(part))
+ } catch (error) {
+ return { ok: false, reason: 'enrich_failed', error }
+ }
+ onProgress(++done, steps)
+ }
+ items = decorated
+ }
+ if (items.length === 0) return { ok: false, reason: 'empty', total: 0 }
+ const data = items.map(mapRow)
+ await save({ header, data, filename: `${filename}_${formatExportStamp()}` })
+ return { ok: true, total, rows: data.length }
+}
diff --git a/web/src/utils/save-excel.js b/web/src/utils/save-excel.js
new file mode 100644
index 0000000..273026a
--- /dev/null
+++ b/web/src/utils/save-excel.js
@@ -0,0 +1,5 @@
+// Loads the xlsx writer on demand so the list pages do not pull it into their chunk.
+export async function saveExcel(file) {
+ const { export_json_to_excel: exportJsonToExcel } = await import('@/vendor/Export2Excel')
+ exportJsonToExcel(file)
+}
diff --git a/web/src/views/goauto/purchase-tasks/index.vue b/web/src/views/goauto/purchase-tasks/index.vue
index 08ba033..4b06035 100644
--- a/web/src/views/goauto/purchase-tasks/index.vue
+++ b/web/src/views/goauto/purchase-tasks/index.vue
@@ -16,7 +16,8 @@