血缘锚定法: task_type字段+迁移+三大接口写入+TreeCanvas基于task_type布局

This commit is contained in:
2026-08-06 13:03:25 +08:00
parent e5e2a395e7
commit 0d323e8181
5 changed files with 78 additions and 40 deletions

View 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")

View File

@ -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="任务初始描述/交接备注",
)

View File

@ -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

View File

@ -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,

View File

@ -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;