TreeCanvas升级: 时间轴重力算法 — 拍平排序+智能分层+居中排布+正确取最早记录备注

This commit is contained in:
2026-08-05 17:44:01 +08:00
parent d9f4e7e66e
commit b79d521bad

View File

@ -1,30 +1,45 @@
<template>
<view class="tree-area">
<view class="tree-canvas-container">
<view v-if="!product.task_tree || !product.task_tree.length" class="zero-task">
<text class="zero-icon">📋</text>
<text class="zero-text">该产品暂无流转任务</text>
<text class="zero-text">该产品暂无流转记录</text>
</view>
<template v-else>
<view class="canvas-hint">🖐 2D 蓝图画布支持随意拖拽探索分支走向</view>
<view class="canvas-hint">🖐 2D 流转蓝图支持全视角自由拖拽探索</view>
<movable-area class="tree-movable-area">
<movable-view class="tree-movable-view" direction="all" :x="0" :y="0"
<movable-view
class="tree-movable-view"
direction="all"
:x="0" :y="0"
:scale="true" :scale-min="0.5" :scale-max="2"
:style="{ width: Math.max(canvasWidth, 1200) + 'px', height: Math.max(canvasHeight, 1200) + 'px' }">
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
>
<view class="canvas-inner">
<!-- 连线层 -->
<view v-for="line in treeLines" :key="line.key" class="canvas-line" :style="lineStyle(line)"></view>
<view v-for="node in treeNodes" :key="node.id" class="canvas-node" :class="statusColor(node.status)"
<!-- 节点卡片层 -->
<view v-for="node in treeNodes" :key="node.id"
class="canvas-node" :class="statusColor(node.status)"
:style="{ left: node.x + 'px', top: node.y + 'px' }"
@tap="node.records && node.records.length && $emit('viewRecords', node)">
<view class="cn-header">
<text class="cn-name">{{ node.task_name }}</text>
<text :class="['cn-badge', statusColor(node.status)]">{{ statusLabel(node.status) }}</text>
</view>
<text class="cn-assignee">负责人: {{ node.assignee_id || '—' }}</text>
<text v-if="node.is_rework" class="tag-rework"> 返工工序</text>
<text v-if="getTaskRemark(node)" class="cn-remark">📌 {{ getTaskRemark(node) }}</text>
<view class="cn-footer">
<text v-if="node.status==='COMPLETED' && (!node.child_tasks || !node.child_tasks.length)" class="cn-end">🏁 终止</text>
<text v-if="node.status==='ARCHIVED'" class="cn-end cn-end-wh">📦 入库</text>
<text v-if="node.records && node.records.length" class="cn-records">📋 {{ node.records.length }}记录 </text>
<text v-if="node.status==='COMPLETED' && (!node.child_tasks || !node.child_tasks.length)" class="cn-end">🏁 终止分支</text>
<text v-else-if="node.status==='ARCHIVED'" class="cn-end cn-end-wh">📦 入库</text>
<text v-if="node.records && node.records.length" class="cn-records">📋 {{ node.records.length }}记录 </text>
</view>
</view>
</view>
@ -35,67 +50,141 @@
</template>
<script>
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库" };
export default {
name: "TreeCanvas",
props: { product: { type: Object, default: null } },
props: {
product: { type: Object, required: true }
},
emits: ["viewRecords"],
computed: {
treeNodes() {
const nodes = []; const CARD_W = 200, CARD_H = 140, GAP_X = 60, GAP_Y = 60;
const layout = (tasks, depth, startX) => {
if (!tasks || !tasks.length) return startX;
const y = 20 + depth * (CARD_H + GAP_Y); let x = startX; const childXs = [];
for (const t of tasks) {
nodes.push({ ...t, x, y }); const childStartX = x;
if (t.child_tasks && t.child_tasks.length) { x = layout(t.child_tasks, depth + 1, x); } else { x += CARD_W + GAP_X; }
childXs.push({ id: t.id, cx: childStartX + CARD_W / 2, cy: y + CARD_H, children: t.child_tasks || [] });
}
nodes._lines = nodes._lines || [];
for (const cx of childXs) { if (cx.children.length) { const midX = cx.cx; for (const c of cx.children) { const childNode = nodes.find(n => n.id === c.id); if (childNode) { nodes._lines.push({ key: cx.id + '-' + c.id, x1: midX, y1: cx.cy, x2: childNode.x + CARD_W / 2, y2: childNode.y }); } } } }
return x;
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); }
};
if (this.product && this.product.task_tree) { nodes._lines = []; layout(this.product.task_tree, 0, 40); }
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 = 240, CARD_H = 140, GAP_X = 60, GAP_Y = 80;
const nodes = [];
nodes._lines = [];
let maxCards = Math.max(...levels.map(l => l.length));
const CENTER_X = (maxCards * CARD_W + (maxCards - 1) * GAP_X) / 2 + 60;
levels.forEach((lvlTasks, depth) => {
const y = 40 + depth * (CARD_H + GAP_Y);
const lvlWidth = lvlTasks.length * CARD_W + (lvlTasks.length - 1) * GAP_X;
const startX = CENTER_X - lvlWidth / 2;
lvlTasks.forEach((t, i) => {
nodes.push({ ...t, x: startX + i * (CARD_W + GAP_X), y, _depth: depth });
});
});
// 5. 智能连线生成
for (let d = 1; d < levels.length; d++) {
const curLvl = levels[d];
const prevLvl = levels[d - 1];
curLvl.forEach(curr => {
const currNode = nodes.find(n => n.id === curr.id);
let parents = [];
if (curr.parent_task_id) {
parents = nodes.filter(n => n.id === curr.parent_task_id);
}
if (parents.length === 0) {
parents = prevLvl.map(p => nodes.find(n => n.id === p.id));
}
parents.forEach(p => {
nodes._lines.push({
key: p.id + '-' + curr.id,
x1: p.x + CARD_W / 2, y1: p.y + CARD_H,
x2: currNode.x + CARD_W / 2, y2: currNode.y
});
});
});
}
return nodes;
},
treeLines() { return this.treeNodes._lines || []; },
canvasWidth() { const nodes = this.treeNodes; if (!nodes.length) return 400; return Math.max(400, Math.max(...nodes.map(n => n.x)) + 300); },
canvasHeight() { const nodes = this.treeNodes; if (!nodes.length) return 400; return Math.max(400, Math.max(...nodes.map(n => n.y)) + 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); }
},
methods: {
statusLabel(s) { return STATUS_MAP[s] || s; },
statusColor(s) { switch (s) { case "PENDING": return "s-yellow"; case "WIP": return "s-blue"; case "COMPLETED": return "s-green"; case "REJECTED": return "s-red"; default: return "s-gray"; } },
getTaskRemark(t) { if (!t) return ""; if (t.remark) return t.remark; if (t.records && t.records.length > 0) { const recs = [...t.records].reverse(); const rec = recs.find(r => r.remark && r.remark.length > 0); if (rec) return rec.remark; } return ""; },
lineStyle(line) { const dx = line.x2 - line.x1; const dy = line.y2 - line.y1; const len = Math.sqrt(dx * dx + dy * dy); const angle = Math.atan2(dy, dx) * 180 / Math.PI; return { left: line.x1 + 'px', top: line.y1 + 'px', width: len + 'px', transform: `rotate(${angle}deg)`, transformOrigin: '0 0' }; },
},
getTaskRemark(t) {
if (!t) return "";
if (t.remark) return t.remark;
if (t.records && t.records.length > 0) {
const rec = t.records.find(r => r.remark && r.remark.trim().length > 0);
if (rec) return rec.remark;
}
return "";
},
lineStyle(line) {
const dx = line.x2 - line.x1; const dy = line.y2 - line.y1;
const len = Math.sqrt(dx * dx + dy * dy);
const angle = Math.atan2(dy, dx) * 180 / Math.PI;
return { left: line.x1 + 'px', top: line.y1 + 'px', width: len + 'px', transform: `rotate(${angle}deg)`, transformOrigin: '0 0' };
},
statusLabel(s) { const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库" }; return map[s] || s; },
statusColor(s) { switch (s) { case "PENDING": return "s-yellow"; case "WIP": return "s-blue"; case "COMPLETED": return "s-green"; case "REJECTED": return "s-red"; default: return "s-gray"; } }
}
};
</script>
<style scoped>
.tree-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; margin-top: 4px; }
.canvas-hint { font-size: 11px; color: #9ca3af; text-align: center; padding: 4px 0; flex-shrink: 0; }
.tree-movable-area { flex: 1; width: 100%; background-color: #ebedf0; border-radius: 12px; overflow: hidden; box-shadow: inset 0 0 20px rgba(0,0,0,0.05); }
.tree-canvas-container { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: #ebedf0; border-radius: 12px; margin-top: 10px; box-shadow: inset 0 0 10px rgba(0,0,0,0.02); }
.zero-task { display: flex; flex-direction: column; align-items: center; padding: 40px 0; }
.zero-icon { font-size: 40px; margin-bottom: 10px; }
.zero-text { font-size: 14px; color: #9ca3af; }
.canvas-hint { font-size: 11px; color: #9ca3af; text-align: center; padding: 8px 0; background: #fff; border-bottom: 1px solid #e5e7eb; flex-shrink: 0; }
.tree-movable-area { flex: 1; width: 100%; }
.canvas-inner { position: absolute; left: 0; top: 0; }
.canvas-node { position: absolute; width: 180px; min-height: 100px; background: #ffffff; border-radius: 12px; padding: 14px; box-shadow: 0 4px 15px rgba(0,0,0,0.08); border-left: 6px solid #3b82f6; border-top: none; z-index: 10; display: flex; flex-direction: column; gap: 6px; }
.canvas-node { position: absolute; width: 240px; min-height: 120px; background: #ffffff; border-radius: 12px; padding: 16px; box-shadow: 0 6px 16px rgba(0,0,0,0.06); border-left: 8px solid #3b82f6; border-top: none; z-index: 10; display: flex; flex-direction: column; gap: 8px; }
.canvas-node.s-yellow { border-left-color: #f59e0b; }
.canvas-node.s-blue { border-left-color: #3b82f6; }
.canvas-node.s-green { border-left-color: #22c55e; }
.canvas-node.s-red { border-left-color: #ef4444; }
.cn-header { display: flex; align-items: center; justify-content: space-between; }
.cn-name { font-size: 14px; font-weight: 800; color: #1f2937; }
.cn-badge { font-size: 10px; padding: 2px 6px; border-radius: 8px; font-weight: 600; }
.cn-name { font-size: 16px; font-weight: 800; color: #1f2937; }
.cn-badge { font-size: 11px; padding: 2px 8px; border-radius: 8px; font-weight: 700; }
.cn-badge.s-yellow { background: #fef3c7; color: #b45309; }
.cn-badge.s-blue { background: #dbeafe; color: #1d4ed8; }
.cn-badge.s-green { background: #dcfce7; color: #15803d; }
.cn-assignee { font-size: 11px; color: #6b7280; }
.cn-remark { font-size: 11px; color: #a16207; background: #fefce8; padding: 4px 8px; border-radius: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cn-footer { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 6px; border-top: 1px solid #f3f4f6; }
.cn-end { font-size: 10px; font-weight: 600; color: #16a34a; }
.cn-badge.s-red { background: #fce4ec; color: #be123c; }
.cn-assignee { font-size: 13px; color: #6b7280; font-weight: 500; }
.tag-rework { font-size: 11px; color: #fff; background: #ef4444; padding: 2px 6px; border-radius: 6px; display: inline-block; width: max-content; }
.cn-remark { font-size: 12px; color: #a16207; background: #fefce8; padding: 6px 10px; border-radius: 6px; border: 1px solid #fef08a; line-height: 1.4; word-break: break-all; }
.cn-footer { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 10px; border-top: 1px dashed #e5e7eb; }
.cn-end { font-size: 11px; font-weight: 700; color: #16a34a; }
.cn-end-wh { color: #7c3aed; }
.cn-records { font-size: 11px; color: #2563eb; font-weight: 600; }
.canvas-line { position: absolute; height: 3px; background: #94a3b8; z-index: 1; border-radius: 2px; box-shadow: 0 1px 2px rgba(0,0,0,0.1); }
.zero-task { display: flex; flex-direction: column; align-items: center; padding: 24px 0; }
.zero-icon { font-size: 40px; margin-bottom: 8px; }
.zero-text { font-size: 14px; color: #9ca3af; }
.cn-records { font-size: 12px; color: #2563eb; font-weight: 700; background: #eff6ff; padding: 2px 8px; border-radius: 12px; }
.canvas-line { position: absolute; height: 3px; background: #cbd5e1; z-index: 1; transform-origin: 0 0; }
</style>