diff --git a/admin/main_test.go b/admin/main_test.go index 37afe24..883710b 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -189,8 +189,15 @@ func TestTaskPage_创建人筛选列表列和详情字段齐全(t *testing.T) { files := map[string][]string{ "templates/task/list.html": { `{{if .ShowCreator}}`, `name="creator"`, `{{.CreatorText}}`, `>筛选`, + `PDD 店铺`, `title="{{.PddShopText}}"`, + `title="{{.ClientTitle}}"`, `{{if .ShowCreator}}10{{else}}9{{end}}`, + }, + "templates/task/detail_modal.html": { + `
创建人
{{.CreatorText}}
`, + `
客户端名称
{{.ClientText}}
`, + `
客户端 ID
{{.ClientIDText}}
`, + `
PDD 店铺
{{.PddShopText}}
`, }, - "templates/task/detail_modal.html": {`
创建人
{{.CreatorText}}
`}, } for path, wants := range files { content, err := os.ReadFile(path) diff --git a/admin/repository/task.go b/admin/repository/task.go index c828720..ed41c12 100644 --- a/admin/repository/task.go +++ b/admin/repository/task.go @@ -176,16 +176,37 @@ func TaskVisibleToUser(q Execer, taskID, visibleUserID string) (bool, error) { return count == 1, nil } -// GetTaskCreatorUsername 返回任务创建人的用户名;历史任务或账号不存在时为空。 -func GetTaskCreatorUsername(q Execer, taskID string) (string, error) { - var username sql.NullString - if err := q.QueryRow(`SELECT u.username FROM tasks t LEFT JOIN users u ON u.user_id=t.created_by_user_id WHERE t.task_id=?`, taskID).Scan(&username); err != nil { +// TaskDisplayContext 是任务详情按当前关联数据展示的名称。 +// 名称可能被修改或对应记录被删除,因此 tasks 仍只保存稳定 ID。 +type TaskDisplayContext struct { + CreatorUsername string + ClientName string + PddShopName string +} + +// GetTaskDisplayContext 返回详情需要的当前创建人、客户端名称和 PDD 店铺。 +// PDD 商品软删除后不再作为当前商品档案展示,店铺名自然为空。 +func GetTaskDisplayContext(q Execer, taskID string) (TaskDisplayContext, error) { + var result TaskDisplayContext + var creatorUsername, clientName, pddShopName sql.NullString + err := q.QueryRow(` + SELECT u.username, c.name, p.shop_name + FROM tasks t + LEFT JOIN users u ON u.user_id = t.created_by_user_id + LEFT JOIN clients c ON c.client_id = t.assigned_client + LEFT JOIN pdd_products p + ON p.goods_id = t.pdd_goods_id AND p.deleted_at IS NULL + WHERE t.task_id = ?`, taskID).Scan(&creatorUsername, &clientName, &pddShopName) + if err != nil { if err == sql.ErrNoRows { - return "", nil + return result, nil } - return "", fmt.Errorf("读取任务创建人失败: %w", err) + return result, fmt.Errorf("读取任务展示信息失败: %w", err) } - return username.String, nil + result.CreatorUsername = creatorUsername.String + result.ClientName = clientName.String + result.PddShopName = pddShopName.String + return result, nil } // RecordClaim 记一笔"某客户端领过某任务"。 @@ -323,6 +344,7 @@ type TaskListRow struct { AssignedClient string // 空表示无主任务 CreatedByUserID string CreatedByUsername string + ClientName string OrderNo string PddGoodsID string @@ -335,7 +357,8 @@ type TaskListRow struct { // PddTitle 是 join pdd_products 拿到的标题,**可能为空**: // 没有对应商品、或商品已被软删除时都是空。 // `[必须]` service 层退回显示 PddGoodsID,不能显示空,见 #19。 - PddTitle string + PddTitle string + PddShopName string } // taskFilterClause 把三项筛选条件拼成 WHERE 子句,供 ListTasks 和 @@ -387,11 +410,13 @@ func ListTasks(q Execer, filter TaskFilter, limit, offset int) ([]TaskListRow, e sqlText := ` SELECT t.task_id, t.task_type, t.status, t.execution_mode, t.assigned_client, t.order_no, t.pdd_goods_id, t.pdd_options, t.quantity, t.max_price_cent, - t.updated_at, p.title, t.created_by_user_id, u.username + t.updated_at, p.title, p.shop_name, + t.created_by_user_id, u.username, c.name FROM tasks t LEFT JOIN pdd_products p ON p.goods_id = t.pdd_goods_id AND p.deleted_at IS NULL - LEFT JOIN users u ON u.user_id = t.created_by_user_id` + + LEFT JOIN users u ON u.user_id = t.created_by_user_id + LEFT JOIN clients c ON c.client_id = t.assigned_client` + where + ` ORDER BY t.updated_at DESC, t.task_id DESC LIMIT ? OFFSET ?` args = append(args, limit, offset) @@ -404,13 +429,14 @@ func ListTasks(q Execer, filter TaskFilter, limit, offset int) ([]TaskListRow, e list := make([]TaskListRow, 0, 16) for rows.Next() { var r TaskListRow - var assigned, orderNo, pddGoodsID, pddOptions, title, creatorID, creatorName sql.NullString + var assigned, orderNo, pddGoodsID, pddOptions, title, shopName sql.NullString + var creatorID, creatorName, clientName sql.NullString var quantity, maxPrice sql.NullInt64 if err := rows.Scan( &r.TaskID, &r.TaskType, &r.Status, &r.ExecutionMode, &assigned, &orderNo, &pddGoodsID, &pddOptions, &quantity, &maxPrice, - &r.UpdatedAt, &title, &creatorID, &creatorName, + &r.UpdatedAt, &title, &shopName, &creatorID, &creatorName, &clientName, ); err != nil { return nil, fmt.Errorf("读取任务列表失败: %w", err) } @@ -422,8 +448,10 @@ func ListTasks(q Execer, filter TaskFilter, limit, offset int) ([]TaskListRow, e r.Quantity = int(quantity.Int64) r.MaxPriceCent = maxPrice.Int64 r.PddTitle = title.String + r.PddShopName = shopName.String r.CreatedByUserID = creatorID.String r.CreatedByUsername = creatorName.String + r.ClientName = clientName.String list = append(list, r) } return list, rows.Err() diff --git a/admin/service/task.go b/admin/service/task.go index 79a2623..1f147e6 100644 --- a/admin/service/task.go +++ b/admin/service/task.go @@ -130,12 +130,38 @@ func isTaskWarn(s model.TaskStatus) bool { // `[必须]` 无主任务显示 `—`,不是空白也不是 ``——#17 之后 // 采集任务默认无主,这一列会大量为空,显示不出来会让人以为页面坏了。 func clientText(assignedClient string) string { - if assignedClient == "" { + if strings.TrimSpace(assignedClient) == "" { return placeholder } return assignedClient } +// clientDisplayName 优先使用操作员看得懂的客户端名称。 +// 名称为空或客户端记录已删除时回退稳定 ID,不能把已分配任务显示成空白。 +func clientDisplayName(clientID, clientName string) string { + if name := strings.TrimSpace(clientName); name != "" { + return name + } + return clientText(clientID) +} + +// clientDisplayTitle 给列表截断单元格提供完整名称和稳定 ID。 +func clientDisplayTitle(clientID, clientName string) string { + name := strings.TrimSpace(clientName) + id := strings.TrimSpace(clientID) + if name != "" && id != "" { + return name + "(" + id + ")" + } + return clientDisplayName(id, name) +} + +func optionalDisplayText(value string) string { + if value = strings.TrimSpace(value); value != "" { + return value + } + return placeholder +} + // ---------- 目标列 ---------- // buildTarget 把一行任务的业务字段拼成「目标」列要显示的一句话。 @@ -236,9 +262,11 @@ type TaskView struct { TypeText string ExecutionModeText string Target string + PddShopText string StatusText string IsWarn bool // 失败 / 需人工,标黄提醒 ClientText string + ClientTitle string CreatorText string UpdatedAt string } @@ -321,9 +349,11 @@ func ListTasksView(db *sql.DB, filter repository.TaskFilter, requestedPage int) TypeText: taskTypeText(r.TaskType), ExecutionModeText: taskExecutionModeText(r.TaskType, r.ExecutionMode), Target: buildTarget(r), + PddShopText: optionalDisplayText(r.PddShopName), StatusText: taskStatusText(r.Status), IsWarn: isTaskWarn(r.Status), - ClientText: clientText(r.AssignedClient), + ClientText: clientDisplayName(r.AssignedClient, r.ClientName), + ClientTitle: clientDisplayTitle(r.AssignedClient, r.ClientName), CreatorText: taskCreatorText(r.CreatedByUsername), UpdatedAt: formatLocalTime(r.UpdatedAt), }) @@ -421,11 +451,13 @@ type TaskDetailView struct { ExecutionModeText string StatusText string ClientText string + ClientIDText string CreatorText string ClaimedAt string FinishedAt string PddGoodsURL string + PddShopText string // IsPurchase 为 false 时,模板要把下面四个采购专有字段整段隐藏, // 不能显示空行——见 #19。 @@ -458,7 +490,7 @@ func GetTaskDetail(db *sql.DB, taskID string) (*TaskDetailView, error) { if err != nil || t == nil { return nil, err } - creatorUsername, err := repository.GetTaskCreatorUsername(db, taskID) + displayContext, err := repository.GetTaskDisplayContext(db, taskID) if err != nil { return nil, err } @@ -468,11 +500,13 @@ func GetTaskDetail(db *sql.DB, taskID string) (*TaskDetailView, error) { TypeText: taskTypeText(t.TaskType), ExecutionModeText: taskExecutionModeText(t.TaskType, t.ExecutionMode), StatusText: taskStatusText(t.Status), - ClientText: clientText(t.AssignedClient), - CreatorText: taskCreatorText(creatorUsername), + ClientText: clientDisplayName(t.AssignedClient, displayContext.ClientName), + ClientIDText: clientText(t.AssignedClient), + CreatorText: taskCreatorText(displayContext.CreatorUsername), ClaimedAt: formatLocalTime(t.ClaimedAt), FinishedAt: formatLocalTime(t.FinishedAt), PddGoodsURL: t.PddGoodsURL, + PddShopText: optionalDisplayText(displayContext.PddShopName), IsPurchase: t.TaskType == model.TaskPurchase, } if v.IsPurchase { diff --git a/admin/service/task_test.go b/admin/service/task_test.go index 20db2b4..94667e4 100644 --- a/admin/service/task_test.go +++ b/admin/service/task_test.go @@ -285,6 +285,76 @@ func TestListTasksView_无主任务客户端列显示占位符(t *testing.T) { } } +func TestListTasksView_显示PDD店铺和客户端名称并保留ID提示(t *testing.T) { + db := newTestDB(t) + now := model.NowISO() + if _, err := db.Exec(` + INSERT INTO pdd_products (goods_id, url, title, shop_name, collect_status, created_at, updated_at) + VALUES ('737116531267', 'https://mobile.yangkeduo.com/goods.html?goods_id=737116531267', + '测试商品', '测试旗舰店', 'collected', ?, ?)`, now, now); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(` + INSERT INTO clients (client_id, name, last_seen_at, created_at, updated_at) + VALUES ('client-001', '办公室手机', ?, ?, ?)`, now, now, now); err != nil { + t.Fatal(err) + } + insertTestTask(t, db, testTaskParams{ + taskID: "COL-SHOP", taskType: model.TaskCollect, status: model.TaskAssigned, + pddGoodsID: "737116531267", assignedClient: "client-001", + }) + + r, err := ListTasksView(db, repository.TaskFilter{}, 1) + if err != nil { + t.Fatal(err) + } + if len(r.Rows) != 1 || r.Rows[0].PddShopText != "测试旗舰店" { + t.Fatalf("列表应显示 PDD 店铺,实际 %+v", r.Rows) + } + if r.Rows[0].ClientText != "办公室手机" || + r.Rows[0].ClientTitle != "办公室手机(client-001)" { + t.Errorf("客户端应显示名称并在提示保留 ID,实际 text=%q title=%q", + r.Rows[0].ClientText, r.Rows[0].ClientTitle) + } + + if _, err := db.Exec(`UPDATE clients SET name='仓库手机' WHERE client_id='client-001'`); err != nil { + t.Fatal(err) + } + renamed, err := ListTasksView(db, repository.TaskFilter{}, 1) + if err != nil { + t.Fatal(err) + } + if renamed.Rows[0].ClientText != "仓库手机" { + t.Errorf("客户端重命名后列表应显示当前名称,实际 %q", renamed.Rows[0].ClientText) + } + + detail, err := GetTaskDetail(db, "COL-SHOP") + if err != nil { + t.Fatal(err) + } + if detail.PddShopText != "测试旗舰店" || detail.ClientText != "仓库手机" || + detail.ClientIDText != "client-001" { + t.Errorf("详情展示信息不完整:%+v", detail) + } +} + +func TestListTasksView_关联缺失时店铺占位且客户端回退ID(t *testing.T) { + db := newTestDB(t) + insertTestTask(t, db, testTaskParams{ + taskID: "COL-FALLBACK", taskType: model.TaskCollect, status: model.TaskAssigned, + pddGoodsID: "missing-product", assignedClient: "deleted-client", + }) + + r, err := ListTasksView(db, repository.TaskFilter{}, 1) + if err != nil { + t.Fatal(err) + } + if r.Rows[0].PddShopText != "—" || r.Rows[0].ClientText != "deleted-client" || + r.Rows[0].ClientTitle != "deleted-client" { + t.Errorf("关联缺失回退错误:%+v", r.Rows[0]) + } +} + // ── 统计跟随筛选 ────────────────────────────────────── func TestListTasksView_统计跟随当前筛选(t *testing.T) { diff --git a/admin/templates/task/detail_modal.html b/admin/templates/task/detail_modal.html index 6f22630..5cd58f4 100644 --- a/admin/templates/task/detail_modal.html +++ b/admin/templates/task/detail_modal.html @@ -16,7 +16,8 @@
类型
{{.TypeText}}
执行模式
{{.ExecutionModeText}}
状态
{{.StatusText}}
-
分配客户端
{{.ClientText}}
+
客户端名称
{{.ClientText}}
+
客户端 ID
{{.ClientIDText}}
创建人
{{.CreatorText}}
领取时间
{{.ClaimedAt}}
完成时间
{{.FinishedAt}}
@@ -26,6 +27,7 @@
PDD 链接
{{.PddGoodsURL}}
+
PDD 店铺
{{.PddShopText}}
{{/* 下面三项只有采购任务才有;采集任务这里整段不出现,不显示空行 */}} {{if .IsPurchase}} diff --git a/admin/templates/task/list.html b/admin/templates/task/list.html index 6006793..f35b6ae 100644 --- a/admin/templates/task/list.html +++ b/admin/templates/task/list.html @@ -66,6 +66,7 @@ 类型 执行模式 目标 + PDD 店铺 状态 客户端 {{if .ShowCreator}}创建人{{end}} @@ -84,15 +85,16 @@ {{.TypeText}} {{.ExecutionModeText}} {{.Target}} + {{.PddShopText}} {{.StatusText}} - {{.ClientText}} + {{.ClientText}} {{if $.ShowCreator}}{{.CreatorText}}{{end}} {{.UpdatedAt}} {{else}} {{/* 空状态要分情况:从没建过任务 和 筛选没结果,下一步动作完全不同 */}} - + {{if .IsFiltered}} 当前筛选条件下没有任务。
换个类型、状态或关键词再试。查看全部 diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index 7b46869..89fa1e0 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -649,20 +649,22 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD ### 7.2 表格列 -☐ / 任务编号 / 类型 / **执行模式** / **目标** / 状态 / 客户端 / 创建人(管理员) / 更新时间 +☐ / 任务编号 / 类型 / **执行模式** / **目标** / PDD 店铺 / 状态 / 客户端 / 创建人(管理员) / 更新时间 ```text -☐ │ 任务编号 │ 类型 │ 执行模式 │ 目标 │ 状态 │ 客户端 │ 更新时间 -☐ │ PDD-20260807-01 │ 采集 │ 采集 │ PDD 737116531267 │ 已领取 │ 办公室-01 │ 15:20 -☐ │ PDD-20260807-02 │ 采购 │ 真实下单(不支付)│ SO-001 · 黑色/M · 2件 · ≤¥42.00 │ 待领取 │ 办公室-02 │ 15:22 +☐ │ 任务编号 │ 类型 │ 执行模式 │ 目标 │ PDD 店铺 │ 状态 │ 客户端 │ 更新时间 +☐ │ PDD-20260807-01 │ 采集 │ 采集 │ PDD 737116531267 │ 某某店铺 │ 已领取 │ 办公室-01 │ 15:20 +☐ │ PDD-20260807-02 │ 采购 │ 真实下单(不支付)│ SO-001 · 黑色/M · 2件 · ≤¥42.00 │ 某某店铺 │ 待领取 │ 办公室-02 │ 15:22 ``` - `[必须]` **目标列**:采集显示 `PDD `(能 join 到未删除商品 的标题时追加显示);采购显示 ` · <颜色/尺码> · <数量>件 · ≤<价格上限>`。 拼接逻辑在 service 层组装成一个字符串,模板只负责显示。 - `[必须]` 价格上限显示成 `¥42.00`(分转元),底层存的是整数分。 -- `[必须]` **客户端列**:无主任务显示 `—`,不是空白或 ``——#17 之后 - 采集任务默认无主,这一列会大量为空。 +- `[必须]` **PDD 店铺列**:按任务的 `pdd_goods_id` 关联当前未删除 + `pdd_products.shop_name`;没有商品、尚未采集、店铺为空或商品已软删除时显示 `—`。 +- `[必须]` **客户端列**:优先显示当前 `clients.name`;名称为空或客户端记录不存在时 + 回退稳定客户端 ID;无主任务显示 `—`。名称和店铺过长时省略,完整内容放在 `title`。 - `[必须]` 类型和状态都是文字,不能只靠颜色区分。 - `[必须]` 管理员显示创建人列,NULL 显示“历史任务”;采购员省略该列。 @@ -679,13 +681,15 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD │ 类型 采集 │ │ 执行模式 采集 │ │ 状态 已领取 │ -│ 分配客户端 办公室-01 │ +│ 客户端名称 办公室-01 │ +│ 客户端 ID client-001 │ │ 创建人 buyer-a │ │ 领取时间 2026-08-07 15:20:31 │ │ 完成时间 — │ ├──────────────────────────────────────────────┤ │ 执行参数 │ │ PDD 链接 https://mobile.yangkeduo.com/... │ +│ PDD 店铺 某某店铺 │ │ 目标规格 黑色 / M (采购任务才有) │ │ 数量 2 (采购任务才有) │ │ 价格上限 ¥42.00 (采购任务才有) │