Files
track/track-uniapp/src/pages/scan/components/TreeCanvas.vue

258 lines
14 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>
<view style="display:flex;flex-direction:column;gap:2px;">
<text class="tc-title">🌳 流转蓝图</text>
<text class="tc-hint">双指缩放 · 单指拖拽</text>
</view>
</view>
<view class="tc-toolbar-right">
<view class="tc-zoom-btn tc-mode-btn" @tap="$emit('swipe')">📇 卡片</view>
<view class="tc-zoom-btn" @tap="zoomOut"></view>
<text class="tc-zoom-label">{{ Math.round(mvScale * 100) }}%</text>
<view class="tc-zoom-btn" @tap="zoomIn"></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">
<movable-view class="tree-movable-view" direction="all"
:x="mvX" :y="mvY" :scale="true" :scale-min="0.1" :scale-max="5"
:scale-value="mvScale" :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
@scale="onScale">
<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="node.records && node.records.length && $emit('viewRecords', 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">{{ node.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>
export default {
name: "TreeCanvas",
props: { product: { type: Object, required: true } },
emits: ["viewRecords", "back", "swipe"],
data() {
return {
mvX: 0, mvY: 0, mvScale: 1,
viewportW: 375, viewportH: 600,
};
},
mounted() {
try { const info = uni.getSystemInfoSync(); this.viewportW = info.windowWidth || 375; this.viewportH = info.windowHeight || 600; } catch {}
this.$nextTick(() => this.fitAll());
},
computed: {
// ============================================================
// 🚀 十字星并发拓扑算法
// ============================================================
treeNodes() {
if (!this.product || !this.product.task_tree) return [];
const CARD_W = 160, CARD_H = 80, GAP_X = 24, GAP_Y = 24;
const CENTER_X = 0; // 中央主干 X
// 拍平 + 深拷贝
const flatMap = {};
const allTasks = [];
const walk = (tasks) => { if (!tasks) return; tasks.forEach(t => { allTasks.push(t); flatMap[t.id] = t; walk(t.child_tasks); }); };
walk(this.product.task_tree);
// 分支编号
const branchLabelMap = {};
const traverse = (tasks, prefix) => {
if (!tasks) return;
let si = 0;
tasks.forEach(t => {
const p = t.parent_task_id ? flatMap[t.parent_task_id] : null;
const isM = !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY' || (!t.task_type && p && (p.status === 'COMPLETED' || p.status === 'CANCELED'));
if (isM) { 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); }
});
};
this.product.task_tree.forEach(t => { branchLabelMap[t.id] = '主分支'; traverse(t.child_tasks, ''); });
// 主管分类
const mains = []; const subs = []; const subByParent = {};
allTasks.forEach(t => {
const p = t.parent_task_id ? flatMap[t.parent_task_id] : null;
const isM = !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY' || (!t.task_type && p && (p.status === 'COMPLETED' || p.status === 'CANCELED'));
if (isM) { mains.push(t); }
else { subs.push(t); const pid = t.parent_task_id || ''; if (!subByParent[pid]) subByParent[pid] = []; subByParent[pid].push(t); }
});
mains.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
// 计算坐标
const nodes = [];
const childSeen = new Set();
mains.forEach((m, mi) => {
m.x = CENTER_X; m.y = mi * (CARD_H + GAP_Y) * 2;
m._isMain = true; m._blabel = '主分支';
nodes.push(m);
const children = subByParent[m.id] || [];
children.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
children.forEach((c, ci) => {
const isLeft = ci % 2 === 0;
const dist = Math.floor(ci / 2) + 1;
c.y = m.y;
c.x = isLeft ? CENTER_X - dist * (CARD_W + GAP_X) : CENTER_X + dist * (CARD_W + GAP_X);
c._isMain = false; c._blabel = branchLabelMap[c.id] || '分支';
nodes.push(c);
childSeen.add(c.id);
});
});
// 遗留分支(父任务不在 mains 中的)
subs.forEach(s => { if (!childSeen.has(s.id)) { s.x = 200; s.y = nodes.length ? nodes[nodes.length - 1].y + CARD_H + GAP_Y : 0; s._isMain = false; s._blabel = '分支'; nodes.push(s); } });
// 加上全局偏移
const padding = 40;
nodes.forEach(n => { n.x += Math.abs(CENTER_X) + CARD_W * 3; n.y += padding; });
return nodes;
},
// ============================================================
// 🚀 连线计算:中央 → 分支
// ============================================================
// 🔧 连线:卡片边缘到边缘,杜绝穿模
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; // 主干左边缘 / 右分支左边缘
result.push({
x1: startX, y1: parent.y + 40,
x2: endX, y2: parent.y + 40,
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, // 垂直居中 (border 4+4=8, -2 微调)
rot: isLeft ? 180 : 0,
dashed: isLegacy,
});
});
return result;
},
// ============================================================
canvasWidth() { const ns = this.treeNodes; if (!ns.length) return 400; return Math.max(...ns.map(n => n.x)) + 200; },
canvasHeight() { const ns = this.treeNodes; if (!ns.length) return 400; return Math.max(...ns.map(n => n.y)) + 200; },
fitScale() {
if (!this.treeNodes.length) return 1;
return Math.max(0.1, Math.min((this.viewportW - 32) / this.canvasWidth, (this.viewportH - 100) / this.canvasHeight, 1));
}
},
methods: {
onScale(e) { if (e?.detail?.scale) this.mvScale = e.detail.scale; },
zoomIn() { this.mvScale = Math.min(5, +(this.mvScale + 0.2).toFixed(1)); },
zoomOut() { this.mvScale = Math.max(0.1, +(this.mvScale - 0.2).toFixed(1)); },
fitAll() { this.mvScale = this.fitScale; this.mvX = 0; this.mvY = 0; },
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 { flex: 1; width: 100%; background-color: #f0f2f5; background-image: linear-gradient(#dde1e6 1px, transparent 1px), linear-gradient(90deg, #dde1e6 1px, transparent 1px); background-size: 24px 24px; }
.canvas-inner { position: absolute; left: 0; top: 0; }
/* 🔧 连线 */
.cn-line { position: absolute; height: 2px; }
.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; }
.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-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>