血缘锚定法: task_type字段+迁移+三大接口写入+TreeCanvas基于task_type布局
This commit is contained in:
24
backend/alembic/versions/a7b8c9d0e1f2_add_task_type.py
Normal file
24
backend/alembic/versions/a7b8c9d0e1f2_add_task_type.py
Normal file
@ -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")
|
||||||
@ -70,6 +70,9 @@ class Task(Base):
|
|||||||
is_rework: Mapped[bool] = mapped_column(
|
is_rework: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=False, comment="是否为返工任务",
|
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(
|
remark: Mapped[str | None] = mapped_column(
|
||||||
String(2000), nullable=True, comment="任务初始描述/交接备注",
|
String(2000), nullable=True, comment="任务初始描述/交接备注",
|
||||||
)
|
)
|
||||||
|
|||||||
@ -131,6 +131,7 @@ class TaskResponse(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
notify_parent_on_complete: bool
|
notify_parent_on_complete: bool
|
||||||
is_rework: bool = False
|
is_rework: bool = False
|
||||||
|
task_type: str | None = None
|
||||||
remark: str | None = None
|
remark: str | None = None
|
||||||
reject_reason: str | None = None
|
reject_reason: str | None = None
|
||||||
received_at: datetime | None = None
|
received_at: datetime | None = None
|
||||||
|
|||||||
@ -112,6 +112,7 @@ def _to_flat_response(task: Task) -> TaskResponse:
|
|||||||
status=task.status,
|
status=task.status,
|
||||||
notify_parent_on_complete=task.notify_parent_on_complete,
|
notify_parent_on_complete=task.notify_parent_on_complete,
|
||||||
is_rework=task.is_rework,
|
is_rework=task.is_rework,
|
||||||
|
task_type=task.task_type,
|
||||||
remark=task.remark,
|
remark=task.remark,
|
||||||
reject_reason=task.reject_reason,
|
reject_reason=task.reject_reason,
|
||||||
received_at=task.received_at,
|
received_at=task.received_at,
|
||||||
@ -143,6 +144,7 @@ def _to_response(task: Task) -> TaskResponse:
|
|||||||
status=task.status,
|
status=task.status,
|
||||||
notify_parent_on_complete=task.notify_parent_on_complete,
|
notify_parent_on_complete=task.notify_parent_on_complete,
|
||||||
is_rework=task.is_rework,
|
is_rework=task.is_rework,
|
||||||
|
task_type=task.task_type,
|
||||||
remark=task.remark,
|
remark=task.remark,
|
||||||
reject_reason=task.reject_reason,
|
reject_reason=task.reject_reason,
|
||||||
received_at=task.received_at,
|
received_at=task.received_at,
|
||||||
@ -335,6 +337,7 @@ async def recall_task(
|
|||||||
task_name=task.task_name,
|
task_name=task.task_name,
|
||||||
assignee_id=operator_id,
|
assignee_id=operator_id,
|
||||||
status=TASK_STATUS_WIP,
|
status=TASK_STATUS_WIP,
|
||||||
|
task_type="RECOVERY",
|
||||||
notify_parent_on_complete=False,
|
notify_parent_on_complete=False,
|
||||||
is_rework=False,
|
is_rework=False,
|
||||||
remark=f"撤回「{task.task_name}」后重新接手",
|
remark=f"撤回「{task.task_name}」后重新接手",
|
||||||
@ -377,6 +380,7 @@ async def spawn_subtask(
|
|||||||
task_name=data.task_name,
|
task_name=data.task_name,
|
||||||
assignee_id=data.assignee_id,
|
assignee_id=data.assignee_id,
|
||||||
status=TASK_STATUS_PENDING,
|
status=TASK_STATUS_PENDING,
|
||||||
|
task_type="SPAWN",
|
||||||
notify_parent_on_complete=False,
|
notify_parent_on_complete=False,
|
||||||
is_rework=False,
|
is_rework=False,
|
||||||
remark=data.remark or None,
|
remark=data.remark or None,
|
||||||
@ -671,6 +675,7 @@ async def transfer_task(
|
|||||||
task_name=task_name,
|
task_name=task_name,
|
||||||
assignee_id=assignee_id,
|
assignee_id=assignee_id,
|
||||||
status=TASK_STATUS_PENDING,
|
status=TASK_STATUS_PENDING,
|
||||||
|
task_type="TRANSFER",
|
||||||
notify_parent_on_complete=False,
|
notify_parent_on_complete=False,
|
||||||
is_rework=False,
|
is_rework=False,
|
||||||
remark=request.note or None,
|
remark=request.note or None,
|
||||||
|
|||||||
@ -59,55 +59,60 @@ export default {
|
|||||||
|
|
||||||
const CARD_W = 320, CARD_H = 460, GAP_X = 80, GAP_Y = 120;
|
const CARD_W = 320, CARD_H = 460, GAP_X = 80, GAP_Y = 120;
|
||||||
const nodes = [];
|
const nodes = [];
|
||||||
|
const rowMaxX = {};
|
||||||
|
let currentMaxYIdx = -1;
|
||||||
|
|
||||||
// 按行组织:每行是一个 { y, tasks: [...] }
|
const flatMap = {};
|
||||||
const rows = [];
|
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) => {
|
const layoutNode = (t) => {
|
||||||
if (!tasks || !tasks.length) return;
|
if (t._visited) return;
|
||||||
tasks.forEach(t => {
|
t._visited = true;
|
||||||
// 工业拓扑:父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 row = rows[rowIdx];
|
if (!t.parent_task_id) {
|
||||||
const x = row.tasks.length === 0 ? 40 : row.tasks[row.tasks.length - 1]._xEnd + GAP_X;
|
// 规则A:根节点 → 新行 X=0
|
||||||
const node = { ...t, x, y: 0, _rowIdx: rowIdx, _xEnd: x + CARD_W };
|
currentMaxYIdx++;
|
||||||
row.tasks.push(node);
|
t._yIdx = currentMaxYIdx;
|
||||||
nodes.push(node);
|
t._xIdx = 0;
|
||||||
});
|
} else {
|
||||||
|
const parent = flatMap[t.parent_task_id];
|
||||||
|
if (parent && !parent._visited) layoutNode(parent);
|
||||||
|
|
||||||
// 递归子任务
|
const type = t.task_type || (parent && parent.status === 'CANCELED' ? 'RECOVERY' : 'SPAWN');
|
||||||
tasks.forEach(t => {
|
|
||||||
if (t.child_tasks && t.child_tasks.length) {
|
if (type === 'SPAWN') {
|
||||||
const parentNode = nodes.find(n => n.id === t.id);
|
t._yIdx = parent._yIdx;
|
||||||
const parentRow = parentNode ? parentNode._rowIdx : rows.length - 1;
|
t._xIdx = (rowMaxX[t._yIdx] !== undefined ? rowMaxX[t._yIdx] : 0) + 1;
|
||||||
layout(t.child_tasks, parentRow, parentNode ? parentNode.x : 40);
|
} 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);
|
this.product.task_tree.forEach(root => layoutNode(root));
|
||||||
|
|
||||||
// 计算 Y 坐标
|
|
||||||
let curY = 20;
|
|
||||||
rows.forEach(row => {
|
|
||||||
row.y = curY;
|
|
||||||
row.tasks.forEach(n => { n.y = curY; });
|
|
||||||
curY += CARD_H + GAP_Y;
|
|
||||||
});
|
|
||||||
|
|
||||||
return nodes;
|
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() {
|
branchLabelMap() {
|
||||||
const map = {};
|
const map = {};
|
||||||
if (!this.product || !this.product.task_tree) return map;
|
if (!this.product || !this.product.task_tree) return map;
|
||||||
|
|||||||
Reference in New Issue
Block a user