feat: 任务列表显示店铺名和执行用时 (#136)

This commit is contained in:
chengma
2026-08-11 11:17:44 +08:00
parent 6d30115c7e
commit e354dddad6
10 changed files with 163 additions and 28 deletions
+13 -1
View File
@@ -4,7 +4,7 @@
``MIGRATIONS`` 末尾增加版本,不能修改已经发布的迁移。 ``MIGRATIONS`` 末尾增加版本,不能修改已经发布的迁移。
""" """
SCHEMA_VERSION = 4 SCHEMA_VERSION = 5
MIGRATION_1 = ( MIGRATION_1 = (
@@ -152,10 +152,22 @@ MIGRATION_4 = (
""", """,
) )
MIGRATION_5 = (
"ALTER TABLE pdd_tasks ADD COLUMN shop_name TEXT",
"""
UPDATE pdd_tasks
SET shop_name = TRIM(json_extract(pdd_data, '$.shop_name'))
WHERE pdd_data IS NOT NULL AND json_valid(pdd_data)
AND json_type(pdd_data, '$.shop_name') = 'text'
AND TRIM(json_extract(pdd_data, '$.shop_name')) <> ''
""",
)
MIGRATIONS = { MIGRATIONS = {
1: MIGRATION_1, 1: MIGRATION_1,
2: MIGRATION_2, 2: MIGRATION_2,
3: MIGRATION_3, 3: MIGRATION_3,
4: MIGRATION_4, 4: MIGRATION_4,
5: MIGRATION_5,
} }
+27 -12
View File
@@ -67,12 +67,15 @@ class TaskRow:
task_type: str task_type: str
goods_id: str = "" goods_id: str = ""
title: str = "" title: str = ""
shop_name: str = ""
color: str = "" color: str = ""
size: str = "" size: str = ""
price_cents: Optional[int] = None price_cents: Optional[int] = None
price_is_starting: bool = False price_is_starting: bool = False
quantity: Optional[int] = None quantity: Optional[int] = None
status: str = "待执行" status: str = "待执行"
latest_run_status: str = ""
duration_seconds: Optional[int] = None
updated_at: str = "" updated_at: str = ""
@@ -86,11 +89,13 @@ class TaskTableModel(QAbstractTableModel):
"选择", "选择",
"任务类型", "任务类型",
"商品标题", "商品标题",
"店铺名",
"颜色", "颜色",
"尺码", "尺码",
"价格", "价格",
"数量", "数量",
"状态", "状态",
"用时",
"更新时间", "更新时间",
) )
@@ -133,9 +138,9 @@ class TaskTableModel(QAbstractTableModel):
return self._display_value(task, column) return self._display_value(task, column)
if role == Qt.TextAlignmentRole: if role == Qt.TextAlignmentRole:
if column in (0, 1, 7): if column in (0, 1, 8, 9):
return Qt.AlignCenter return Qt.AlignCenter
if column in (5, 6): if column in (6, 7):
return Qt.AlignRight | Qt.AlignVCenter return Qt.AlignRight | Qt.AlignVCenter
return Qt.AlignLeft | Qt.AlignVCenter return Qt.AlignLeft | Qt.AlignVCenter
@@ -367,19 +372,27 @@ class TaskTableModel(QAbstractTableModel):
if column == 2: if column == 2:
return task.title or "尚未获取标题" return task.title or "尚未获取标题"
if column == 3: if column == 3:
return task.color or "—" return task.shop_name or "—"
if column == 4: if column == 4:
return task.size or "—" return task.color or "—"
if column == 5: if column == 5:
return task.size or "—"
if column == 6:
if task.price_cents is None: if task.price_cents is None:
return "—" return "—"
suffix = " 起" if task.price_is_starting else "" suffix = " 起" if task.price_is_starting else ""
return f"¥{task.price_cents / 100:.2f}{suffix}" return f"¥{task.price_cents / 100:.2f}{suffix}"
if column == 6:
return "—" if task.quantity is None else str(task.quantity)
if column == 7: if column == 7:
return task.status return "—" if task.quantity is None else str(task.quantity)
if column == 8: if column == 8:
return task.status
if column == 9:
if task.latest_run_status == "running":
return "进行中"
if task.duration_seconds is None:
return "—"
return f"{max(0, task.duration_seconds)} 秒"
if column == 10:
return task.updated_at or "—" return task.updated_at or "—"
return "" return ""
@@ -567,11 +580,13 @@ class PDDTaskPage(QWidget):
self.taskTable.setColumnWidth(0, 64) self.taskTable.setColumnWidth(0, 64)
self.taskTable.setColumnWidth(1, 82) self.taskTable.setColumnWidth(1, 82)
self.taskTable.setColumnWidth(3, 108) self.taskTable.setColumnWidth(3, 108)
self.taskTable.setColumnWidth(4, 82) self.taskTable.setColumnWidth(4, 108)
self.taskTable.setColumnWidth(5, 96) self.taskTable.setColumnWidth(5, 82)
self.taskTable.setColumnWidth(6, 70) self.taskTable.setColumnWidth(6, 96)
self.taskTable.setColumnWidth(7, 116) self.taskTable.setColumnWidth(7, 70)
self.taskTable.setColumnWidth(8, 156) self.taskTable.setColumnWidth(8, 116)
self.taskTable.setColumnWidth(9, 82)
self.taskTable.setColumnWidth(10, 156)
self.taskTable.viewport().installEventFilter(self) self.taskTable.viewport().installEventFilter(self)
self.emptyStateCard = CardWidget(self) self.emptyStateCard = CardWidget(self)
+5
View File
@@ -1709,10 +1709,15 @@ def summary_to_row(summary: TaskSummary) -> TaskRow:
task_type=TASK_TYPE_TEXT[summary.task_type], task_type=TASK_TYPE_TEXT[summary.task_type],
goods_id=summary.goods_id or "", goods_id=summary.goods_id or "",
title=summary.title or "", title=summary.title or "",
shop_name=summary.shop_name or "",
color=summary.target_color or "", color=summary.target_color or "",
size=summary.target_size or "", size=summary.target_size or "",
price_cents=summary.price_cent, price_cents=summary.price_cent,
quantity=summary.quantity, quantity=summary.quantity,
status=TASK_STATUS_TEXT[summary.status], status=TASK_STATUS_TEXT[summary.status],
latest_run_status=(
summary.latest_run_status.value if summary.latest_run_status else ""
),
duration_seconds=summary.duration_seconds,
updated_at=summary.updated_at, updated_at=summary.updated_at,
) )
+3
View File
@@ -138,6 +138,9 @@ class TaskSummary:
quantity: Optional[int] quantity: Optional[int]
status: TaskStatus status: TaskStatus
updated_at: str updated_at: str
shop_name: Optional[str] = None
latest_run_status: Optional[RunStatus] = None
duration_seconds: Optional[int] = None
@dataclass(frozen=True) @dataclass(frozen=True)
+36 -6
View File
@@ -129,11 +129,20 @@ class TaskRepository:
connection = open_database(self._db_path) connection = open_database(self._db_path)
try: try:
rows = connection.execute( rows = connection.execute(
"SELECT id, remote_task_id, task_type, goods_id, title," "SELECT pdd_tasks.id, remote_task_id, task_type, goods_id, title,"
" target_color, target_size, price_cent, quantity, status, updated_at" " shop_name, target_color, target_size, price_cent, quantity,"
" status, pdd_tasks.updated_at, latest_run.run_status AS latest_run_status,"
" CASE WHEN latest_run.run_status = 'running' THEN NULL"
" WHEN latest_run.started_at IS NOT NULL AND latest_run.finished_at IS NOT NULL"
" THEN MAX(0, CAST(strftime('%s', latest_run.finished_at)"
" - strftime('%s', latest_run.started_at) AS INTEGER))"
" ELSE NULL END AS duration_seconds"
" FROM pdd_tasks" " FROM pdd_tasks"
" LEFT JOIN task_runs AS latest_run ON latest_run.id = ("
" SELECT id FROM task_runs WHERE task_id = pdd_tasks.id"
" ORDER BY attempt_no DESC LIMIT 1)"
f"{where_sql}" f"{where_sql}"
" ORDER BY updated_at DESC, id DESC" " ORDER BY pdd_tasks.updated_at DESC, pdd_tasks.id DESC"
" LIMIT ? OFFSET ?", " LIMIT ? OFFSET ?",
parameters, parameters,
).fetchall() ).fetchall()
@@ -1098,13 +1107,14 @@ class TaskRepository:
task_cursor = connection.execute( task_cursor = connection.execute(
"UPDATE pdd_tasks SET status = 'result_pending'," "UPDATE pdd_tasks SET status = 'result_pending',"
" current_step = 'purchase_order_matched_pending_report'," " current_step = 'purchase_order_matched_pending_report',"
" pdd_data = ?," " pdd_data = ?, shop_name = ?,"
" price_cent = ?, last_error_code = NULL," " price_cent = ?, last_error_code = NULL,"
" last_error_message = NULL, finished_at = ?, updated_at = ?" " last_error_message = NULL, finished_at = ?, updated_at = ?"
" WHERE id = ? AND status = 'manual_review'" " WHERE id = ? AND status = 'manual_review'"
" AND current_step = 'reconcile_purchase'", " AND current_step = 'reconcile_purchase'",
( (
result_json, result_json,
self._summary_shop_name(pdd_data),
self._purchase_result_price(pdd_data), self._purchase_result_price(pdd_data),
now, now,
now, now,
@@ -1188,11 +1198,12 @@ class TaskRepository:
) )
connection.execute( connection.execute(
"UPDATE pdd_tasks SET status = 'result_pending'," "UPDATE pdd_tasks SET status = 'result_pending',"
" current_step = 'submit_result', pdd_data = ?," " current_step = 'submit_result', pdd_data = ?, shop_name = ?,"
" price_cent = ?, finished_at = ?, updated_at = ?" " price_cent = ?, finished_at = ?, updated_at = ?"
" WHERE id = ?", " WHERE id = ?",
( (
result_json, result_json,
self._summary_shop_name(pdd_data),
self._purchase_result_price(pdd_data), self._purchase_result_price(pdd_data),
now, now,
now, now,
@@ -1373,11 +1384,13 @@ class TaskRepository:
connection.execute( connection.execute(
"UPDATE pdd_tasks SET status = 'result_pending'," "UPDATE pdd_tasks SET status = 'result_pending',"
" current_step = 'submit_result', pdd_data = ?, goods_id = ?," " current_step = 'submit_result', pdd_data = ?, goods_id = ?,"
" title = ?, price_cent = ?, finished_at = ?, updated_at = ?" " title = ?, shop_name = ?, price_cent = ?,"
" finished_at = ?, updated_at = ?"
" WHERE id = ?", " WHERE id = ?",
( (
result_json, result_json,
pdd_data.get("goods_id"), pdd_data.get("title"), pdd_data.get("goods_id"), pdd_data.get("title"),
self._summary_shop_name(pdd_data),
self._summary_price(pdd_data), now, now, task["id"], self._summary_price(pdd_data), now, now, task["id"],
), ),
) )
@@ -1834,14 +1847,31 @@ class TaskRepository:
task_type=TaskType(row["task_type"]), task_type=TaskType(row["task_type"]),
goods_id=row["goods_id"], goods_id=row["goods_id"],
title=row["title"], title=row["title"],
shop_name=row["shop_name"],
target_color=row["target_color"], target_color=row["target_color"],
target_size=row["target_size"], target_size=row["target_size"],
price_cent=row["price_cent"], price_cent=row["price_cent"],
quantity=row["quantity"], quantity=row["quantity"],
status=TaskStatus(row["status"]), status=TaskStatus(row["status"]),
latest_run_status=(
RunStatus(row["latest_run_status"])
if row["latest_run_status"] is not None
else None
),
duration_seconds=row["duration_seconds"],
updated_at=row["updated_at"], updated_at=row["updated_at"],
) )
@staticmethod
def _summary_shop_name(pdd_data: Dict[str, object]) -> Optional[str]:
"""提取列表需要的店铺名,空值不写入摘要列。"""
value = pdd_data.get("shop_name")
if not isinstance(value, str):
return None
value = value.strip()
return value or None
@staticmethod @staticmethod
def _to_detail(row: sqlite3.Row) -> TaskDetail: def _to_detail(row: sqlite3.Row) -> TaskDetail:
admin_payload = TaskRepository._load_json_object(row["admin_payload"]) admin_payload = TaskRepository._load_json_object(row["admin_payload"])
+38 -5
View File
@@ -6,7 +6,7 @@ import unittest
from pathlib import Path from pathlib import Path
from src.db import DatabaseVersionError, initialize_database, open_database from src.db import DatabaseVersionError, initialize_database, open_database
from src.db_schema import MIGRATION_1, MIGRATION_2, MIGRATION_3 from src.db_schema import MIGRATION_1, MIGRATION_2, MIGRATION_3, MIGRATION_4
EXPECTED_TABLES = { EXPECTED_TABLES = {
@@ -64,7 +64,7 @@ class DatabaseInitializationTests(unittest.TestCase):
self.assertTrue(EXPECTED_TABLES.issubset(tables)) self.assertTrue(EXPECTED_TABLES.issubset(tables))
self.assertTrue(EXPECTED_INDEXES.issubset(indexes)) self.assertTrue(EXPECTED_INDEXES.issubset(indexes))
self.assertEqual(version, 4) self.assertEqual(version, 5)
def test_v1_database_is_upgraded_without_losing_task_runs(self) -> None: def test_v1_database_is_upgraded_without_losing_task_runs(self) -> None:
connection = open_database(self.db_path) connection = open_database(self.db_path)
@@ -107,7 +107,7 @@ class DatabaseInitializationTests(unittest.TestCase):
connection.close() connection.close()
self.assertIn("result_data", columns) self.assertIn("result_data", columns)
self.assertEqual(attempt_id, "ATTEMPT-OLD") self.assertEqual(attempt_id, "ATTEMPT-OLD")
self.assertEqual(version, 4) self.assertEqual(version, 5)
def test_initialize_can_run_twice_without_losing_data(self) -> None: def test_initialize_can_run_twice_without_losing_data(self) -> None:
initialize_database(self.db_path) initialize_database(self.db_path)
@@ -170,7 +170,7 @@ class DatabaseInitializationTests(unittest.TestCase):
finally: finally:
connection.close() connection.close()
self.assertEqual(row[0], "dry_run") self.assertEqual(row[0], "dry_run")
self.assertEqual(version, 4) self.assertEqual(version, 5)
def test_v3_database_adds_soft_remove_column_without_losing_tasks(self) -> None: def test_v3_database_adds_soft_remove_column_without_losing_tasks(self) -> None:
connection = open_database(self.db_path) connection = open_database(self.db_path)
@@ -212,7 +212,40 @@ class DatabaseInitializationTests(unittest.TestCase):
connection.close() connection.close()
self.assertIn("removed_at", columns) self.assertIn("removed_at", columns)
self.assertEqual(tuple(row), ("COL-V3", None)) self.assertEqual(tuple(row), ("COL-V3", None))
self.assertEqual(version, 4) self.assertEqual(version, 5)
def test_v4_database_backfills_shop_name_from_valid_pdd_data(self) -> None:
connection = open_database(self.db_path)
try:
with connection:
for statement in MIGRATION_1 + MIGRATION_2 + MIGRATION_3 + MIGRATION_4:
connection.execute(statement)
connection.execute("PRAGMA user_version = 4")
connection.execute(
"INSERT INTO pdd_tasks"
" (remote_task_id, task_type, goods_url, status, pdd_data,"
" received_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
"COL-V4", "collect", "https://example.test/goods",
"succeeded", '{"shop_name":" 测试店铺 "}',
"2026-08-10T00:00:00Z", "2026-08-10T00:00:00Z",
"2026-08-10T00:00:00Z",
),
)
finally:
connection.close()
initialize_database(self.db_path)
connection = open_database(self.db_path)
try:
row = connection.execute(
"SELECT shop_name FROM pdd_tasks WHERE remote_task_id = 'COL-V4'"
).fetchone()
version = connection.execute("PRAGMA user_version").fetchone()[0]
finally:
connection.close()
self.assertEqual(row[0], "测试店铺")
self.assertEqual(version, 5)
def test_new_connection_uses_required_pragmas(self) -> None: def test_new_connection_uses_required_pragmas(self) -> None:
initialize_database(self.db_path) initialize_database(self.db_path)
+1 -1
View File
@@ -166,7 +166,7 @@ class TaskTableModelCheckTests(unittest.TestCase):
page.detailRequested.connect(received.append) page.detailRequested.connect(received.append)
self.assertNotIn("操作", page.taskModel.HEADERS) self.assertNotIn("操作", page.taskModel.HEADERS)
self.assertEqual(page.taskModel.columnCount(), 9) self.assertEqual(page.taskModel.columnCount(), 11)
page._on_table_activated(page.taskModel.index(0, CHECK_COLUMN)) page._on_table_activated(page.taskModel.index(0, CHECK_COLUMN))
page._on_table_activated(page.taskModel.index(0, 2)) page._on_table_activated(page.taskModel.index(0, 2))
+27
View File
@@ -258,6 +258,7 @@ class TaskRepositoryTests(unittest.TestCase):
{ {
"goods_id": "10001", "goods_id": "10001",
"title": "测试商品", "title": "测试商品",
"shop_name": "测试店铺",
"price_granularity": "color", "price_granularity": "color",
"dimensions": [], "dimensions": [],
"skus": [{"price_cent": 990}], "skus": [{"price_cent": 990}],
@@ -269,12 +270,38 @@ class TaskRepositoryTests(unittest.TestCase):
TaskStatus.RESULT_PENDING, TaskStatus.RESULT_PENDING,
) )
self.assertEqual(event.status, OutboxStatus.PENDING) self.assertEqual(event.status, OutboxStatus.PENDING)
summary = self.repository.list_tasks()[0]
self.assertEqual(summary.shop_name, "测试店铺")
self.assertEqual(summary.latest_run_status, RunStatus.SUCCEEDED)
self.repository.mark_outbox_sent(event.id) self.repository.mark_outbox_sent(event.id)
self.assertEqual( self.assertEqual(
self.repository.get_task("TASK-RUN").status, self.repository.get_task("TASK-RUN").status,
TaskStatus.SUCCEEDED, TaskStatus.SUCCEEDED,
) )
def test_list_uses_latest_run_duration_and_clamps_negative_value(self):
self.repository.add_claimed_task(self._task("TASK-DURATION"))
started = self.repository.start_collect_run("TASK-DURATION", "USB-001")
connection = open_database(self.db_path)
try:
with connection:
connection.execute(
"UPDATE task_runs SET run_status = 'failed',"
" started_at = ?, finished_at = ? WHERE attempt_id = ?",
(
"2026-08-11T08:00:10Z",
"2026-08-11T08:00:05Z",
started.attempt_id,
),
)
finally:
connection.close()
summary = self.repository.list_tasks()[0]
self.assertEqual(summary.latest_run_status, RunStatus.FAILED)
self.assertEqual(summary.duration_seconds, 0)
def test_latest_result_outbox_excludes_newer_failure_event(self): def test_latest_result_outbox_excludes_newer_failure_event(self):
self.repository.add_claimed_task(self._task("TASK-REPORT")) self.repository.add_claimed_task(self._task("TASK-REPORT"))
first = self.repository.start_collect_run("TASK-REPORT", "USB-001") first = self.repository.start_collect_run("TASK-REPORT", "USB-001")
+10 -2
View File
@@ -130,6 +130,7 @@ CREATE TABLE pdd_tasks (
goods_id TEXT, goods_id TEXT,
goods_url TEXT NOT NULL, goods_url TEXT NOT NULL,
title TEXT, title TEXT,
shop_name TEXT,
target_color TEXT, target_color TEXT,
target_size TEXT, target_size TEXT,
price_cent INTEGER CHECK (price_cent IS NULL OR price_cent >= 0), price_cent INTEGER CHECK (price_cent IS NULL OR price_cent >= 0),
@@ -175,6 +176,7 @@ CREATE INDEX idx_pdd_tasks_visible_list
- `remote_task_id`:跨 Admin、Client、日志、提交和归档使用的稳定任务编号。Mock 任务也必须分配稳定编号。 - `remote_task_id`:跨 Admin、Client、日志、提交和归档使用的稳定任务编号。Mock 任务也必须分配稳定编号。
- `execution_mode`:Admin 领取时确定的不可变执行模式;历史记录默认为 `dry_run`,Client 不得把任务自行提升为 `live`。 - `execution_mode`:Admin 领取时确定的不可变执行模式;历史记录默认为 `dry_run`,Client 不得把任务自行提升为 `live`。
- `price_cent`:列表摘要价格;采集任务通常为最低可用 SKU 价格,详细价格以 `pdd_data.skus` 为准。 - `price_cent`:列表摘要价格;采集任务通常为最低可用 SKU 价格,详细价格以 `pdd_data.skus` 为准。
- `shop_name`:列表使用的店铺名摘要;保存结果时从 `pdd_data.shop_name` 同步,避免列表查询解析完整 JSON。
- `status`:**本机执行状态**,只由本机的执行流程和 Outbox 提交响应驱动。Admin 那边把任务标成什么,本地不知道也不需要知道(见 §3.1)。 - `status`:**本机执行状态**,只由本机的执行流程和 Outbox 提交响应驱动。Admin 那边把任务标成什么,本地不知道也不需要知道(见 §3.1)。
- `current_step`:当前安全步骤,例如 `open_goods`、`collect_skus`、`select_options`、`placing_order`、`reconcile_order`。 - `current_step`:当前安全步骤,例如 `open_goods`、`collect_skus`、`select_options`、`placing_order`、`reconcile_order`。
- `admin_payload`:`claim` 时收到的原始 Admin 任务 JSON,用于审计和向前兼容。 - `admin_payload`:`claim` 时收到的原始 Admin 任务 JSON,用于审计和向前兼容。
@@ -212,15 +214,21 @@ CREATE INDEX idx_pdd_tasks_visible_list
列表默认查询: 列表默认查询:
```sql ```sql
SELECT id, remote_task_id, task_type, title, target_color, target_size, SELECT pdd_tasks.id, remote_task_id, task_type, title, shop_name,
price_cent, quantity, status, updated_at target_color, target_size, price_cent, quantity, status,
latest_run.run_status, latest_run.started_at, latest_run.finished_at,
pdd_tasks.updated_at
FROM pdd_tasks FROM pdd_tasks
LEFT JOIN task_runs AS latest_run ON /* 每个任务 attempt_no 最大的一条执行记录 */
WHERE removed_at IS NULL WHERE removed_at IS NULL
AND /* 本地搜索与筛选条件 */ AND /* 本地搜索与筛选条件 */
ORDER BY updated_at DESC, id DESC ORDER BY updated_at DESC, id DESC
LIMIT :limit OFFSET :offset; LIMIT :limit OFFSET :offset;
``` ```
列表必须在同一条 SQL 中读取最新执行记录,不能逐行查询。已结束执行的用时按
`finished_at - started_at` 取整秒,异常负数按 0 秒显示;正在执行显示“进行中”。
批量软删除必须在一个事务内先校验全部勾选任务,再统一写入 `removed_at`。 批量软删除必须在一个事务内先校验全部勾选任务,再统一写入 `removed_at`。
只允许 `succeeded`、`failed`、`cancelled`,且所有 Outbox 都已发送、所有执行记录的 只允许 `succeeded`、`failed`、`cancelled`,且所有 Outbox 都已发送、所有执行记录的
`irreversible_action_at` 都为空。任一任务不满足时整体失败,不能只隐藏其中一部分。 `irreversible_action_at` 都为空。任一任务不满足时整体失败,不能只隐藏其中一部分。
+3 -1
View File
@@ -41,7 +41,7 @@
│ PDD 任务 │ │ PDD 任务 │
│ [自动获取] [类型▼] [状态▼] [关键词...] [搜索] [刷新] [全选] [反选] [已选0条] [重新上报] [重新采集] [重新采购] [删除] │ │ [自动获取] [类型▼] [状态▼] [关键词...] [搜索] [刷新] [全选] [反选] [已选0条] [重新上报] [重新采集] [重新采购] [删除] │
├─────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────┤
│ 选择 │ 类型 │ 商品标题 │ 颜色 │ 尺码 │ 价格 │ 数量 │ 状态 │ 更新时间 │ │ 选择 │ 类型 │ 商品标题 │ 店铺名 │ 颜色 │ 尺码 │ 价格 │ 数量 │ 状态 │ 用时 │ 更新时间 │
│ │ │ │
│ 任务数据表格 │ │ 任务数据表格 │
│ │ │ │
@@ -127,11 +127,13 @@
| 选择 | 居中 | 行首复选框;占位空行不可勾选,身份使用 `remote_task_id` | | 选择 | 居中 | 行首复选框;占位空行不可勾选,身份使用 `remote_task_id` |
| 任务类型 | 居中 | “采集”或“采购”,颜色只作辅助 | | 任务类型 | 居中 | “采集”或“采购”,颜色只作辅助 |
| 商品标题 | 左对齐、可伸展 | 空值显示“尚未获取标题”,截断时提供完整工具提示 | | 商品标题 | 左对齐、可伸展 | 空值显示“尚未获取标题”,截断时提供完整工具提示 |
| 店铺名 | 左对齐 | 采集到的店铺名;无值显示 `—` |
| 颜色 | 左对齐 | 无值显示 `—` | | 颜色 | 左对齐 | 无值显示 `—` |
| 尺码 | 左对齐 | 无值显示 `—` | | 尺码 | 左对齐 | 无值显示 `—` |
| 价格 | 右对齐 | 格式为 `¥39.90`;采集摘要可显示 `¥39.90 起` | | 价格 | 右对齐 | 格式为 `¥39.90`;采集摘要可显示 `¥39.90 起` |
| 数量 | 右对齐 | 采购数量;采集显示 `—` | | 数量 | 右对齐 | 采购数量;采集显示 `—` |
| 状态 | 居中 | 中文状态文字和状态图标/标记 | | 状态 | 居中 | 中文状态文字和状态图标/标记 |
| 用时 | 居中 | 已结束显示整数秒,执行中显示“进行中”,尚未执行显示 `—` |
| 更新时间 | 左对齐 | 本地时区,精确到秒或按产品确认格式 | | 更新时间 | 左对齐 | 本地时区,精确到秒或按产品确认格式 |
### 5.2 数据行为 ### 5.2 数据行为