diff --git a/backend/alembic/versions/a7b8c9d0e1f2_add_task_type.py b/backend/alembic/versions/a7b8c9d0e1f2_add_task_type.py new file mode 100644 index 0000000..d1fd82b --- /dev/null +++ b/backend/alembic/versions/a7b8c9d0e1f2_add_task_type.py @@ -0,0 +1,24 @@ +"""add_task_type + +Revision ID: a7b8c9d0e1f2 +Revises: f6a7b8c9d0e1 +Create Date: 2026-08-06 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "a7b8c9d0e1f2" +down_revision: Union[str, None] = "f6a7b8c9d0e1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("tasks", sa.Column("task_type", sa.String(20), nullable=True, comment="派生类型: TRANSFER/SPAWN/RECOVERY")) + # 刷老数据:parent_task_id 非空的默认为 TRANSFER + op.execute("UPDATE tasks SET task_type = 'TRANSFER' WHERE parent_task_id IS NOT NULL AND task_type IS NULL") + + +def downgrade() -> None: + op.drop_column("tasks", "task_type") diff --git a/backend/app/models/task.py b/backend/app/models/task.py index 50543dc..486a734 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -70,6 +70,9 @@ class Task(Base): is_rework: Mapped[bool] = mapped_column( Boolean, default=False, comment="是否为返工任务", ) + task_type: Mapped[str | None] = mapped_column( + String(20), nullable=True, comment="任务派生类型: TRANSFER/SPAWN/RECOVERY/null=历史数据", + ) remark: Mapped[str | None] = mapped_column( String(2000), nullable=True, comment="任务初始描述/交接备注", ) diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py index 3ea2bf8..246d85c 100644 --- a/backend/app/schemas/task.py +++ b/backend/app/schemas/task.py @@ -131,6 +131,7 @@ class TaskResponse(BaseModel): status: str notify_parent_on_complete: bool is_rework: bool = False + task_type: str | None = None remark: str | None = None reject_reason: str | None = None received_at: datetime | None = None diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py index 94f73b9..c7245f2 100644 --- a/backend/app/services/task_service.py +++ b/backend/app/services/task_service.py @@ -112,6 +112,7 @@ def _to_flat_response(task: Task) -> TaskResponse: status=task.status, notify_parent_on_complete=task.notify_parent_on_complete, is_rework=task.is_rework, + task_type=task.task_type, remark=task.remark, reject_reason=task.reject_reason, received_at=task.received_at, @@ -143,6 +144,7 @@ def _to_response(task: Task) -> TaskResponse: status=task.status, notify_parent_on_complete=task.notify_parent_on_complete, is_rework=task.is_rework, + task_type=task.task_type, remark=task.remark, reject_reason=task.reject_reason, received_at=task.received_at, @@ -335,6 +337,7 @@ async def recall_task( task_name=task.task_name, assignee_id=operator_id, status=TASK_STATUS_WIP, + task_type="RECOVERY", notify_parent_on_complete=False, is_rework=False, remark=f"撤回「{task.task_name}」后重新接手", @@ -377,6 +380,7 @@ async def spawn_subtask( task_name=data.task_name, assignee_id=data.assignee_id, status=TASK_STATUS_PENDING, + task_type="SPAWN", notify_parent_on_complete=False, is_rework=False, remark=data.remark or None, @@ -671,6 +675,7 @@ async def transfer_task( task_name=task_name, assignee_id=assignee_id, status=TASK_STATUS_PENDING, + task_type="TRANSFER", notify_parent_on_complete=False, is_rework=False, remark=request.note or None, diff --git a/track-uniapp/src/pages/scan/components/TreeCanvas.vue b/track-uniapp/src/pages/scan/components/TreeCanvas.vue index b9f0c18..aac2713 100644 --- a/track-uniapp/src/pages/scan/components/TreeCanvas.vue +++ b/track-uniapp/src/pages/scan/components/TreeCanvas.vue @@ -59,55 +59,60 @@ export default { const CARD_W = 320, CARD_H = 460, GAP_X = 80, GAP_Y = 120; const nodes = []; + const rowMaxX = {}; + let currentMaxYIdx = -1; - // 按行组织:每行是一个 { y, tasks: [...] } - const rows = []; + const flatMap = {}; + const flatten = (tasks) => { + if (!tasks) return; + tasks.forEach(t => { flatMap[t.id] = t; flatten(t.child_tasks); }); + }; + flatten(this.product.task_tree); - const layout = (tasks, parentRowIdx, parentX) => { - if (!tasks || !tasks.length) return; - tasks.forEach(t => { - // 工业拓扑:父WIP→并行(同Y);父COMPLETED→串行(换行) - // 后端保证 COMPLETED 后不可 spawn,故 COMPLETED 的子任务必为转交产物 - const parent = this.taskDataMap[t.parent_task_id]; - const isSerial = !t.parent_task_id || (parent && (parent.status === 'COMPLETED' || parent.status === 'CANCELED')); - const rowIdx = isSerial ? (parentRowIdx >= 0 ? parentRowIdx + 1 : rows.length) : parentRowIdx; - while (rows.length <= rowIdx) rows.push({ y: 0, tasks: [] }); + const layoutNode = (t) => { + if (t._visited) return; + t._visited = true; - const row = rows[rowIdx]; - const x = row.tasks.length === 0 ? 40 : row.tasks[row.tasks.length - 1]._xEnd + GAP_X; - const node = { ...t, x, y: 0, _rowIdx: rowIdx, _xEnd: x + CARD_W }; - row.tasks.push(node); - nodes.push(node); - }); + if (!t.parent_task_id) { + // 规则A:根节点 → 新行 X=0 + currentMaxYIdx++; + t._yIdx = currentMaxYIdx; + t._xIdx = 0; + } else { + const parent = flatMap[t.parent_task_id]; + if (parent && !parent._visited) layoutNode(parent); - // 递归子任务 - tasks.forEach(t => { - if (t.child_tasks && t.child_tasks.length) { - const parentNode = nodes.find(n => n.id === t.id); - const parentRow = parentNode ? parentNode._rowIdx : rows.length - 1; - layout(t.child_tasks, parentRow, parentNode ? parentNode.x : 40); + const type = t.task_type || (parent && parent.status === 'CANCELED' ? 'RECOVERY' : 'SPAWN'); + + if (type === 'SPAWN') { + t._yIdx = parent._yIdx; + t._xIdx = (rowMaxX[t._yIdx] !== undefined ? rowMaxX[t._yIdx] : 0) + 1; + } else if (type === 'TRANSFER' || type === 'MAIN') { + currentMaxYIdx++; + t._yIdx = currentMaxYIdx; + t._xIdx = 0; + } else if (type === 'RECOVERY') { + currentMaxYIdx++; + t._yIdx = currentMaxYIdx; + t._xIdx = parent._xIdx; } - }); + } + + rowMaxX[t._yIdx] = Math.max(rowMaxX[t._yIdx] || 0, t._xIdx); + t.x = 40 + t._xIdx * (CARD_W + GAP_X); + t.y = 20 + t._yIdx * (CARD_H + GAP_Y); + nodes.push(t); + + if (t.child_tasks && t.child_tasks.length) { + t.child_tasks + .sort((a, b) => (a.task_type === 'TRANSFER' ? -1 : 1)) + .forEach(child => layoutNode(child)); + } }; - layout(this.product.task_tree, -1, 40); - - // 计算 Y 坐标 - let curY = 20; - rows.forEach(row => { - row.y = curY; - row.tasks.forEach(n => { n.y = curY; }); - curY += CARD_H + GAP_Y; - }); - + this.product.task_tree.forEach(root => layoutNode(root)); return nodes; }, - taskDataMap() { - const map = {}; - const walk = (tasks) => { if (!tasks) return; tasks.forEach(t => { map[t.id] = t; walk(t.child_tasks); }); }; - if (this.product) walk(this.product.task_tree); - return map; - }, branchLabelMap() { const map = {}; if (!this.product || !this.product.task_tree) return map;