diff --git a/admin/AGENTS.md b/admin/AGENTS.md index 2d3f5a4..598cc5c 100644 --- a/admin/AGENTS.md +++ b/admin/AGENTS.md @@ -91,8 +91,8 @@ ## 界面规则 -- 四个模块统一使用三段式布局:**顶部工具条 / 中间带勾选的表格 / 底部状态条**。 - 第一个页面写完,其余三个照抄结构改字段,不要给某个模块搞特殊。 +- 五个模块统一使用三段式布局:**顶部工具条 / 中间带勾选的表格 / 底部状态条**。 + 第一个页面写完,其余四个照抄结构改字段,不要给某个模块搞特殊。 - 表格行的身份用业务主键,**不得用行号**。 - 批量删除必须二次确认,并显示"将删除 N 条"。 - 破坏性操作(删除、导入覆盖)用 POST,**不得用 GET**。 diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go index dbdf948..5bbed93 100644 --- a/admin/handler/web/others.go +++ b/admin/handler/web/others.go @@ -4,9 +4,11 @@ import ( "fmt" "log" "net/http" + "net/url" "github.com/gin-gonic/gin" + "cmautobuy/admin/repository" "cmautobuy/admin/service" ) @@ -70,29 +72,114 @@ func (h *Handler) SybDelete(c *gin.Context) { fail(c, http.StatusNotImplemented, "删除功能尚未实现。") } -// ---------- 3. 采购任务 ---------- - -// TaskList 渲染采购任务列表页。 +// ---------- 3. 采集采购 ---------- // -// 注意颜色尺码显示的是 **PDD 侧**的规格(实际要买的),不是蝦皮的。 -// 价格上限显示成 ¥42.00,底层存的是整数分。 +// `tasks` 是一张表,用 task_type 区分采集和采购,这个模块把两种任务放在 +// 同一个列表里显示,靠「目标」一列概括各自的业务信息,见 #19。 +// 拼接和翻译逻辑全在 service/task.go,这里只做取参数 → 调 service → 渲染。 + +// TaskList 渲染「采集采购」列表页。 func (h *Handler) TaskList(c *gin.Context) { - keyword := c.Query("order_no") + filter := repository.TaskFilter{ + Type: service.ParseTaskType(c.Query("type")), + Status: service.ParseTaskStatus(c.Query("status")), + Keyword: c.Query("q"), + } - // TODO(骨架): 查 tasks,task_type = 'purchase' - var rows []gin.H + result, err := service.ListTasksView(h.db, filter) + if err != nil { + fail(c, http.StatusInternalServerError, + "读取任务列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。") + return + } - c.HTML(http.StatusOK, "task/list", page(c, "tasks", "采购任务", gin.H{ - "Keyword": keyword, - "Rows": rows, - "Status": "尚未实现:创建任务后这里显示执行进度", + // 底部状态条平时显示统计,刚做完删除操作时先显示操作结果, + // 跳转带过来的 msg 参数,见 h.taskRedirect。 + statusLine := result.StatusLine() + if msg := c.Query("msg"); msg != "" { + statusLine = msg + " · " + statusLine + } + + c.HTML(http.StatusOK, "task/list", page(c, "tasks", "采集采购", gin.H{ + "Rows": result.Rows, + "Keyword": filter.Keyword, + "Status": statusLine, + "TypeFilter": string(filter.Type), + "StatusFilter": string(filter.Status), + "TypeOptions": service.TaskTypeOptions(), + "StatusOptions": service.TaskStatusOptions(), + "IsFiltered": result.IsFiltered, })) } +// TaskDetail 渲染双击行弹出的只读详情弹窗内容(不是整页)。 +// +// `[必须]` 只读。改派 / 重试 / 取消是后续工单的范围,这里不提供入口。 +// 弹窗机制复用 #18 已有的那套(static/js/app.js 的 setupRowDetail), +// 不新造一套。 +func (h *Handler) TaskDetail(c *gin.Context) { + taskID := c.Query("id") + if taskID == "" { + fail(c, http.StatusBadRequest, "任务编号不对,请刷新页面后重试。") + return + } + + detail, err := service.GetTaskDetail(h.db, taskID) + if err != nil { + fail(c, http.StatusInternalServerError, "读取任务详情失败,数据没有被改动。") + return + } + if detail == nil { + fail(c, http.StatusNotFound, "这个任务不存在,请刷新页面。") + return + } + + c.HTML(http.StatusOK, "task/detail_modal", gin.H{"D": detail}) +} + // TaskDelete 批量删除任务。 +// +// `tasks` 表没有软删除列——加一列是数据库结构变更,不在本工单范围内, +// 见 repository.DeleteTasks 的注释。二次确认在前端做(data-confirm-delete)。 func (h *Handler) TaskDelete(c *gin.Context) { - // TODO(骨架) - fail(c, http.StatusNotImplemented, "删除功能尚未实现。") + ids := c.PostFormArray("ids") + if len(ids) == 0 { + h.taskRedirect(c, "没有勾选任何任务,没有删除") + return + } + + n, err := service.DeleteTasks(h.db, ids) + if err != nil { + fail(c, http.StatusInternalServerError, + "删除失败:"+err.Error()+"。没有删除任何记录。") + return + } + h.taskRedirect(c, fmt.Sprintf("已删除 %d 条", n)) +} + +// taskRedirect 处理完写操作后跳回列表页,带上当前筛选条件, +// 免得操作员每做一次删除就要重新筛一遍。用 303 而不是直接渲染, +// 是为了让浏览器地址栏变成 GET /tasks——按 F5 不会重复提交刚才的删除。 +func (h *Handler) taskRedirect(c *gin.Context, msg string) { + params := url.Values{} + if t := c.PostForm("type"); t != "" { + params.Set("type", t) + } + if s := c.PostForm("status"); s != "" { + params.Set("status", s) + } + if q := c.PostForm("q"); q != "" { + params.Set("q", q) + } + if msg != "" { + params.Set("msg", msg) + } + + target := "/tasks" + if len(params) > 0 { + target += "?" + params.Encode() + } + c.Redirect(http.StatusSeeOther, target) } // ---------- 4. 客户端列表 ---------- diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go index b7fee5c..8953db7 100644 --- a/admin/handler/web/web.go +++ b/admin/handler/web/web.go @@ -64,8 +64,9 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) { pages.POST("/syb/create-task", h.SybCreateTask) pages.POST("/syb/delete", h.SybDelete) - // 4. 采购任务 + // 4. 采集采购 pages.GET("/tasks", h.TaskList) + pages.GET("/tasks/detail", h.TaskDetail) // 双击行时前端来取弹窗内容 pages.POST("/tasks/delete", h.TaskDelete) // 5. 客户端列表 diff --git a/admin/main.go b/admin/main.go index a48e549..9fc9a26 100644 --- a/admin/main.go +++ b/admin/main.go @@ -1,5 +1,5 @@ -// Admin 是本地运行的 Web 管理端:管理蝦皮商品、顺运宝货运单、 -// 采购任务和客户端,并给 Client 提供三个接口。 +// Admin 是本地运行的 Web 管理端:管理蝦皮商品、PDD 商品、顺运宝货运单、 +// 采集采购和客户端,并给 Client 提供三个接口。 // // 启动:go run . 然后打开 http://127.0.0.1:8080 // 这个命令是安全的——Admin 只管理数据,不会连手机、不会下单。 diff --git a/admin/repository/task.go b/admin/repository/task.go index 0be6732..f1d80f2 100644 --- a/admin/repository/task.go +++ b/admin/repository/task.go @@ -263,6 +263,164 @@ func MarkTaskFailure(q Execer, taskID string, newStatus model.TaskStatus, errCod return nil } +// ---------- 采集采购列表(#19) ---------- + +// TaskFilter 是「采集采购」列表页支持的筛选条件,三项都可以为空。 +type TaskFilter struct { + Type model.TaskType + Status model.TaskStatus + Keyword string // 同时匹配任务编号、订单号、PDD 商品 ID +} + +// TaskListRow 是列表一行要用到的原始字段,还没翻成界面文字—— +// 那是 service 层的事(尤其是「目标」列的拼接,见 #19)。 +type TaskListRow struct { + TaskID string + TaskType model.TaskType + Status model.TaskStatus + AssignedClient string // 空表示无主任务 + + OrderNo string + PddGoodsID string + PddOptions string + Quantity int + MaxPriceCent int64 + + UpdatedAt string + + // PddTitle 是 join pdd_products 拿到的标题,**可能为空**: + // 没有对应商品、或商品已被软删除时都是空。 + // `[必须]` service 层退回显示 PddGoodsID,不能显示空,见 #19。 + PddTitle string +} + +// taskFilterClause 把三项筛选条件拼成 WHERE 子句,供 ListTasks 和 +// CountTasksByStatus 共用——两处筛选逻辑必须完全一致, +// 否则底部统计会跟表格对不上(见 #19 的验收要求)。 +func taskFilterClause(filter TaskFilter) (string, []any) { + var clauses []string + var args []any + + if filter.Type != "" { + clauses = append(clauses, "t.task_type = ?") + args = append(args, string(filter.Type)) + } + if filter.Status != "" { + clauses = append(clauses, "t.status = ?") + args = append(args, string(filter.Status)) + } + if kw := strings.TrimSpace(filter.Keyword); kw != "" { + pattern := "%" + escapeLike(kw) + "%" + clauses = append(clauses, + "(t.task_id LIKE ? ESCAPE '\\' OR t.order_no LIKE ? ESCAPE '\\' OR t.pdd_goods_id LIKE ? ESCAPE '\\')") + args = append(args, pattern, pattern, pattern) + } + + if len(clauses) == 0 { + return "", args + } + return " WHERE " + strings.Join(clauses, " AND "), args +} + +// ListTasks 查采集采购列表,按更新时间倒序。 +// +// `[必须]` 目标列所需字段全在 tasks 表上,不需要 join。 +// `[建议]` 这里额外 LEFT JOIN 了 pdd_products 取标题,纯粹是为了让采集任务的 +// 「目标」列更好读;join 条件里的 `deleted_at IS NULL` 让"join 不到"和 +// "商品已软删除"这两种情况都自然落到 PddTitle 为空,service 层退回显示 +// PddGoodsID 即可,不用在 service 里再判断一次已删除。 +func ListTasks(q Execer, filter TaskFilter) ([]TaskListRow, error) { + where, args := taskFilterClause(filter) + sqlText := ` + SELECT t.task_id, t.task_type, t.status, t.assigned_client, + t.order_no, t.pdd_goods_id, t.pdd_options, t.quantity, t.max_price_cent, + t.updated_at, p.title + FROM tasks t + LEFT JOIN pdd_products p + ON p.goods_id = t.pdd_goods_id AND p.deleted_at IS NULL` + + where + ` ORDER BY t.updated_at DESC, t.task_id DESC` + + rows, err := q.Query(sqlText, args...) + if err != nil { + return nil, fmt.Errorf("查询任务列表失败: %w", err) + } + defer rows.Close() + + list := make([]TaskListRow, 0, 16) + for rows.Next() { + var r TaskListRow + var assigned, orderNo, pddGoodsID, pddOptions, title sql.NullString + var quantity, maxPrice sql.NullInt64 + + if err := rows.Scan( + &r.TaskID, &r.TaskType, &r.Status, &assigned, + &orderNo, &pddGoodsID, &pddOptions, &quantity, &maxPrice, + &r.UpdatedAt, &title, + ); err != nil { + return nil, fmt.Errorf("读取任务列表失败: %w", err) + } + + r.AssignedClient = assigned.String + r.OrderNo = orderNo.String + r.PddGoodsID = pddGoodsID.String + r.PddOptions = pddOptions.String + r.Quantity = int(quantity.Int64) + r.MaxPriceCent = maxPrice.Int64 + r.PddTitle = title.String + list = append(list, r) + } + return list, rows.Err() +} + +// CountTasksByStatus 按当前筛选统计各状态的任务数。 +// +// `[必须]` **用和 ListTasks 完全相同的筛选条件**——统计要跟随当前筛选, +// 筛了「采集」就只统计采集任务,否则底部数字和表格对不上,见 #19。 +func CountTasksByStatus(q Execer, filter TaskFilter) (map[model.TaskStatus]int, error) { + where, args := taskFilterClause(filter) + sqlText := `SELECT t.status, COUNT(*) FROM tasks t` + where + ` GROUP BY t.status` + + rows, err := q.Query(sqlText, args...) + if err != nil { + return nil, fmt.Errorf("统计任务状态失败: %w", err) + } + defer rows.Close() + + counts := map[model.TaskStatus]int{} + for rows.Next() { + var status string + var n int + if err := rows.Scan(&status, &n); err != nil { + return nil, err + } + counts[model.TaskStatus(status)] = n + } + return counts, rows.Err() +} + +// DeleteTasks 按任务编号批量删除,返回实际删掉的条数。 +// +// `[必须]` 硬删,不是软删除:tasks 表本来就没有 deleted_at 列, +// 加一列属于数据库结构变更,不在本工单范围内(见 #19「不做」清单)。 +// 遗留下来的 task_claims 记录不删——它没有外键约束,留着不影响任何查询, +// 之后想清理是可以独立做的小事,不值得为它扩大这次改动的范围。 +func DeleteTasks(q Execer, taskIDs []string) (int64, error) { + if len(taskIDs) == 0 { + return 0, nil + } + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(taskIDs)), ",") + args := make([]any, len(taskIDs)) + for i, id := range taskIDs { + args[i] = id + } + + res, err := q.Exec(`DELETE FROM tasks WHERE task_id IN (`+placeholders+`)`, args...) + if err != nil { + return 0, fmt.Errorf("批量删除任务失败: %w", err) + } + return res.RowsAffected() +} + // InsertCollectTask 建一条采集任务。 // // `[必须]` 采集任务**不指定客户端**:assigned_client 为 NULL、status 为 pending, diff --git a/admin/service/task.go b/admin/service/task.go new file mode 100644 index 0000000..1c50e83 --- /dev/null +++ b/admin/service/task.go @@ -0,0 +1,391 @@ +// 「采集采购」模块的业务逻辑(#19)。 +// +// `tasks` 是一张表,用 task_type 区分采集和采购。这一层的核心工作是把 +// 两种业务字段完全不同的任务,统一翻成界面能直接显示的东西——尤其是 +// 「目标」这一列:拼接逻辑必须放在这里,模板只负责显示, +// 散在模板里的话谁都维护不了。 +// +// 改动前必读 admin/AGENTS.md 的分层约定:本层**不碰 HTTP,也不拼 SQL**。 +package service + +import ( + "database/sql" + "encoding/json" + "fmt" + "sort" + "strings" + + "cmautobuy/admin/model" + "cmautobuy/admin/repository" +) + +// ---------- 界面文字:类型 ---------- + +var taskTypeTexts = map[model.TaskType]string{ + model.TaskCollect: "采集", + model.TaskPurchase: "采购", +} + +// TaskTypeOption 是类型筛选下拉框的一项。 +type TaskTypeOption struct { + Value string // 空串表示"全部" + Text string +} + +// TaskTypeOptions 返回类型筛选下拉框的全部选项。 +func TaskTypeOptions() []TaskTypeOption { + return []TaskTypeOption{ + {"", "全部"}, + {string(model.TaskCollect), taskTypeTexts[model.TaskCollect]}, + {string(model.TaskPurchase), taskTypeTexts[model.TaskPurchase]}, + } +} + +// ParseTaskType 校验类型筛选参数。认不出的一律当"全部",不报错—— +// 地址栏参数是用户可以随便改的,不值得为它弹错误页。 +func ParseTaskType(s string) model.TaskType { + t := model.TaskType(strings.TrimSpace(s)) + if _, ok := taskTypeTexts[t]; ok { + return t + } + return "" +} + +func taskTypeText(t model.TaskType) string { + if s, ok := taskTypeTexts[t]; ok { + return s + } + return string(t) +} + +// ---------- 界面文字:状态 ---------- + +// taskStatusOrder 定下状态在筛选框和底部状态条里的显示顺序, +// 跟 docs/admin/01-requirements.md §6.2 的表格顺序保持一致。 +var taskStatusOrder = []model.TaskStatus{ + model.TaskPending, model.TaskAssigned, model.TaskClaimed, + model.TaskSucceeded, model.TaskManualReview, model.TaskFailed, model.TaskCancelled, +} + +var taskStatusTexts = map[model.TaskStatus]string{ + model.TaskPending: "待分配", + model.TaskAssigned: "待领取", + model.TaskClaimed: "已领取", + model.TaskSucceeded: "成功", + model.TaskManualReview: "需人工", + model.TaskFailed: "失败", + model.TaskCancelled: "已取消", +} + +// TaskStatusOption 是状态筛选下拉框的一项。 +type TaskStatusOption struct { + Value string + Text string +} + +// TaskStatusOptions 返回状态筛选下拉框的全部选项:全部 + 7 个状态。 +func TaskStatusOptions() []TaskStatusOption { + opts := make([]TaskStatusOption, 0, len(taskStatusOrder)+1) + opts = append(opts, TaskStatusOption{"", "全部"}) + for _, s := range taskStatusOrder { + opts = append(opts, TaskStatusOption{string(s), taskStatusTexts[s]}) + } + return opts +} + +// ParseTaskStatus 校验状态筛选参数,规则同 ParseTaskType。 +func ParseTaskStatus(s string) model.TaskStatus { + st := model.TaskStatus(strings.TrimSpace(s)) + if _, ok := taskStatusTexts[st]; ok { + return st + } + return "" +} + +func taskStatusText(s model.TaskStatus) string { + if t, ok := taskStatusTexts[s]; ok { + return t + } + return string(s) +} + +// isTaskWarn 判断这一行要不要标黄提醒。 +// 失败和需人工都是操作员该去看一眼的状态,跟 PDD 商品页对失败行的处理一致。 +func isTaskWarn(s model.TaskStatus) bool { + return s == model.TaskFailed || s == model.TaskManualReview +} + +// clientText 是「分配客户端」列/字段的统一显示规则。 +// +// `[必须]` 无主任务显示 `—`,不是空白也不是 ``——#17 之后 +// 采集任务默认无主,这一列会大量为空,显示不出来会让人以为页面坏了。 +func clientText(assignedClient string) string { + if assignedClient == "" { + return placeholder + } + return assignedClient +} + +// ---------- 目标列 ---------- + +// buildTarget 把一行任务的业务字段拼成「目标」列要显示的一句话。 +// +// `[必须]` 拼接逻辑放在这里,不要散到模板里——见 #19。 +func buildTarget(r repository.TaskListRow) string { + if r.TaskType == model.TaskPurchase { + return buildPurchaseTarget(r) + } + // 默认按采集任务处理:目前只有 collect/purchase 两种类型, + // 万一将来出现认不出的类型,退回采集的展示方式好过什么都不显示。 + return buildCollectTarget(r) +} + +// buildCollectTarget 采集任务的目标:`PDD `, +// 能 join 到未删除商品的标题时追加显示,join 不到或商品已软删除 +// 时 PddTitle 本来就是空,天然退回只显示 goods_id。 +func buildCollectTarget(r repository.TaskListRow) string { + id := r.PddGoodsID + if id == "" { + id = placeholder + } + target := "PDD " + id + if r.PddTitle != "" { + target += " · " + r.PddTitle + } + return target +} + +// buildPurchaseTarget 采购任务的目标:`<订单号> · <颜色/尺码> · <数量>件 · ≤<价格上限>`。 +// 每一段都可能缺,缺了就显示占位符,不静默拼出一句不完整的话。 +func buildPurchaseTarget(r repository.TaskListRow) string { + orderNo := r.OrderNo + if orderNo == "" { + orderNo = placeholder + } + parts := []string{ + orderNo, + specText(r.PddOptions), + quantityText(r.Quantity), + priceLimitText(r.MaxPriceCent), + } + return strings.Join(parts, " · ") +} + +// specText 把 pdd_options(形如 {"color":"黑色","size":"M码"})翻成 +// "黑色/M码" 这样的一句话。 +// +// 按 key 排序拼接,不是按 JSON 原始顺序——JSON 对象本来就无序, +// 直接按解析出的 map 遍历会导致同一条任务每次刷新页面顺序都不一样。 +func specText(rawOptions string) string { + if strings.TrimSpace(rawOptions) == "" { + return placeholder + } + var options map[string]string + if err := json.Unmarshal([]byte(rawOptions), &options); err != nil || len(options) == 0 { + return placeholder + } + keys := make([]string, 0, len(options)) + for k := range options { + keys = append(keys, k) + } + sort.Strings(keys) + + values := make([]string, 0, len(keys)) + for _, k := range keys { + values = append(values, options[k]) + } + return strings.Join(values, "/") +} + +// quantityText 把数量翻成"2件"。quantity 为 0(数据库约束下等价于 NULL) +// 时显示占位符,不显示"0件"——那会被误读成"数量是 0"。 +func quantityText(quantity int) string { + if quantity <= 0 { + return placeholder + "件" + } + return fmt.Sprintf("%d件", quantity) +} + +// priceLimitText 把价格上限翻成"≤¥42.00"。 +// +// `[必须]` 复用 formatPriceCent,不要另写一份价格格式化逻辑(见 #19)。 +// cent <= 0 时(数据库约束下等价于 NULL)显示占位符,不显示 ≤¥0.00—— +// 那会让人以为上限是 0 元。 +func priceLimitText(cent int64) string { + if cent <= 0 { + return "≤" + placeholder + } + return "≤" + formatPriceCent(¢) +} + +// ---------- 列表 ---------- + +// TaskView 是列表页一行要显示的全部内容,全部已经是字符串。 +type TaskView struct { + TaskID string + TypeText string + Target string + StatusText string + IsWarn bool // 失败 / 需人工,标黄提醒 + ClientText string + UpdatedAt string +} + +// TaskListResult 是列表页要的全部数据。 +type TaskListResult struct { + Rows []TaskView + Counts map[model.TaskStatus]int + Total int // 当前筛选下的总数(= Counts 求和),不是全库总数 + + // IsFiltered 为 false 时如果 Rows 也是空的,说明库里从来没建过任务 + // (不筛选就是查全表,全表空自然等价于"从没有过");为 true 时 + // Rows 为空则是"当前筛选没有结果"——两种空状态文案不同,模板据此区分。 + IsFiltered bool +} + +// ListTasksView 查列表并把每一行翻成界面文字。 +// +// `[必须]` 底部统计要跟随当前筛选,所以 Counts 和 Rows 用的是**同一个** +// filter,见 #19。 +func ListTasksView(db *sql.DB, filter repository.TaskFilter) (*TaskListResult, error) { + rows, err := repository.ListTasks(db, filter) + if err != nil { + return nil, err + } + counts, err := repository.CountTasksByStatus(db, filter) + if err != nil { + return nil, err + } + + result := &TaskListResult{ + Rows: make([]TaskView, 0, len(rows)), + Counts: counts, + IsFiltered: filter.Type != "" || filter.Status != "" || + strings.TrimSpace(filter.Keyword) != "", + } + for _, n := range counts { + result.Total += n + } + + for _, r := range rows { + result.Rows = append(result.Rows, TaskView{ + TaskID: r.TaskID, + TypeText: taskTypeText(r.TaskType), + Target: buildTarget(r), + StatusText: taskStatusText(r.Status), + IsWarn: isTaskWarn(r.Status), + ClientText: clientText(r.AssignedClient), + UpdatedAt: formatLocalTime(r.UpdatedAt), + }) + } + return result, nil +} + +// StatusLine 拼底部状态条,形如: +// +// 共 42 条 · 待分配 3 · 待领取 5 · 已领取 2 · 成功 30 · 需人工 1 · 失败 1 · 已取消 0 +// +// `[必须]` 这里的每一个数字都来自当前筛选下的 Counts,不是全库统计—— +// 筛了「采集」就只统计采集任务,否则数字和表格对不上,见 #19。 +func (r *TaskListResult) StatusLine() string { + parts := []string{fmt.Sprintf("共 %d 条", r.Total)} + for _, s := range taskStatusOrder { + parts = append(parts, fmt.Sprintf("%s %d", taskStatusText(s), r.Counts[s])) + } + return strings.Join(parts, " · ") +} + +// DeleteTasks 批量删除任务,返回实际删掉的条数。 +func DeleteTasks(db *sql.DB, taskIDs []string) (int64, error) { + return repository.DeleteTasks(db, dedupe(taskIDs)) +} + +// ---------- 详情弹窗 ---------- + +// resultDataLimit 是弹窗里展开显示 result_data 的最大字符数。 +// 客户端提交的完整结果可能很大,超过这个长度就截断, +// 避免一次性把几十 KB 的 JSON 糊在页面上。 +const resultDataLimit = 4000 + +// TaskDetailView 是双击弹窗要显示的全部内容。 +// +// `[必须]` 只读——本工单不做改派、重试、取消,弹窗里不应该有对应的表单。 +type TaskDetailView struct { + TaskID string + TypeText string + StatusText string + ClientText string + ClaimedAt string + FinishedAt string + + PddGoodsURL string + + // IsPurchase 为 false 时,模板要把下面四个采购专有字段整段隐藏, + // 不能显示空行——见 #19。 + IsPurchase bool + SpecText string + QuantityText string + PriceLimitText string + + HasError bool + ErrorCode string + ErrorMessage string + + HasResult bool + ResultData string + ResultTruncated bool +} + +// GetTaskDetail 读一条任务的完整信息,翻成弹窗要显示的文字。 +// 任务不存在返回 (nil, nil)。 +func GetTaskDetail(db *sql.DB, taskID string) (*TaskDetailView, error) { + t, err := repository.GetTask(db, taskID) + if err != nil || t == nil { + return nil, err + } + + v := &TaskDetailView{ + TaskID: t.TaskID, + TypeText: taskTypeText(t.TaskType), + StatusText: taskStatusText(t.Status), + ClientText: clientText(t.AssignedClient), + ClaimedAt: formatLocalTime(t.ClaimedAt), + FinishedAt: formatLocalTime(t.FinishedAt), + PddGoodsURL: t.PddGoodsURL, + IsPurchase: t.TaskType == model.TaskPurchase, + } + if v.IsPurchase { + v.SpecText = specText(t.PddOptions) + v.QuantityText = quantityText(t.Quantity) + v.PriceLimitText = priceLimitText(t.MaxPriceCent) + } + + if t.ErrorCode != "" || t.ErrorMessage != "" { + v.HasError = true + v.ErrorCode = orPlaceholder(t.ErrorCode) + v.ErrorMessage = orPlaceholder(t.ErrorMessage) + } + + if strings.TrimSpace(t.ResultData) != "" { + v.HasResult = true + v.ResultData, v.ResultTruncated = truncateResultData(t.ResultData) + } + return v, nil +} + +func orPlaceholder(s string) string { + if s == "" { + return placeholder + } + return s +} + +// truncateResultData 超过 resultDataLimit 个字符就截断, +// 并如实告诉操作员被截断了(不能悄悄截断,那会让人以为结果就这么短)。 +func truncateResultData(raw string) (string, bool) { + r := []rune(raw) + if len(r) <= resultDataLimit { + return raw, false + } + return string(r[:resultDataLimit]), true +} diff --git a/admin/service/task_test.go b/admin/service/task_test.go new file mode 100644 index 0000000..1c5b45c --- /dev/null +++ b/admin/service/task_test.go @@ -0,0 +1,455 @@ +package service + +import ( + "database/sql" + "testing" + + "cmautobuy/admin/model" + "cmautobuy/admin/repository" +) + +// insertTestTask 插一条任务,字段按需覆盖默认值,供本文件的用例使用。 +// 跟 client_test.go 里的 insertTask 不一样:那个专为"领取"场景写死了固定值, +// 这里需要覆盖类型、状态、options 等更多维度。 +type testTaskParams struct { + taskID string + taskType model.TaskType + status model.TaskStatus + assignedClient string + orderNo string + pddGoodsID string + pddOptions string + quantity int + maxPriceCent int64 + errorCode string + errorMessage string + resultData string + claimedAt string + finishedAt string +} + +func insertTestTask(t *testing.T, db *sql.DB, p testTaskParams) { + t.Helper() + now := model.NowISO() + + var assigned, orderNo, pddGoodsID, pddOptions, errCode, errMsg, resultData, claimedAt, finishedAt any + if p.assignedClient != "" { + assigned = p.assignedClient + } + if p.orderNo != "" { + orderNo = p.orderNo + } + if p.pddGoodsID != "" { + pddGoodsID = p.pddGoodsID + } + if p.pddOptions != "" { + pddOptions = p.pddOptions + } + if p.errorCode != "" { + errCode = p.errorCode + } + if p.errorMessage != "" { + errMsg = p.errorMessage + } + if p.resultData != "" { + resultData = p.resultData + } + if p.claimedAt != "" { + claimedAt = p.claimedAt + } + if p.finishedAt != "" { + finishedAt = p.finishedAt + } + + var quantity, maxPrice any + if p.quantity > 0 { + quantity = p.quantity + } + if p.maxPriceCent > 0 { + maxPrice = p.maxPriceCent + } + + _, err := db.Exec(` + INSERT INTO tasks (task_id, task_type, status, assigned_client, claimed_at, + order_no, pdd_goods_url, pdd_goods_id, pdd_options, + quantity, max_price_cent, + error_code, error_message, result_data, finished_at, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 'https://mobile.yangkeduo.com/goods.html?goods_id=1', ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?)`, + p.taskID, string(p.taskType), string(p.status), assigned, claimedAt, + orderNo, pddGoodsID, pddOptions, + quantity, maxPrice, + errCode, errMsg, resultData, finishedAt, + now, now) + if err != nil { + t.Fatalf("插入测试任务 %s 失败: %v", p.taskID, err) + } +} + +// ── 列表:同时显示采集和采购 ────────────────────────── + +func TestListTasksView_同时显示采集和采购(t *testing.T) { + db := newTestDB(t) + insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"}) + insertTestTask(t, db, testTaskParams{taskID: "PUR-1", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-1"}) + + result, err := ListTasksView(db, repository.TaskFilter{}) + if err != nil { + t.Fatalf("查询失败: %v", err) + } + if len(result.Rows) != 2 { + t.Fatalf("应该看到 2 条任务,实际 %d 条", len(result.Rows)) + } +} + +// ── 类型 / 状态 / 关键词筛选 ────────────────────────── + +func TestListTasksView_类型筛选生效(t *testing.T) { + db := newTestDB(t) + insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"}) + insertTestTask(t, db, testTaskParams{taskID: "PUR-1", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-1"}) + + result, err := ListTasksView(db, repository.TaskFilter{Type: model.TaskCollect}) + if err != nil { + t.Fatalf("查询失败: %v", err) + } + if len(result.Rows) != 1 || result.Rows[0].TaskID != "COL-1" { + t.Fatalf("筛选采集应该只剩 COL-1,实际 %+v", result.Rows) + } + if !result.IsFiltered { + t.Error("有类型筛选时 IsFiltered 应该为 true") + } +} + +func TestListTasksView_状态筛选生效(t *testing.T) { + db := newTestDB(t) + insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"}) + insertTestTask(t, db, testTaskParams{taskID: "COL-2", taskType: model.TaskCollect, status: model.TaskSucceeded, pddGoodsID: "222"}) + + result, err := ListTasksView(db, repository.TaskFilter{Status: model.TaskSucceeded}) + if err != nil { + t.Fatalf("查询失败: %v", err) + } + if len(result.Rows) != 1 || result.Rows[0].TaskID != "COL-2" { + t.Fatalf("筛选'成功'应该只剩 COL-2,实际 %+v", result.Rows) + } +} + +func TestListTasksView_关键词同时匹配任务编号订单号PDD商品ID(t *testing.T) { + db := newTestDB(t) + insertTestTask(t, db, testTaskParams{taskID: "COL-ABC", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "737116531267"}) + insertTestTask(t, db, testTaskParams{taskID: "PUR-1", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-XYZ"}) + insertTestTask(t, db, testTaskParams{taskID: "PUR-2", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-OTHER"}) + + // 按任务编号命中 + r, err := ListTasksView(db, repository.TaskFilter{Keyword: "ABC"}) + if err != nil || len(r.Rows) != 1 || r.Rows[0].TaskID != "COL-ABC" { + t.Fatalf("按任务编号搜索失败: err=%v rows=%+v", err, r) + } + // 按订单号命中 + r, err = ListTasksView(db, repository.TaskFilter{Keyword: "XYZ"}) + if err != nil || len(r.Rows) != 1 || r.Rows[0].TaskID != "PUR-1" { + t.Fatalf("按订单号搜索失败: err=%v rows=%+v", err, r) + } + // 按 PDD 商品 ID 命中 + r, err = ListTasksView(db, repository.TaskFilter{Keyword: "737116531267"}) + if err != nil || len(r.Rows) != 1 || r.Rows[0].TaskID != "COL-ABC" { + t.Fatalf("按 PDD 商品 ID 搜索失败: err=%v rows=%+v", err, r) + } +} + +// ── 目标列 ──────────────────────────────────────────── + +func TestListTasksView_目标列_采集任务显示PDD商品ID(t *testing.T) { + db := newTestDB(t) + insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "737116531267"}) + + r, err := ListTasksView(db, repository.TaskFilter{}) + if err != nil { + t.Fatalf("查询失败: %v", err) + } + want := "PDD 737116531267" + if r.Rows[0].Target != want { + t.Errorf("目标列 = %q,want %q", r.Rows[0].Target, want) + } +} + +func TestListTasksView_目标列_采集任务能join到标题时追加显示(t *testing.T) { + db := newTestDB(t) + if _, err := db.Exec(` + INSERT INTO pdd_products (goods_id, url, title, collect_status, created_at, updated_at) + VALUES ('737116531267', 'https://mobile.yangkeduo.com/goods.html?goods_id=737116531267', + '秋冬保暖加绒卫衣', 'collected', ?, ?)`, model.NowISO(), model.NowISO()); err != nil { + t.Fatalf("插入 PDD 商品失败: %v", err) + } + insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskSucceeded, pddGoodsID: "737116531267"}) + + r, err := ListTasksView(db, repository.TaskFilter{}) + if err != nil { + t.Fatalf("查询失败: %v", err) + } + want := "PDD 737116531267 · 秋冬保暖加绒卫衣" + if r.Rows[0].Target != want { + t.Errorf("目标列 = %q,want %q", r.Rows[0].Target, want) + } +} + +func TestListTasksView_目标列_商品已软删除时退回显示商品ID(t *testing.T) { + db := newTestDB(t) + now := model.NowISO() + if _, err := db.Exec(` + INSERT INTO pdd_products (goods_id, url, title, collect_status, deleted_at, created_at, updated_at) + VALUES ('737116531267', 'https://mobile.yangkeduo.com/goods.html?goods_id=737116531267', + '秋冬保暖加绒卫衣', 'collected', ?, ?, ?)`, now, now, now); err != nil { + t.Fatalf("插入 PDD 商品失败: %v", err) + } + insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "737116531267"}) + + r, err := ListTasksView(db, repository.TaskFilter{}) + if err != nil { + t.Fatalf("查询失败: %v", err) + } + want := "PDD 737116531267" + if r.Rows[0].Target != want { + t.Errorf("商品已软删除时应退回只显示商品 ID,实际 %q", r.Rows[0].Target) + } +} + +func TestListTasksView_目标列_采购任务显示订单号规格数量价格上限(t *testing.T) { + db := newTestDB(t) + insertTestTask(t, db, testTaskParams{ + taskID: "PUR-1", taskType: model.TaskPurchase, status: model.TaskAssigned, + orderNo: "SO-001", pddOptions: `{"color":"黑色","size":"M"}`, + quantity: 2, maxPriceCent: 4200, + }) + + r, err := ListTasksView(db, repository.TaskFilter{}) + if err != nil { + t.Fatalf("查询失败: %v", err) + } + want := "SO-001 · 黑色/M · 2件 · ≤¥42.00" + if r.Rows[0].Target != want { + t.Errorf("目标列 = %q,want %q", r.Rows[0].Target, want) + } +} + +// ── 客户端列 ────────────────────────────────────────── + +func TestListTasksView_无主任务客户端列显示占位符(t *testing.T) { + db := newTestDB(t) + insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"}) + + r, err := ListTasksView(db, repository.TaskFilter{}) + if err != nil { + t.Fatalf("查询失败: %v", err) + } + if r.Rows[0].ClientText != "—" { + t.Errorf("无主任务客户端列应该显示占位符,实际 %q", r.Rows[0].ClientText) + } +} + +// ── 统计跟随筛选 ────────────────────────────────────── + +func TestListTasksView_统计跟随当前筛选(t *testing.T) { + db := newTestDB(t) + insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"}) + insertTestTask(t, db, testTaskParams{taskID: "COL-2", taskType: model.TaskCollect, status: model.TaskSucceeded, pddGoodsID: "222"}) + insertTestTask(t, db, testTaskParams{taskID: "PUR-1", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-1"}) + insertTestTask(t, db, testTaskParams{taskID: "PUR-2", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-2"}) + + all, err := ListTasksView(db, repository.TaskFilter{}) + if err != nil { + t.Fatalf("查询失败: %v", err) + } + if all.Total != 4 { + t.Fatalf("不筛选时总数应该是 4,实际 %d", all.Total) + } + + collectOnly, err := ListTasksView(db, repository.TaskFilter{Type: model.TaskCollect}) + if err != nil { + t.Fatalf("查询失败: %v", err) + } + if collectOnly.Total != 2 { + t.Fatalf("筛选采集后总数应该是 2,实际 %d", collectOnly.Total) + } + if collectOnly.Counts[model.TaskAssigned] != 0 { + t.Errorf("筛选采集后不该统计到采购任务的'待领取',实际 %d", collectOnly.Counts[model.TaskAssigned]) + } + if collectOnly.Counts[model.TaskPending] != 1 || collectOnly.Counts[model.TaskSucceeded] != 1 { + t.Errorf("筛选采集后的状态分布不对: %+v", collectOnly.Counts) + } +} + +// ── 空状态 ──────────────────────────────────────────── + +func TestListTasksView_从未建过任务时不算筛选无结果(t *testing.T) { + db := newTestDB(t) + r, err := ListTasksView(db, repository.TaskFilter{}) + if err != nil { + t.Fatalf("查询失败: %v", err) + } + if len(r.Rows) != 0 { + t.Fatalf("空库应该查出 0 条") + } + if r.IsFiltered { + t.Error("没有任何筛选条件时 IsFiltered 应该为 false,模板据此显示'还没有任务'而不是'筛选无结果'") + } +} + +func TestListTasksView_筛选无结果时IsFiltered为true(t *testing.T) { + db := newTestDB(t) + insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"}) + + r, err := ListTasksView(db, repository.TaskFilter{Type: model.TaskPurchase}) + if err != nil { + t.Fatalf("查询失败: %v", err) + } + if len(r.Rows) != 0 { + t.Fatalf("筛选采购应该查出 0 条") + } + if !r.IsFiltered { + t.Error("筛选出 0 条时 IsFiltered 应该为 true,模板据此显示'筛选无结果'而不是'还没有任务'") + } +} + +// ── 详情弹窗 ────────────────────────────────────────── + +func TestGetTaskDetail_采购任务显示专有字段(t *testing.T) { + db := newTestDB(t) + insertTestTask(t, db, testTaskParams{ + taskID: "PUR-1", taskType: model.TaskPurchase, status: model.TaskClaimed, + orderNo: "SO-001", pddOptions: `{"color":"黑色","size":"M"}`, + quantity: 2, maxPriceCent: 4200, assignedClient: "client-001", + claimedAt: model.NowISO(), + }) + + d, err := GetTaskDetail(db, "PUR-1") + if err != nil { + t.Fatalf("查询失败: %v", err) + } + if d == nil { + t.Fatal("任务应该存在") + } + if !d.IsPurchase { + t.Error("采购任务 IsPurchase 应该为 true") + } + if d.SpecText != "黑色/M" || d.QuantityText != "2件" || d.PriceLimitText != "≤¥42.00" { + t.Errorf("采购专有字段不对: spec=%q qty=%q price=%q", d.SpecText, d.QuantityText, d.PriceLimitText) + } + if d.ClientText != "client-001" { + t.Errorf("分配客户端 = %q, want client-001", d.ClientText) + } +} + +func TestGetTaskDetail_采集任务不带采购专有字段(t *testing.T) { + db := newTestDB(t) + insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"}) + + d, err := GetTaskDetail(db, "COL-1") + if err != nil { + t.Fatalf("查询失败: %v", err) + } + if d.IsPurchase { + t.Error("采集任务 IsPurchase 应该为 false,模板据此隐藏采购专有字段") + } + if d.SpecText != "" || d.QuantityText != "" || d.PriceLimitText != "" { + t.Errorf("采集任务不该填充采购专有字段: spec=%q qty=%q price=%q", d.SpecText, d.QuantityText, d.PriceLimitText) + } +} + +func TestGetTaskDetail_有错误信息时显示(t *testing.T) { + db := newTestDB(t) + insertTestTask(t, db, testTaskParams{ + taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskFailed, + pddGoodsID: "111", errorCode: "PDD_PAGE_TIMEOUT", errorMessage: "商品页加载超时", + }) + + d, err := GetTaskDetail(db, "COL-1") + if err != nil { + t.Fatalf("查询失败: %v", err) + } + if !d.HasError { + t.Fatal("应该检测到错误信息") + } + if d.ErrorCode != "PDD_PAGE_TIMEOUT" || d.ErrorMessage != "商品页加载超时" { + t.Errorf("错误信息不对: code=%q msg=%q", d.ErrorCode, d.ErrorMessage) + } +} + +func TestGetTaskDetail_没有错误信息时不显示错误段(t *testing.T) { + db := newTestDB(t) + insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"}) + + d, err := GetTaskDetail(db, "COL-1") + if err != nil { + t.Fatalf("查询失败: %v", err) + } + if d.HasError { + t.Error("没有错误码和错误说明时 HasError 应该为 false") + } +} + +func TestGetTaskDetail_任务不存在返回nil(t *testing.T) { + db := newTestDB(t) + d, err := GetTaskDetail(db, "NOT-EXIST") + if err != nil { + t.Fatalf("不该报错: %v", err) + } + if d != nil { + t.Error("不存在的任务应该返回 nil") + } +} + +// ── 删除 ────────────────────────────────────────────── + +func TestDeleteTasks_批量删除(t *testing.T) { + db := newTestDB(t) + insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"}) + insertTestTask(t, db, testTaskParams{taskID: "COL-2", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "222"}) + + n, err := DeleteTasks(db, []string{"COL-1"}) + if err != nil { + t.Fatalf("删除失败: %v", err) + } + if n != 1 { + t.Fatalf("应该删除 1 条,实际 %d", n) + } + + r, err := ListTasksView(db, repository.TaskFilter{}) + if err != nil { + t.Fatalf("查询失败: %v", err) + } + if len(r.Rows) != 1 || r.Rows[0].TaskID != "COL-2" { + t.Fatalf("剩余任务应该只有 COL-2,实际 %+v", r.Rows) + } +} + +// ── 目标列拼接的边界情况 ────────────────────────────── + +func TestSpecText_空或无法解析时返回占位符(t *testing.T) { + if got := specText(""); got != placeholder { + t.Errorf("空 options: got %q", got) + } + if got := specText("not json"); got != placeholder { + t.Errorf("无法解析: got %q", got) + } +} + +func TestQuantityText_零或负数返回占位符(t *testing.T) { + if got := quantityText(0); got != placeholder+"件" { + t.Errorf("数量为 0: got %q", got) + } + if got := quantityText(3); got != "3件" { + t.Errorf("数量为 3: got %q", got) + } +} + +func TestPriceLimitText_零或负数返回占位符(t *testing.T) { + if got := priceLimitText(0); got != "≤"+placeholder { + t.Errorf("价格为 0: got %q", got) + } + if got := priceLimitText(4200); got != "≤¥42.00" { + t.Errorf("价格为 4200 分: got %q", got) + } +} diff --git a/admin/static/css/app.css b/admin/static/css/app.css index bb4e259..2b724de 100644 --- a/admin/static/css/app.css +++ b/admin/static/css/app.css @@ -1,9 +1,9 @@ /* Admin 样式表。手写 CSS,不引入框架、不引入构建流程。 见 admin/AGENTS.md 前端约束。 - 四个模块共用同一套三段式布局的样式: + 五个模块共用同一套三段式布局的样式: .toolbar(顶部工具条)/ .table-wrap(中间表格)/ .statusbar(底部状态条)。 - 改这里会同时影响四个页面,这是有意的——不要为某个页面复制一份。 */ + 改这里会同时影响五个页面,这是有意的——不要为某个页面复制一份。 */ * { box-sizing: border-box; } @@ -206,6 +206,19 @@ button.primary { } button.primary:hover { background: #1a5fd0; } +/* 详情弹窗里折叠展开的完整结果(result_data),见任务详情弹窗 */ +.result-data { + white-space: pre-wrap; + word-break: break-all; + background: #f5f6f8; + border: 1px solid #e1e4e8; + border-radius: 4px; + padding: 10px; + max-height: 300px; + overflow-y: auto; + font-size: 12px; +} + /* 弹窗里的字段表:左边标签,右边值 */ .detail { display: grid; diff --git a/admin/templates/partials/footer.html b/admin/templates/partials/footer.html index 0ed6ff8..6a7c16f 100644 --- a/admin/templates/partials/footer.html +++ b/admin/templates/partials/footer.html @@ -1,7 +1,7 @@ {{define "footer"}} -{{/* 底部状态条:三段式布局的第三段,四个页面都有 */}} +{{/* 底部状态条:三段式布局的第三段,五个页面都有 */}}
{{.Status}}
diff --git a/admin/templates/partials/header.html b/admin/templates/partials/header.html index 50d7cb6..b9b5e1e 100644 --- a/admin/templates/partials/header.html +++ b/admin/templates/partials/header.html @@ -13,7 +13,7 @@ 蝦皮数据 PDD 商品 顺运宝数据 - 采购任务 + 采集采购 客户端列表 diff --git a/admin/templates/task/detail_modal.html b/admin/templates/task/detail_modal.html new file mode 100644 index 0000000..f25c575 --- /dev/null +++ b/admin/templates/task/detail_modal.html @@ -0,0 +1,58 @@ +{{define "task/detail_modal"}} +{{/* 双击一行时弹出的内容。 + 这是一个**片段**,不是整页——外面的弹窗壳子在 task/list.html 里。 + + `[必须]` 只读:本工单不做改派、重试、取消,弹窗里不放对应的表单和按钮, + 见 #19。采购专有字段(目标规格/数量/价格上限)在采集任务这里 + **整段隐藏**,不显示空行。 */}} +{{with .D}} + + + + + +{{end}} +{{end}} diff --git a/admin/templates/task/list.html b/admin/templates/task/list.html index 2d7375d..d2a6d45 100644 --- a/admin/templates/task/list.html +++ b/admin/templates/task/list.html @@ -1,48 +1,88 @@ {{define "task/list"}} {{template "header" .}} +{{/* 三段式布局的第一段:顶部工具条。 + 采集和采购放在同一页,最常被问的是"有没有卡住的任务", + 所以类型/状态筛选放在关键词搜索前面,见 #19。 */}}
+
+ + + + +
+
- - + + + +
-
- - -
+ + + {{/* 把当前筛选一起带过去,删除完还停在原来的筛选上 */}} + + + + +
+{{/* 第二段:表格。双击一行打开只读详情弹窗,见 static/js/app.js */}}
- - - - - - - - + + + + {{range .Rows}} - {{/* TODO(骨架): 行渲染。 - 颜色尺码显示的是 **PDD 侧**的规格(实际要买的),不是蝦皮的。 - 价格上限显示成 ¥42.00,底层存的是整数分。 - 状态不能只靠颜色区分,必须有文字。 - 建议提供「改派」操作——没有心跳,客户端挂了要靠人工改派。 */}} + + + + {{/* 类型是中文文字,不能只靠颜色区分 */}} + + + + + + {{else}} + {{/* 空状态要分情况:从没建过任务 和 筛选没结果,下一步动作完全不同 */}} - {{end}} @@ -50,5 +90,18 @@
订单号商品标题颜色尺码数量价格上限蝦皮 ID分配客户端任务编号类型目标 状态客户端 更新时间
+ + {{.TaskID}}{{.TypeText}}{{.Target}}{{.StatusText}}{{.ClientText}}{{.UpdatedAt}}
- 还没有采购任务。
- 到「顺运宝数据」勾选货运单,点「创建采购任务」。 +
+ {{if .IsFiltered}} + 当前筛选条件下没有任务。
+ 换个类型、状态或关键词再试。查看全部 + {{else}} + 还没有任务。
+ 到「PDD 商品」勾选商品点「创建采集任务」, + 或到「顺运宝数据」勾选货运单点「创建采购任务」。 + {{end}}
+

