feat: 展示任务PDD店铺和客户端名称 (#158)
This commit is contained in:
+8
-1
@@ -189,8 +189,15 @@ func TestTaskPage_创建人筛选列表列和详情字段齐全(t *testing.T) {
|
||||
files := map[string][]string{
|
||||
"templates/task/list.html": {
|
||||
`{{if .ShowCreator}}`, `name="creator"`, `{{.CreatorText}}`, `>筛选</button>`,
|
||||
`<th>PDD 店铺</th>`, `title="{{.PddShopText}}"`,
|
||||
`title="{{.ClientTitle}}"`, `{{if .ShowCreator}}10{{else}}9{{end}}`,
|
||||
},
|
||||
"templates/task/detail_modal.html": {
|
||||
`<dt>创建人</dt><dd>{{.CreatorText}}</dd>`,
|
||||
`<dt>客户端名称</dt><dd>{{.ClientText}}</dd>`,
|
||||
`<dt>客户端 ID</dt><dd>{{.ClientIDText}}</dd>`,
|
||||
`<dt>PDD 店铺</dt><dd>{{.PddShopText}}</dd>`,
|
||||
},
|
||||
"templates/task/detail_modal.html": {`<dt>创建人</dt><dd>{{.CreatorText}}</dd>`},
|
||||
}
|
||||
for path, wants := range files {
|
||||
content, err := os.ReadFile(path)
|
||||
|
||||
+40
-12
@@ -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()
|
||||
|
||||
+39
-5
@@ -130,12 +130,38 @@ func isTaskWarn(s model.TaskStatus) bool {
|
||||
// `[必须]` 无主任务显示 `—`,不是空白也不是 `<nil>`——#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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
<dt>类型</dt><dd>{{.TypeText}}</dd>
|
||||
<dt>执行模式</dt><dd>{{.ExecutionModeText}}</dd>
|
||||
<dt>状态</dt><dd>{{.StatusText}}</dd>
|
||||
<dt>分配客户端</dt><dd>{{.ClientText}}</dd>
|
||||
<dt>客户端名称</dt><dd>{{.ClientText}}</dd>
|
||||
<dt>客户端 ID</dt><dd>{{.ClientIDText}}</dd>
|
||||
<dt>创建人</dt><dd>{{.CreatorText}}</dd>
|
||||
<dt>领取时间</dt><dd>{{.ClaimedAt}}</dd>
|
||||
<dt>完成时间</dt><dd>{{.FinishedAt}}</dd>
|
||||
@@ -26,6 +27,7 @@
|
||||
<dl class="detail">
|
||||
<dt>PDD 链接</dt>
|
||||
<dd><a href="{{.PddGoodsURL}}" target="_blank" rel="noopener noreferrer">{{.PddGoodsURL}}</a></dd>
|
||||
<dt>PDD 店铺</dt><dd>{{.PddShopText}}</dd>
|
||||
|
||||
{{/* 下面三项只有采购任务才有;采集任务这里整段不出现,不显示空行 */}}
|
||||
{{if .IsPurchase}}
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
<th>类型</th>
|
||||
<th>执行模式</th>
|
||||
<th>目标</th>
|
||||
<th>PDD 店铺</th>
|
||||
<th>状态</th>
|
||||
<th>客户端</th>
|
||||
{{if .ShowCreator}}<th>创建人</th>{{end}}
|
||||
@@ -84,15 +85,16 @@
|
||||
<td>{{.TypeText}}</td>
|
||||
<td>{{.ExecutionModeText}}</td>
|
||||
<td class="truncate" title="{{.Target}}">{{.Target}}</td>
|
||||
<td class="truncate" title="{{.PddShopText}}">{{.PddShopText}}</td>
|
||||
<td>{{.StatusText}}</td>
|
||||
<td>{{.ClientText}}</td>
|
||||
<td class="truncate" title="{{.ClientTitle}}">{{.ClientText}}</td>
|
||||
{{if $.ShowCreator}}<td>{{.CreatorText}}</td>{{end}}
|
||||
<td>{{.UpdatedAt}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
{{/* 空状态要分情况:从没建过任务 和 筛选没结果,下一步动作完全不同 */}}
|
||||
<tr class="empty">
|
||||
<td colspan="{{if .ShowCreator}}9{{else}}8{{end}}">
|
||||
<td colspan="{{if .ShowCreator}}10{{else}}9{{end}}">
|
||||
{{if .IsFiltered}}
|
||||
当前筛选条件下没有任务。<br>
|
||||
<small>换个类型、状态或关键词再试。<a href="/tasks">查看全部</a></small>
|
||||
|
||||
@@ -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 <pdd_goods_id>`(能 join 到未删除商品
|
||||
的标题时追加显示);采购显示 `<order_no> · <颜色/尺码> · <数量>件 · ≤<价格上限>`。
|
||||
拼接逻辑在 service 层组装成一个字符串,模板只负责显示。
|
||||
- `[必须]` 价格上限显示成 `¥42.00`(分转元),底层存的是整数分。
|
||||
- `[必须]` **客户端列**:无主任务显示 `—`,不是空白或 `<nil>`——#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 (采购任务才有) │
|
||||
|
||||
Reference in New Issue
Block a user