血缘锚定法: 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(
|
||||
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="任务初始描述/交接备注",
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user