+ 双击任意一行可以查看这个任务的完整信息,包括分配客户端、领取/完成时间和错误详情。 +

+ +{{/* ── 详情弹窗的壳子 ─────────────────────────── + 里面的内容双击行时由 /tasks/detail 返回,前端只负责放进来和显示。 + 机制复用 #18 的 setupRowDetail,不新造一套。 */}} + + {{template "footer" .}} {{end}} diff --git a/docs/README.md b/docs/README.md index be9fb73..a8f2f27 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,7 +8,7 @@ | 子项目 | 是什么 | 技术栈 | 规则文件 | |---|---|---|---| | **Client** | Windows 桌面客户端,控制安卓设备在拼多多采集和下单 | Python 3.10 + PyQt5 | [client/AGENTS.md](../client/AGENTS.md) | -| **Admin** | 本地 Web 管理端,管理商品、货运单、采购任务和客户端 | Go + Gin + HTML 模板 | [admin/AGENTS.md](../admin/AGENTS.md) | +| **Admin** | 本地 Web 管理端,管理蝦皮商品、PDD 商品、货运单、采集采购和客户端 | Go + Gin + HTML 模板 | [admin/AGENTS.md](../admin/AGENTS.md) | 两者通过三个 HTTP 接口交互,契约以 [Client 侧的接口契约](client/04-admin-api-contract.md) 为准。 @@ -81,11 +81,11 @@ |---|---| | [00 上手指南](admin/00-getting-started.md) | 装 Go、跑起来、常见报错 | | [00 术语表](admin/00-glossary.md) | 货运单、upsert、SKU 映射等 | -| [01 产品需求基线](admin/01-requirements.md) | 业务链路、四个模块、状态定义 | +| [01 产品需求基线](admin/01-requirements.md) | 业务链路、五个模块、状态定义 | | [02 系统架构](admin/02-architecture.md) | 分层、目录、模板组织、前端约束 | | [03 数据模型](admin/03-data-model.md) | SQLite 表、Excel 导入规则 | | [04 Client 接口实现](admin/04-client-api.md) | 服务端怎么实现那三个接口 | -| [05 界面规范](admin/05-ui-specification.md) | 三段式布局、四个页面、弹窗 | +| [05 界面规范](admin/05-ui-specification.md) | 三段式布局、五个页面、弹窗 | | [06 质量、安全与测试](admin/06-quality-security.md) | 测试、Web 安全、发布门禁 | | [07 设备登记联调手册](admin/07-设备登记联调手册.md) | **给 Client 开发者**:怎么让新设备登记成功 | diff --git a/docs/admin/00-getting-started.md b/docs/admin/00-getting-started.md index 7aba6ec..9b0624c 100644 --- a/docs/admin/00-getting-started.md +++ b/docs/admin/00-getting-started.md @@ -92,17 +92,18 @@ go run . ## 4. 你应该看到什么 -左边(或顶部)是四个模块的导航: +左边(或顶部)是五个模块的导航: | 模块 | 干什么的 | |---|---| | 蝦皮数据 | 导入蝦皮商品报表,填 PDD 链接,发起采集 | +| PDD 商品 | 维护拼多多商品档案,发起采集,查看采回来的规格价格。这个页面不依赖蝦皮和顺运宝的任何数据,单独就能跑通"建商品 → 建采集任务 → 领走执行 → 提交结果 → 显示已采集"这条闭环,见 [05 界面规范](05-ui-specification.md) §5 | | 顺运宝数据 | 同步货运单,匹配规格,生成采购任务 | -| 采购任务 | 看任务执行到哪一步了 | +| 采集采购 | 看采集和采购任务执行到哪一步了 | | 客户端列表 | 看哪些客户端在干活 | 每个页面都是同一个结构:**上面工具条 / 中间表格 / 下面状态条**。 -四个页面长得像是故意的,见 [05 界面规范](05-ui-specification.md)。 +五个页面长得像是故意的,见 [05 界面规范](05-ui-specification.md)。 ## 5. 常见报错 diff --git a/docs/admin/01-requirements.md b/docs/admin/01-requirements.md index b68a831..77da716 100644 --- a/docs/admin/01-requirements.md +++ b/docs/admin/01-requirements.md @@ -72,7 +72,7 @@ PDD 商品页 ──────→ PDD 商品表 | 1 | 蝦皮数据 | 商品档案、PDD 链接、发起采集 | | 2 | PDD 商品 | PDD 商品档案、发起采集、查看采回来的规格价格 | | 3 | 顺运宝数据 | 货运单、规格匹配、生成采购任务 | -| 4 | 采购任务 | 执行进度跟踪 | +| 4 | 采集采购 | 采集任务和采购任务的执行进度跟踪 | | 5 | 客户端列表 | 客户端注册与状态 | 五个模块**统一使用三段式页面布局**:顶部工具条 / 中间带勾选的表格 / 底部状态条。 @@ -209,26 +209,80 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 **底部状态条:** 最近同步时间、待匹配条数。 -### 4.4 采购任务模块 +### 4.4 采集采购模块 -**顶部工具条:** 订单号搜索框、搜索按钮、删除按钮。 +`tasks` 是**一张表**,用 `task_type` 区分采集和采购。这个模块把两种任务 +放在同一个列表里显示——分开成两个页面的话,采集任务建出来了, +操作员却只能盯着 PDD 商品的 `collect_status` 猜任务本身怎么样了 +(被谁领了、领了多久、失败在哪一步),完全看不出任务这一层的信息。 + +`[必须]` 名字是「**采集采购**」,不是「任务」。它直接点出这一页装的是 +哪两类任务,比泛称「任务」更能让操作员一眼知道点进去看什么。 + +`[必须]` 路由 `/tasks` 不变,避免已有的书签和文档链接失效。 + +**顶部工具条:** + +```text +[类型▾ 全部] [状态▾ 全部] 关键词[____________] [搜索] [删除] +``` + +- **类型**:全部 / 采集 / 采购。 +- **状态**:全部 + 7 个状态,见 §6.2。 +- **关键词**:同时匹配任务编号、订单号、PDD 商品 ID。 + +`[必须]` 筛选比搜索更常用:这个页面最常被问的问题是"有没有卡住的任务", +不是"订单 SO-001 怎么样了",所以类型和状态筛选要放在最前面。 **中间表格:** +两种任务的业务字段完全不同——采购有订单号/颜色尺码/数量/价格上限, +采集只有 PDD 链接。并列显示的话采集任务行会有一半是空列, +所以改成固定列 + 一列「目标」概括业务信息: + | 列 | 说明 | |---|---| | 勾选 | 支持批量删除 | -| 订单号 | | -| 商品标题 | | -| 颜色 / 尺码 | **PDD 侧**的规格,不是蝦皮的 | -| 数量 | | -| 价格上限 | 人民币分,见 §7 | -| 蝦皮 ID | | -| 分配客户端 | 可改派 | -| 状态 | 见 §6 | -| 更新时间 | | +| 任务编号 | `task_id` | +| 类型 | 采集 / 采购,文字,不能只靠颜色 | +| **目标** | 采集:`PDD `(能 join 到未删除的商品标题时追加显示);采购:` · <颜色/尺码> · <数量>件 · ≤<价格上限>` | +| 状态 | 中文,7 个取值见 §6.2 | +| 客户端 | `assigned_client`;**无主任务显示 `—`**(#17 之后采集任务默认无主,这一列会大量为空) | +| 更新时间 | 本地时区 | -**底部状态条:** 各状态的任务条数统计。 +`[必须]` 目标列所需字段全在 `tasks` 表上(`pdd_goods_id` / `order_no` / +`pdd_options` / `quantity` / `max_price_cent`),不需要 join。 + +`[建议]` 采集任务的目标 join `pdd_products` 取标题显示更友好, +但 join 不到或商品已软删除时必须退回只显示 `pdd_goods_id`,不能空着。 + +`[必须]` 目标列的拼接逻辑在 service 层组装成一个字符串,模板只负责显示—— +散在模板里没人维护得住。 + +`[必须]` 价格上限显示成 `¥42.00`,底层是整数分。 + +**双击行打开详情弹窗:** + +`[必须]` **只读**。改派 / 重试 / 取消是后续工单的范围,本页不提供入口。 + +弹窗显示:类型、状态、分配客户端、领取时间、完成时间;执行参数 +(PDD 链接,采购任务额外显示目标规格、数量、价格上限);错误信息 +(错误码、错误说明,没有错误时不显示这一段)。 + +`[必须]` 采购专有字段(数量、价格上限、目标规格)在采集任务的弹窗里 +**整段隐藏**,不显示空行。 + +`[建议]` `result_data`(Client 提交的完整结果)默认折叠,提供展开查看; +过长时截断显示。 + +**底部状态条:** + +```text +共 42 条 · 待分配 3 · 待领取 5 · 已领取 2 · 成功 30 · 需人工 1 · 失败 1 · 已取消 0 +``` + +`[必须]` 统计要**跟随当前筛选**。筛了「采集」就只统计采集任务, +否则数字和表格对不上,操作员会以为页面出错。 ### 4.5 客户端列表模块 diff --git a/docs/admin/02-architecture.md b/docs/admin/02-architecture.md index b46edb7..0975a5d 100644 --- a/docs/admin/02-architecture.md +++ b/docs/admin/02-architecture.md @@ -55,7 +55,7 @@ admin/ ├── go.mod ├── config/ 端口、路径、超时 ├── handler/ -│ ├── web/ 四个模块的页面 +│ ├── web/ 五个模块的页面 │ │ ├── shopee.go │ │ ├── syb.go │ │ ├── task.go @@ -104,8 +104,8 @@ templates/ └── statusbar.html 底部状态条 ``` -`[必须]` 三段式布局做成 `partials/` 里的公共片段,四个模块复用。 -**不要每个页面复制一份**——改一次样式要改四个地方,必然改漏。 +`[必须]` 三段式布局做成 `partials/` 里的公共片段,五个模块复用。 +**不要每个页面复制一份**——改一次样式要改五个地方,必然改漏。 `[必须]` 模板输出走 `html/template` 的自动转义。 **禁止用 `template.HTML` 包裹用户可控的内容**(商品名、订单号都是外部来的)。 diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index 5e94306..30d5e67 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -19,7 +19,7 @@ ```text ┌────────────────────────────────────────────────────────────┐ -│ [导航] 蝦皮数据 │ PDD 商品 │ 顺运宝数据 │ 采购任务 │ 客户端 │ +│ [导航] 蝦皮数据 │ PDD 商品 │ 顺运宝数据 │ 采集采购 │ 客户端 │ ├────────────────────────────────────────────────────────────┤ │ 顶部工具条:[操作按钮…] [搜索框] [搜索] [删除] │ ├────────────────────────────────────────────────────────────┤ @@ -373,20 +373,93 @@ Go 的 map 是无序的,不靠它定顺序的话,同一个商品每次刷新 `[必须]` 创建前弹出确认框,让操作员选**分配给哪个客户端**,并确认价格上限。 价格上限默认从 `pdd_data` 带出,可改,**不允许为空**。 -## 7. 采购任务页 +## 7. 采集采购页 -工具条:`订单号 [____] [搜索] [删除]` +`tasks` 一张表用 `task_type` 区分采集和采购,本页把两种任务放在同一个 +列表里显示,靠「目标」一列概括各自不同的业务信息,见 +[01 需求](01-requirements.md) §4.4。 -表格列:☐ / 订单号 / 商品标题 / 颜色 / 尺码 / 数量 / 价格上限 / -蝦皮ID / **分配客户端** / 状态 / 更新时间 +`[必须]` 导航文字和页面标题都是「**采集采购**」,不是「任务」或「采购任务」。 -- `[必须]` 颜色尺码显示的是 **PDD 侧**的规格(实际要买的),不是蝦皮的。 +### 7.1 工具条 + +```text +[类型▾ 全部] [状态▾ 全部] 关键词[____________] [搜索] [删除] +``` + +- **类型**:全部 / 采集 / 采购。 +- **状态**:全部 + 7 个状态,见 [01 需求](01-requirements.md) §6.2。 +- **关键词**:同时匹配任务编号、订单号、PDD 商品 ID。 + +### 7.2 表格列 + +☐ / 任务编号 / 类型 / **目标** / 状态 / 客户端 / 更新时间 + +```text +☐ │ 任务编号 │ 类型 │ 目标 │ 状态 │ 客户端 │ 更新时间 +☐ │ PDD-20260807-01 │ 采集 │ PDD 737116531267 │ 已领取 │ 办公室-01 │ 15:20 +☐ │ PDD-20260807-02 │ 采购 │ SO-001 · 黑色/M · 2件 · ≤¥42.00 │ 待领取 │ — │ 15:22 +``` + +- `[必须]` **目标列**:采集显示 `PDD `(能 join 到未删除商品 + 的标题时追加显示);采购显示 ` · <颜色/尺码> · <数量>件 · ≤<价格上限>`。 + 拼接逻辑在 service 层组装成一个字符串,模板只负责显示。 - `[必须]` 价格上限显示成 `¥42.00`(分转元),底层存的是整数分。 -- `[建议]` 提供"改派"操作,把任务分给别的客户端—— - 没有心跳,客户端挂了要靠人工改派。 -- 状态含义见 [01 需求](01-requirements.md) §6.2。 +- `[必须]` **客户端列**:无主任务显示 `—`,不是空白或 ``——#17 之后 + 采集任务默认无主,这一列会大量为空。 +- `[必须]` 类型和状态都是文字,不能只靠颜色区分。 -底部状态条显示各状态的条数统计。 +### 7.3 详情弹窗(双击行打开) + +`[必须]` **只读**。没有改派 / 重试 / 取消入口,那些是后续工单的范围。 + +弹窗内容: + +```text +┌──────────────────────────────────────────────┐ +│ 任务 PDD-20260807-01 │ +├──────────────────────────────────────────────┤ +│ 类型 采集 │ +│ 状态 已领取 │ +│ 分配客户端 办公室-01 │ +│ 领取时间 2026-08-07 15:20:31 │ +│ 完成时间 — │ +├──────────────────────────────────────────────┤ +│ 执行参数 │ +│ PDD 链接 https://mobile.yangkeduo.com/... │ +│ 目标规格 黑色 / M (采购任务才有) │ +│ 数量 2 (采购任务才有) │ +│ 价格上限 ¥42.00 (采购任务才有) │ +├──────────────────────────────────────────────┤ +│ 错误 │ +│ 错误码 PDD_PAGE_TIMEOUT │ +│ 错误说明 商品页加载超时 │ +├──────────────────────────────────────────────┤ +│ [关闭] │ +└──────────────────────────────────────────────┘ +``` + +- `[必须]` 采购专有字段(数量、价格上限、目标规格)在采集任务的弹窗里 + **整段隐藏**,不显示空行。 +- 错误段没有错误码/说明时整段不显示。 +- `[建议]` `result_data`(Client 提交的完整结果)默认折叠,展开查看; + 过长时截断显示。 + +### 7.4 删除 + +`[必须]` 批量删除要二次确认,写明"将删除 N 条,不可恢复"——`tasks` 表 +没有软删除列,删了就是真删了。 + +### 7.5 底部状态条 + +```text +共 42 条 · 待分配 3 · 待领取 5 · 已领取 2 · 成功 30 · 需人工 1 · 失败 1 · 已取消 0 +``` + +`[必须]` 统计要**跟随当前筛选**:筛了「采集」就只统计采集任务, +筛了某个状态统计也随之变化,否则数字和表格对不上,操作员会以为 +页面出错。这一点跟 PDD 商品页不同——那边的统计是全局概览, +不受筛选影响;本页反过来是有意的,见 [01 需求](01-requirements.md) §4.4。 ## 8. 客户端列表页 diff --git a/docs/admin/06-quality-security.md b/docs/admin/06-quality-security.md index 4499fe6..b828dad 100644 --- a/docs/admin/06-quality-security.md +++ b/docs/admin/06-quality-security.md @@ -57,7 +57,7 @@ ### 2.3 页面测试 - `go vet ./...` 无告警; -- 所有模板能正常渲染(起服务跑一遍四个页面,断言 200); +- 所有模板能正常渲染(起服务跑一遍五个页面,断言 200); - 空数据、少量数据、大量数据三种情况; - 批量删除的二次确认存在; - 校验失败时输入不丢。 @@ -150,7 +150,7 @@ | 1 | 依赖版本已固定 | `go.mod` / `go.sum` 已提交,`go mod verify` 通过 | 开发者 | | 2 | `go vet ./...` 无告警 | | 开发者 | | 3 | `go test ./...` 全绿 | 不允许有跳过而未说明的用例 | 开发者 | -| 4 | 四个页面能正常打开 | 起服务跑一遍 | 开发者 | +| 4 | 五个页面能正常打开 | 起服务跑一遍 | 开发者 | | 5 | 样本导入条数正确 | 导入参考样本(需向项目负责人索取,放 `raw_data/`),应得 5195 商品 + 6092 SKU | 开发者 | | 6 | 数据库从上一版本迁移成功 | 拿旧 `admin.db` 副本启动新版本,人工数据不丢 | 开发者 | | 7 | 契约测试通过 | [04 §9 清单](04-client-api.md)逐条 | 开发者 |