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

311 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="node in treeNodes" :key="node.id"
class="canvas-node" :class="[statusColor(node.status), node.status === 'ARCHIVED' ? 'node-archived' : '']"
: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.parent_task_id ? 'badge-sub' : 'badge-main'">{{ node.parent_task_id ? branchLabelMap[node.id] || '分支' : '主分支' }}</text>
<text :class="['cn-badge', statusColor(node.status), node.status === 'ARCHIVED' ? 'cn-badge-archived' : '']">{{ 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="node.status === 'ARCHIVED'" class="tag-archived">📦 已入库</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-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>
</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 (e) { /* ignore */ }
this.$nextTick(() => { this.fitAll(); });
},
computed: {
treeNodes() {
if (!this.product || !this.product.task_tree) return [];
const CARD_W = 320, CARD_H = 460, GAP_X = 80, GAP_Y = 120;
const nodes = [];
const rowMaxX = {};
let currentMaxYIdx = -1;
// 🚀 深拷贝:防止 Vue props readonly Proxy 异常导致白屏!
const cloneTree = (tasks) => {
if (!tasks) return [];
return tasks.map(t => ({
...t,
child_tasks: cloneTree(t.child_tasks),
records: t.records ? [...t.records] : []
}));
};
const workTree = cloneTree(this.product.task_tree);
const flatMap = {};
const flatten = (tasks) => {
if (!tasks) return;
tasks.forEach(t => { flatMap[t.id] = t; flatten(t.child_tasks); });
};
flatten(workTree);
const layoutNode = (t) => {
if (t._visited) return;
t._visited = true;
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);
const type = t.task_type || (parent && parent.status === 'CANCELED' ? 'RECOVERY' : 'SPAWN');
if (type === 'SPAWN') {
// 🚀 增加 parent 存在性防御
t._yIdx = parent ? parent._yIdx : 0;
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;
// 🚀 增加 parent 存在性防御
t._xIdx = parent ? parent._xIdx : 0;
}
}
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) => {
const aTrans = a.task_type === 'TRANSFER' ? -1 : 1;
const bTrans = b.task_type === 'TRANSFER' ? -1 : 1;
if (aTrans !== bTrans) return aTrans - bTrans;
return new Date(a.created_at) - new Date(b.created_at);
})
.forEach(child => layoutNode(child));
}
};
workTree.forEach(root => layoutNode(root));
return nodes;
},
branchLabelMap() {
const map = {};
if (!this.product || !this.product.task_tree) return map;
// 拍平树结构,方便随时查父节点状态
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 traverse = (tasks, prefix) => {
if (!tasks) return;
let spawnIndex = 0;
tasks.forEach((t) => {
const parent = t.parent_task_id ? flatMap[t.parent_task_id] : null;
// 🚀 终极防御判定:
// 1. 没有父节点(打底任务) -> 主分支
// 2. 明确的基因 (TRANSFER/RECOVERY) -> 主分支
// 3. 兜底:如果基因丢失,但它的父节点已经完工(COMPLETED),那它必然是接力的转交任务 -> 主分支
const isMain = !t.parent_task_id
|| t.task_type === 'TRANSFER'
|| t.task_type === 'RECOVERY'
|| (!t.task_type && parent && (parent.status === 'COMPLETED' || parent.status === 'CANCELED'));
if (isMain) {
map[t.id] = '主分支';
traverse(t.child_tasks, prefix);
} else {
spawnIndex++;
const num = prefix ? `${prefix}.${spawnIndex}` : `${spawnIndex}`;
map[t.id] = `分支 ${num}`;
traverse(t.child_tasks, num);
}
});
};
this.product.task_tree.forEach((t) => {
map[t.id] = '主分支';
traverse(t.child_tasks, '');
});
return map;
},
canvasWidth() { if (!this.treeNodes.length) return 400; return Math.max(1200, Math.max(...this.treeNodes.map(n => n.x)) + 400); },
canvasHeight() { if (!this.treeNodes.length) return 400; return Math.max(1600, Math.max(...this.treeNodes.map(n => n.y)) + 400); },
fitScale() {
if (!this.treeNodes.length) return 1;
const vw = this.viewportW || 375;
const vh = this.viewportH || 600;
const cw = this.canvasWidth;
const ch = this.canvasHeight;
const sx = (vw - 32) / cw;
const sy = (vh - 100) / ch;
return Math.max(0.1, Math.min(sx, sy, 1));
}
},
methods: {
onScale(e) { if (e && e.detail && typeof e.detail.scale === 'number') 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() {
const s = this.fitScale;
this.mvScale = s;
this.mvX = 0;
this.mvY = 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: "已入库", CANCELED: "已撤回" }; 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"; case "CANCELED": return "s-canceled"; case "ARCHIVED": return "s-archived"; default: return "s-gray"; } }
}
};
</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: 10; }
.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; }
/* 节点卡片 */
.canvas-node { position: absolute; width: 320px; min-height: 460px; background: #ffffff; border-radius: 20px; padding: 24px; box-shadow: 0 10px 30px rgba(0,0,0,0.10), 0 4px 8px rgba(0,0,0,0.06); border-left: 12px solid #3b82f6; z-index: 10; display: flex; flex-direction: column; gap: 10px; transition: opacity 0.2s; }
.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.5; filter: grayscale(0.6); }
/* 🚀 ARCHIVED 入库卡片专属样式 */
.canvas-node.s-archived { border-left-color: #8b5cf6; opacity: 0.72; }
.canvas-node.s-archived::after { content: "📦"; position: absolute; right: 20px; bottom: 20px; font-size: 60px; opacity: 0.08; pointer-events: none; }
.cn-header { display: flex; align-items: center; justify-content: space-between; }
.badge-main { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: #2563eb; color: #fff; font-weight: 700; }
.badge-sub { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: #ede9fe; color: #7c3aed; font-weight: 700; }
.cn-badge { font-size: 14px; padding: 4px 12px; 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-badge.s-red { background: #fce4ec; color: #be123c; }
.cn-badge-archived { background: #ede9fe; color: #7c3aed; }
.cn-assignee { font-size: 16px; color: #6b7280; font-weight: 500; margin-top: 10px; }
.tag-rework { font-size: 13px; color: #fff; background: #ef4444; padding: 4px 10px; border-radius: 6px; display: inline-block; width: max-content; }
.tag-archived { font-size: 14px; color: #7c3aed; background: #ede9fe; padding: 6px 12px; border-radius: 8px; display: inline-block; width: max-content; font-weight: 700; border: 2px dashed #a78bfa; }
.cn-remark { font-size: 16px; color: #a16207; background: #fefce8; padding: 16px; border-radius: 8px; border: 1px solid #fef08a; line-height: 1.5; word-break: break-all; min-height: 100px; white-space: normal; margin-top: 16px; }
.cn-footer { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 14px; border-top: 1px dashed #e5e7eb; }
.cn-end { font-size: 13px; font-weight: 700; color: #16a34a; }
.cn-end-wh { color: #7c3aed; }
.cn-records { font-size: 14px; color: #2563eb; font-weight: 700; background: #eff6ff; padding: 4px 12px; border-radius: 12px; }
</style>