并发协作重构: focusTasks全视野+WBS分支编码+TreeCanvas并发同层布局(串行Y+1/并行同Y)
This commit is contained in:
@ -60,79 +60,68 @@ export default {
|
|||||||
treeNodes() {
|
treeNodes() {
|
||||||
if (!this.product || !this.product.task_tree) return [];
|
if (!this.product || !this.product.task_tree) return [];
|
||||||
|
|
||||||
// 1. 拍平所有任务(无视后端的错误嵌套)
|
|
||||||
const allTasks = [];
|
|
||||||
const flatten = (tasks) => {
|
|
||||||
if (!tasks) return;
|
|
||||||
for (const t of tasks) { allTasks.push(t); flatten(t.child_tasks); }
|
|
||||||
};
|
|
||||||
flatten(this.product.task_tree);
|
|
||||||
|
|
||||||
// 2. 绝对时间轴排序(强制自上而下流转)
|
|
||||||
allTasks.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
|
||||||
|
|
||||||
// 3. 智能分层(判定谁是同行裂变,谁是上下游)
|
|
||||||
const levels = [];
|
|
||||||
let currentLevel = [];
|
|
||||||
let lastTime = null;
|
|
||||||
|
|
||||||
allTasks.forEach(t => {
|
|
||||||
const tTime = new Date(t.created_at).getTime();
|
|
||||||
if (lastTime === null) {
|
|
||||||
currentLevel.push(t); lastTime = tTime;
|
|
||||||
} else {
|
|
||||||
if (Math.abs(tTime - lastTime) < 2000 ||
|
|
||||||
(t.parent_task_id && currentLevel.some(c => c.parent_task_id === t.parent_task_id))) {
|
|
||||||
currentLevel.push(t);
|
|
||||||
} else {
|
|
||||||
levels.push(currentLevel);
|
|
||||||
currentLevel = [t]; lastTime = tTime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (currentLevel.length > 0) levels.push(currentLevel);
|
|
||||||
|
|
||||||
// 4. 计算 X, Y 坐标
|
|
||||||
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 = [];
|
||||||
nodes._lines = [];
|
nodes._lines = [];
|
||||||
|
|
||||||
let maxCards = Math.max(...levels.map(l => l.length));
|
// 按行组织:每行是一个 { y, tasks: [...] }
|
||||||
const CENTER_X = (maxCards * CARD_W + (maxCards - 1) * GAP_X) / 2 + 60;
|
const rows = [];
|
||||||
|
|
||||||
levels.forEach((lvlTasks, depth) => {
|
const layout = (tasks, parentRowIdx, parentX) => {
|
||||||
const y = 40 + depth * (CARD_H + GAP_Y);
|
if (!tasks || !tasks.length) return;
|
||||||
const lvlWidth = lvlTasks.length * CARD_W + (lvlTasks.length - 1) * GAP_X;
|
tasks.forEach(t => {
|
||||||
const startX = CENTER_X - lvlWidth / 2;
|
// 判定:parent COMPLETED → 串行 Y+1;parent WIP/PENDING → 并行同Y偏右
|
||||||
lvlTasks.forEach((t, i) => {
|
const isSerial = !t.parent_task_id || (this.taskStatusMap[t.parent_task_id] === 'COMPLETED');
|
||||||
nodes.push({ ...t, x: startX + i * (CARD_W + GAP_X), y, _depth: depth });
|
const rowIdx = isSerial ? (parentRowIdx >= 0 ? parentRowIdx + 1 : rows.length) : parentRowIdx;
|
||||||
});
|
while (rows.length <= rowIdx) rows.push({ y: 0, tasks: [] });
|
||||||
});
|
|
||||||
|
|
||||||
// 5. 智能连线生成
|
const row = rows[rowIdx];
|
||||||
for (let d = 1; d < levels.length; d++) {
|
const x = row.tasks.length === 0 ? 40 : row.tasks[row.tasks.length - 1]._xEnd + GAP_X;
|
||||||
const curLvl = levels[d];
|
const node = { ...t, x, y: 0, _rowIdx: rowIdx, _xEnd: x + CARD_W };
|
||||||
const prevLvl = levels[d - 1];
|
row.tasks.push(node);
|
||||||
curLvl.forEach(curr => {
|
nodes.push(node);
|
||||||
const currNode = nodes.find(n => n.id === curr.id);
|
|
||||||
let parents = [];
|
// 连线到父节点
|
||||||
if (curr.parent_task_id) {
|
if (t.parent_task_id) {
|
||||||
parents = nodes.filter(n => n.id === curr.parent_task_id);
|
const parent = nodes.find(n => n.id === t.parent_task_id);
|
||||||
}
|
if (parent) {
|
||||||
if (parents.length === 0) {
|
|
||||||
parents = prevLvl.map(p => nodes.find(n => n.id === p.id));
|
|
||||||
}
|
|
||||||
parents.forEach(p => {
|
|
||||||
nodes._lines.push({
|
nodes._lines.push({
|
||||||
key: p.id + '-' + curr.id,
|
key: parent.id + '-' + t.id,
|
||||||
x1: p.x + CARD_W / 2, y1: p.y + CARD_H,
|
x1: parent.x + CARD_W / 2, y1: parent.y + CARD_H,
|
||||||
x2: currNode.x + CARD_W / 2, y2: currNode.y
|
x2: node.x + CARD_W / 2, y2: node.y,
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 递归子任务
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
return nodes;
|
return nodes;
|
||||||
},
|
},
|
||||||
|
taskStatusMap() {
|
||||||
|
const map = {};
|
||||||
|
const walk = (tasks) => { if (!tasks) return; tasks.forEach(t => { map[t.id] = t.status; walk(t.child_tasks); }); };
|
||||||
|
if (this.product) walk(this.product.task_tree);
|
||||||
|
return map;
|
||||||
|
},
|
||||||
treeLines() { return this.treeNodes._lines || []; },
|
treeLines() { return this.treeNodes._lines || []; },
|
||||||
canvasWidth() { if (!this.treeNodes.length) return 400; return Math.max(800, Math.max(...this.treeNodes.map(n => n.x)) + 300); },
|
canvasWidth() { if (!this.treeNodes.length) return 400; return Math.max(800, Math.max(...this.treeNodes.map(n => n.x)) + 300); },
|
||||||
canvasHeight() { if (!this.treeNodes.length) return 400; return Math.max(800, Math.max(...this.treeNodes.map(n => n.y)) + 300); }
|
canvasHeight() { if (!this.treeNodes.length) return 400; return Math.max(800, Math.max(...this.treeNodes.map(n => n.y)) + 300); }
|
||||||
|
|||||||
@ -23,7 +23,9 @@
|
|||||||
<view v-for="t in focusTasks" :key="t.id" class="task-list-item"
|
<view v-for="t in focusTasks" :key="t.id" class="task-list-item"
|
||||||
:class="statusColor(t.status)" @tap="lockTask(t.id)">
|
:class="statusColor(t.status)" @tap="lockTask(t.id)">
|
||||||
<view class="tli-left">
|
<view class="tli-left">
|
||||||
|
<text class="tag-branch">{{ branchLabelsMap[t.id] || '' }}</text>
|
||||||
<text class="tli-name">{{ t.task_name }}</text>
|
<text class="tli-name">{{ t.task_name }}</text>
|
||||||
|
<text class="tli-assignee">→ {{ t.assignee_id || '未分配' }}</text>
|
||||||
<text v-if="getTaskRemark(t)" class="tli-remark">{{ getTaskRemark(t) }}</text>
|
<text v-if="getTaskRemark(t)" class="tli-remark">{{ getTaskRemark(t) }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="tli-right">
|
<view class="tli-right">
|
||||||
@ -44,6 +46,7 @@
|
|||||||
|
|
||||||
<view class="focus-card" :class="statusColor(lockedTask.status)">
|
<view class="focus-card" :class="statusColor(lockedTask.status)">
|
||||||
<view class="fc-header">
|
<view class="fc-header">
|
||||||
|
<text class="tag-branch">{{ branchLabelsMap[lockedTask.id] || '' }}</text>
|
||||||
<text class="fc-status">{{ statusLabel(lockedTask.status) }}</text>
|
<text class="fc-status">{{ statusLabel(lockedTask.status) }}</text>
|
||||||
<text v-if="lockedTask.is_rework" class="tag tag-rework-sm">⚠返工</text>
|
<text v-if="lockedTask.is_rework" class="tag tag-rework-sm">⚠返工</text>
|
||||||
<text v-if="lockedTask.parent_task_id && lockedTask.status === 'WIP'" class="sub-branch-end"
|
<text v-if="lockedTask.parent_task_id && lockedTask.status === 'WIP'" class="sub-branch-end"
|
||||||
@ -117,8 +120,7 @@ export default {
|
|||||||
const walk = (tasks) => {
|
const walk = (tasks) => {
|
||||||
if (!tasks) return;
|
if (!tasks) return;
|
||||||
for (const t of tasks) {
|
for (const t of tasks) {
|
||||||
const isMine = t.status === 'WIP' || t.status === 'PENDING';
|
if (t.status === 'WIP' || t.status === 'PENDING') result.push(t);
|
||||||
if (isMine && (t.assignee_id == this.currentUserId || t.assignee_id == this.currentUsername)) result.push(t);
|
|
||||||
walk(t.child_tasks);
|
walk(t.child_tasks);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -126,6 +128,21 @@ export default {
|
|||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
lockedTask() { return this.focusTasks.find(t => t.id === this.lockedTaskId) || null; },
|
lockedTask() { return this.focusTasks.find(t => t.id === this.lockedTaskId) || null; },
|
||||||
|
branchLabelsMap() {
|
||||||
|
const map = {};
|
||||||
|
const walk = (tasks, prefix, idx) => {
|
||||||
|
if (!tasks) return;
|
||||||
|
tasks.forEach((t, i) => {
|
||||||
|
const label = prefix === '' ? '主分支' : `${prefix}${i + 1}`;
|
||||||
|
map[t.id] = label;
|
||||||
|
if (t.child_tasks && t.child_tasks.length) {
|
||||||
|
walk(t.child_tasks, prefix === '' ? '' : `${label}.`, i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (this.product) walk(this.product.task_tree, '', 0);
|
||||||
|
return map;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
statusLabel(s) { return STATUS_MAP[s] || s; },
|
statusLabel(s) { return STATUS_MAP[s] || s; },
|
||||||
@ -150,7 +167,9 @@ export default {
|
|||||||
.task-list-item.s-yellow { border-left-color: #f59e0b; }
|
.task-list-item.s-yellow { border-left-color: #f59e0b; }
|
||||||
.task-list-item.s-blue { border-left-color: #3b82f6; }
|
.task-list-item.s-blue { border-left-color: #3b82f6; }
|
||||||
.tli-left { flex: 1; min-width: 0; }
|
.tli-left { flex: 1; min-width: 0; }
|
||||||
|
.tag-branch { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: #ede9fe; color: #7c3aed; font-weight: 700; display: inline-block; width: max-content; margin-bottom: 4px; }
|
||||||
.tli-name { font-size: 15px; font-weight: 700; color: #1f2937; display: block; }
|
.tli-name { font-size: 15px; font-weight: 700; color: #1f2937; display: block; }
|
||||||
|
.tli-assignee { font-size: 11px; color: #9ca3af; display: block; margin-top: 2px; }
|
||||||
.tli-remark { font-size: 12px; color: #a16207; font-weight: bold; margin-top: 4px; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.tli-remark { font-size: 12px; color: #a16207; font-weight: bold; margin-top: 4px; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.tli-right { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; flex-shrink: 0; margin-left: 8px; }
|
.tli-right { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; flex-shrink: 0; margin-left: 8px; }
|
||||||
.tli-badge { font-size: 11px; padding: 2px 10px; border-radius: 12px; font-weight: 600; }
|
.tli-badge { font-size: 11px; padding: 2px 10px; border-radius: 12px; font-weight: 600; }
|
||||||
|
|||||||
Reference in New Issue
Block a user