Files
track/track-uniapp/src/pages/scan/components/TreeCanvas.vue
duxingchen 5b5f4a6b0e fix(frontend): TreeCanvas 坐标+连线三重修复 — 根治箭头悬空
1. calcSubtreeHeight: Math.max(CARD_H+GAP, totalChildrenHeight)
   → 首子与父平齐,父级高度取 max 而非无脑叠加

2. placeChildren: startY = parentNode.y (水平对齐)
   → 移除初始化 GAP,累加改为 startY += subtreeH

3. lines(): 动态追踪 Y 坐标
   → y1 = isLeft? n.y+40 : parent.y+40
   → y2 = isLeft? parent.y+40 : n.y+40
   → lineStyle 的 Math.atan2 自动画出完美对角线
2026-08-11 16:42:14 +08:00

327 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="tree-canvas-fullscreen">
<view class="tc-toolbar">
<view class="tc-toolbar-left">
<view class="tc-back-btn" @tap="$emit('back')">← 返回</view>
<text class="tc-title">🌳 流转蓝图</text>
</view>
<view class="tc-toolbar-right">
<view class="tc-zoom-btn tc-mode-btn" @tap="$emit('swipe')">📇 卡片</view>
<view class="tc-zoom-btn tc-fit-btn" @tap="fitAll">⊡ 全览</view>
</view>
</view>
<view v-if="!product.task_tree || !product.task_tree.length" class="zero-task">
<text class="zero-icon">📋</text><text class="zero-text">该产品暂无流转记录</text>
</view>
<template v-else>
<movable-area class="tree-movable-area" :style="{ width: viewportW + 'px', height: areaHeight + 'px' }">
<movable-view class="tree-movable-view" direction="all"
:x="mvX" :y="mvY" :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
:inertia="true" :friction="2" :damping="40"
@change="onChange">
<view class="canvas-inner">
<!-- 🔧 连线层 -->
<view v-for="(line, i) in lines" :key="'l'+i"
class="cn-line" :style="lineStyle(line)"
:class="line.dashed ? 'cn-line-dashed' : 'cn-line-solid'" />
<!-- 🔧 连线末端箭头 -->
<view v-for="(arrow, i) in arrows" :key="'a'+i"
class="cn-arrow" :style="{ left: arrow.x + 'px', top: arrow.y + 'px', transform: 'rotate(' + arrow.rot + 'deg)' }"
:class="arrow.dashed ? 'cn-arrow-dashed' : 'cn-arrow-solid'" />
<!-- 🚀 十字星节点 -->
<view v-for="node in treeNodes" :key="node.id"
class="canvas-node" :class="[statusColor(node.status), node._isMain ? 'node-main' : 'node-sub']"
:style="{ left: node.x + 'px', top: node.y + 'px' }"
@tap="handleNodeTap(node)">
<view class="cn-header">
<text :class="node._isMain ? 'badge-main' : 'badge-sub'">{{ node._isMain ? '主分支' : node._blabel }}</text>
<text :class="['cn-badge-mini', statusColor(node.status)]">{{ statusLabel(node.status) }}</text>
</view>
<text class="cn-assignee">{{ formatUserName(node.assignee_id) || '—' }}</text>
<text v-if="!node._isMain && node.parent && !node.parent._isMain" class="cn-nested-hint">协助: {{ formatUserName(node.parent.assignee_id) || '—' }}</text>
<text class="cn-time">⏰ {{ fmtDate(node.created_at) }}{{ node.completed_at ? '→' + fmtDate(node.completed_at) : '→至今' }}</text>
<text v-if="node.records && node.records.length" class="cn-records-mini">{{ node.records.length }}条</text>
</view>
</view>
</movable-view>
</movable-area>
</template>
</view>
</template>
<script>
import { formatUserName } from "../../../utils/format";
export default {
name: "TreeCanvas",
props: { product: { type: Object, required: true } },
emits: ["viewRecords", "back", "swipe"],
data() {
return {
mvX: 0, mvY: 0,
viewportW: 375, viewportH: 600,
areaHeight: 500,
};
},
mounted() {
try { const info = uni.getSystemInfoSync(); this.viewportW = info.windowWidth || 375; this.viewportH = info.windowHeight || 600; } catch {}
this.areaHeight = Math.max(this.viewportH - 100, 400);
this.$nextTick(() => this.fitAll());
},
computed: {
// ============================================================
// 🚀 父子相对坐标延伸算法(v3 — 无重叠 Y 轴栈式布局)
// ============================================================
treeNodes() {
if (!this.product || !this.product.task_tree) return [];
const CARD_W = 160, CARD_H = 80, GAP_X = 24, VERTICAL_GAP = 28, MAIN_GAP = 40;
const CENTER_X = 0;
// 拍平 + 浅拷贝
const flatMap = {};
const allTasks = [];
const walk = (tasks) => { if (!tasks) return; tasks.forEach(t => { const copy = { ...t }; allTasks.push(copy); flatMap[copy.id] = copy; walk(t.child_tasks); }); };
walk(this.product.task_tree);
allTasks.forEach(t => {
if (t.child_tasks && t.child_tasks.length) t.child_tasks = t.child_tasks.map(c => flatMap[c.id]).filter(Boolean);
if (t.parent_task_id) t.parent = flatMap[t.parent_task_id] || null;
});
// 🚀 主干判定
const isMainFn = (t) => !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY';
// 分支编号
const branchLabelMap = {};
const traverse = (tasks, prefix) => {
if (!tasks) return;
let si = 0;
tasks.forEach(t => {
if (isMainFn(t)) { branchLabelMap[t.id] = '主分支'; traverse(t.child_tasks, prefix); }
else { si++; const n = prefix ? prefix + '.' + si : '' + si; branchLabelMap[t.id] = '分支 ' + n; traverse(t.child_tasks, n); }
});
};
const rootCopies = this.product.task_tree.map(t => flatMap[t.id]).filter(Boolean);
rootCopies.forEach(t => { branchLabelMap[t.id] = '主分支'; traverse(t.child_tasks, ''); });
// 🚀 构建真实的全局 childMap(按 parent_task_id)
const childMap = {};
allTasks.forEach(t => {
const pid = t.parent_task_id || '';
if (!childMap[pid]) childMap[pid] = [];
childMap[pid].push(t);
});
Object.values(childMap).forEach(arr => arr.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)));
// 主管分类
const mains = [];
allTasks.forEach(t => { if (isMainFn(t)) mains.push(t); });
mains.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
// 🚀 预计算每个节点的子树高度(兄弟累加取 max,首子与父平齐)
const calcSubtreeHeight = (nodeId) => {
const children = childMap[nodeId] || [];
if (children.length === 0) return CARD_H + VERTICAL_GAP;
const totalChildrenHeight = children.reduce((sum, c) => sum + calcSubtreeHeight(c.id), 0);
return Math.max(CARD_H + VERTICAL_GAP, totalChildrenHeight);
};
const nodes = [];
// 🚀 递归放置算法:首个子节点与父节点水平平齐
// 同级子节点按 index 错开 Y 轴,各占其子树高度防止重叠
const placeChildren = (parentNode, currentSide) => {
const children = childMap[parentNode.id] || [];
// 🚀 首个子节点 Y 起始:与父节点水平平齐
let startY = parentNode.y;
children.forEach((child, index) => {
const subtreeH = calcSubtreeHeight(child.id);
// 主干的第一层协助分支:均衡分发左右
let side = currentSide;
if (parentNode._isMain) {
side = index % 2 === 0 ? 'right' : 'left';
}
// 🚀 X 轴:永远基于真实父亲向外延伸
const childX = side === 'right'
? parentNode.x + CARD_W + GAP_X
: parentNode.x - CARD_W - GAP_X;
// 🚀 Y 轴:首子与父平齐,后续兄弟向下累加
const childY = startY;
const childNode = {
...child,
x: childX, y: childY,
_isMain: false, _side: side,
_blabel: branchLabelMap[child.id] || '分支',
_rootMainId: parentNode._isMain ? parentNode.id : parentNode._rootMainId,
};
nodes.push(childNode);
flatMap[childNode.id] = childNode;
// 递归放置孙子节点(沿相同方向)
placeChildren(childNode, side);
// 🚀 下一个兄弟节点跳到当前子树高度之后(calcSubtreeHeight 已含 GAP)
startY += subtreeH;
});
};
// 🚀 渲染入口:主干节点也按子树深度排布
let mainStartY = 0;
mains.forEach((m, mi) => {
const subtreeH = calcSubtreeHeight(m.id);
const mainNode = { ...m, x: CENTER_X, y: mainStartY, _isMain: true, _blabel: '主分支', _rootMainId: m.id };
nodes.push(mainNode);
flatMap[mainNode.id] = mainNode;
placeChildren(mainNode, null);
mainStartY += subtreeH + MAIN_GAP;
});
// 全局偏移(给左侧分支预留空间)
const minX = Math.min(...nodes.map(n => n.x));
const minY = Math.min(...nodes.map(n => n.y));
const padding = 40;
nodes.forEach(n => { n.x += Math.abs(minX) + CARD_W * 2 + padding; n.y += padding - Math.min(0, minY); });
return nodes;
},
// ============================================================
// 🚀 连线计算:中央 → 分支
// ============================================================
// 🔧 连线:卡片边缘到边缘,动态追踪 Y 坐标,杜绝箭头悬空
lines() {
const result = [];
const flatMap = {};
this.treeNodes.forEach(n => flatMap[n.id] = n);
this.treeNodes.forEach(n => {
if (n._isMain) return;
// 🚀 连线到真实直接父节点(树枝延伸)
const parent = n.parent_task_id ? flatMap[n.parent_task_id] : null;
if (!parent) return;
const isLeft = n.x < parent.x;
const startX = isLeft ? n.x + 160 : parent.x + 160;
const endX = isLeft ? parent.x : n.x;
// 🚀 动态计算真实的 Y 坐标对接点
const startY = isLeft ? n.y + 40 : parent.y + 40;
const endY = isLeft ? parent.y + 40 : n.y + 40;
result.push({
x1: startX, y1: startY,
x2: endX, y2: endY,
dashed: parent.status === 'COMPLETED' || parent.status === 'ARCHIVED',
});
});
return result;
},
arrows() {
const result = [];
const flatMap = {};
this.treeNodes.forEach(n => flatMap[n.id] = n);
this.treeNodes.forEach(n => {
if (n._isMain) return;
const parent = n.parent_task_id ? flatMap[n.parent_task_id] : null;
if (!parent) return;
const isLegacy = parent && (parent.status === 'COMPLETED' || parent.status === 'ARCHIVED');
const isLeft = n.x < parent.x;
result.push({
x: isLeft ? n.x + 160 : n.x - 6,
y: n.y + 40 - 2,
rot: isLeft ? 180 : 0,
dashed: isLegacy,
});
});
return result;
},
// ============================================================
canvasWidth() { const ns = this.treeNodes; if (!ns.length) return 800; return Math.max(...ns.map(n => n.x)) + 400; },
canvasHeight() { const ns = this.treeNodes; if (!ns.length) return 800; return Math.max(...ns.map(n => n.y)) + 300; },
},
methods: {
formatUserName,
// 🚀 手势结束后同步位置
onChange(e) {
if (e?.detail?.x !== undefined) this.mvX = e.detail.x;
if (e?.detail?.y !== undefined) this.mvY = e.detail.y;
},
handleNodeTap(node) {
if (node.records && node.records.length > 0) {
this.$emit('viewRecords', node);
} else {
uni.showToast({ title: '当前节点暂无流转记录', icon: 'none' });
}
},
// 🚀 全览居中:固定 100% 比例,内容整体在视口中居中
fitAll() {
const canvasW = this.canvasWidth;
const canvasH = this.canvasHeight;
const viewW = this.viewportW;
const viewH = this.areaHeight;
this.mvX = (viewW - canvasW) / 2;
this.mvY = (viewH - canvasH) / 2;
},
fmtDate(d) { if (!d) return ""; const dt = new Date(d); return (dt.getMonth() + 1) + '/' + dt.getDate(); },
statusLabel(s) { const m = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" }; return m[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"; case "CANCELED": return "s-canceled"; case "ARCHIVED": return "s-archived"; default: return "s-gray"; } },
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' }; }
}
};
</script>
<style scoped>
.tree-canvas-fullscreen { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 100; display: flex; flex-direction: column; background: #e8ecf0; }
.tc-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; padding-top: calc(10px + env(safe-area-inset-top)); background: rgba(255,255,255,0.95); backdrop-filter: blur(10px); border-bottom: 1px solid #e5e7eb; flex-shrink: 0; z-index: 999; }
.tc-toolbar-left { display: flex; align-items: center; gap: 12px; }
.tc-back-btn { font-size: 14px; font-weight: 700; color: #2563eb; padding: 6px 12px; background: #eff6ff; border-radius: 8px; }
.tc-title { font-size: 16px; font-weight: 800; color: #1f2937; }
.tc-hint { font-size: 10px; color: #9ca3af; }
.tc-toolbar-right { display: flex; align-items: center; gap: 8px; }
.tc-zoom-btn { width: 36px; height: 36px; border-radius: 10px; background: #f3f4f6; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 700; color: #374151; }
.tc-zoom-btn:active { background: #e5e7eb; }
.tc-zoom-label { font-size: 12px; font-weight: 600; color: #6b7280; min-width: 42px; text-align: center; }
.tc-mode-btn { width: auto; padding: 0 10px; font-size: 12px; background: #ede9fe; color: #7c3aed; }
.tc-fit-btn { width: auto; padding: 0 12px; font-size: 13px; background: #dbeafe; color: #2563eb; }
.zero-task { display: flex; flex-direction: column; align-items: center; padding: 60px 0; }
.zero-icon { font-size: 40px; margin-bottom: 10px; }
.zero-text { font-size: 14px; color: #9ca3af; }
.tree-movable-area { width: 100%; background-color: #f0f2f5; background-image: linear-gradient(#dde1e6 1px, transparent 1px), linear-gradient(90deg, #dde1e6 1px, transparent 1px); background-size: 24px 24px; overflow: hidden; }
.canvas-inner { position: absolute; left: 0; top: 0; width: 100%; height: 100%; }
/* 🚀 movable-view 填充整个父容器,确保空白区域也能响应拖拽/缩放手势 */
.tree-movable-view { width: 100%; height: 100%; }
/* 🔧 连线(禁止吞事件) */
.cn-line { position: absolute; height: 2px; pointer-events: none; }
.cn-line-solid { background: #9ca3af; }
.cn-line-dashed { background: repeating-linear-gradient(90deg, #fdba74 0, #fdba74 6px, transparent 6px, transparent 10px); }
/* 🔧 连线箭头(禁止吞事件) */
.cn-arrow { position: absolute; width: 0; height: 0; border-left: 6px solid #9ca3af; border-top: 4px solid transparent; border-bottom: 4px solid transparent; pointer-events: none; }
.cn-arrow-dashed { border-left-color: #fdba74; }
/* 🔧 极简卡片 160x80 */
.canvas-node { position: absolute; width: 160px; min-height: auto; padding: 10px 12px; background: #fff; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); border-left: 6px solid #3b82f6; z-index: 10; display: flex; flex-direction: column; gap: 4px; font-size: 11px; }
.canvas-node.node-sub { border-left-color: #7c3aed; }
.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; }
.canvas-node.s-canceled { border-left-color: #9ca3af; opacity: 0.4; }
.canvas-node.s-archived { border-left-color: #8b5cf6; opacity: 0.65; }
.cn-header { display: flex; align-items: center; justify-content: space-between; }
.badge-main { font-size: 9px; padding: 1px 6px; border-radius: 4px; background: #2563eb; color: #fff; font-weight: 700; }
.badge-sub { font-size: 9px; padding: 1px 6px; border-radius: 4px; background: #ede9fe; color: #7c3aed; font-weight: 700; }
.cn-badge-mini { font-size: 9px; padding: 1px 6px; border-radius: 4px; font-weight: 700; }
.cn-badge-mini.s-yellow { background: #fef3c7; color: #b45309; }
.cn-badge-mini.s-blue { background: #dbeafe; color: #1d4ed8; }
.cn-badge-mini.s-green { background: #dcfce7; color: #15803d; }
.cn-badge-mini.s-red { background: #fce4ec; color: #be123c; }
.cn-badge-mini.s-archived { background: #ede9fe; color: #7c3aed; }
.cn-assignee { font-size: 10px; color: #6b7280; }
.cn-nested-hint { font-size: 8px; color: #a78bfa; font-weight: 600; }
.cn-time { font-size: 9px; color: #9ca3af; }
.cn-records-mini { font-size: 9px; color: #2563eb; background: #eff6ff; padding: 1px 6px; border-radius: 6px; display: inline-block; width: max-content; }
</style>