2026-08-05 17:25:15 +08:00
|
|
|
|
<template>
|
2026-08-05 17:44:01 +08:00
|
|
|
|
<view class="tree-canvas-container">
|
2026-08-05 17:25:15 +08:00
|
|
|
|
<view v-if="!product.task_tree || !product.task_tree.length" class="zero-task">
|
|
|
|
|
|
<text class="zero-icon">📋</text>
|
2026-08-05 17:44:01 +08:00
|
|
|
|
<text class="zero-text">该产品暂无流转记录</text>
|
2026-08-05 17:25:15 +08:00
|
|
|
|
</view>
|
2026-08-05 17:44:01 +08:00
|
|
|
|
|
2026-08-05 17:25:15 +08:00
|
|
|
|
<template v-else>
|
2026-08-05 17:44:01 +08:00
|
|
|
|
<view class="canvas-hint">🖐 2D 流转蓝图:支持全视角自由拖拽探索</view>
|
|
|
|
|
|
|
2026-08-05 17:25:15 +08:00
|
|
|
|
<movable-area class="tree-movable-area">
|
2026-08-05 17:44:01 +08:00
|
|
|
|
<movable-view
|
|
|
|
|
|
class="tree-movable-view"
|
|
|
|
|
|
direction="all"
|
|
|
|
|
|
:x="0" :y="0"
|
2026-08-06 10:02:50 +08:00
|
|
|
|
:scale="true" :scale-min="0.3" :scale-max="2"
|
2026-08-05 17:44:01 +08:00
|
|
|
|
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
|
|
|
|
|
|
>
|
2026-08-05 17:25:15 +08:00
|
|
|
|
<view class="canvas-inner">
|
2026-08-05 17:44:01 +08:00
|
|
|
|
<!-- 节点卡片层 -->
|
|
|
|
|
|
<view v-for="node in treeNodes" :key="node.id"
|
|
|
|
|
|
class="canvas-node" :class="statusColor(node.status)"
|
2026-08-05 17:25:15 +08:00
|
|
|
|
:style="{ left: node.x + 'px', top: node.y + 'px' }"
|
|
|
|
|
|
@tap="node.records && node.records.length && $emit('viewRecords', node)">
|
2026-08-05 17:44:01 +08:00
|
|
|
|
|
2026-08-05 17:25:15 +08:00
|
|
|
|
<view class="cn-header">
|
|
|
|
|
|
<text class="cn-name">{{ node.task_name }}</text>
|
|
|
|
|
|
<text :class="['cn-badge', statusColor(node.status)]">{{ statusLabel(node.status) }}</text>
|
|
|
|
|
|
</view>
|
2026-08-05 17:44:01 +08:00
|
|
|
|
|
2026-08-05 17:25:15 +08:00
|
|
|
|
<text class="cn-assignee">负责人: {{ node.assignee_id || '—' }}</text>
|
2026-08-05 17:44:01 +08:00
|
|
|
|
<text v-if="node.is_rework" class="tag-rework">⚠ 返工工序</text>
|
|
|
|
|
|
|
2026-08-05 17:25:15 +08:00
|
|
|
|
<text v-if="getTaskRemark(node)" class="cn-remark">📌 {{ getTaskRemark(node) }}</text>
|
2026-08-05 17:44:01 +08:00
|
|
|
|
|
2026-08-05 17:25:15 +08:00
|
|
|
|
<view class="cn-footer">
|
2026-08-05 17:44:01 +08:00
|
|
|
|
<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>
|
2026-08-05 17:25:15 +08:00
|
|
|
|
</view>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</movable-view>
|
|
|
|
|
|
</movable-area>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
|
export default {
|
|
|
|
|
|
name: "TreeCanvas",
|
2026-08-05 17:44:01 +08:00
|
|
|
|
props: {
|
|
|
|
|
|
product: { type: Object, required: true }
|
|
|
|
|
|
},
|
2026-08-05 17:25:15 +08:00
|
|
|
|
emits: ["viewRecords"],
|
|
|
|
|
|
computed: {
|
|
|
|
|
|
treeNodes() {
|
2026-08-05 17:44:01 +08:00
|
|
|
|
if (!this.product || !this.product.task_tree) return [];
|
|
|
|
|
|
|
2026-08-06 10:02:50 +08:00
|
|
|
|
const CARD_W = 320, CARD_H = 460, GAP_X = 80, GAP_Y = 120;
|
2026-08-05 17:44:01 +08:00
|
|
|
|
const nodes = [];
|
|
|
|
|
|
|
2026-08-06 10:21:15 +08:00
|
|
|
|
// 按行组织:每行是一个 { y, tasks: [...] }
|
|
|
|
|
|
const rows = [];
|
|
|
|
|
|
|
|
|
|
|
|
const layout = (tasks, parentRowIdx, parentX) => {
|
|
|
|
|
|
if (!tasks || !tasks.length) return;
|
|
|
|
|
|
tasks.forEach(t => {
|
|
|
|
|
|
// 判定:parent COMPLETED → 串行 Y+1;parent WIP/PENDING → 并行同Y偏右
|
|
|
|
|
|
const isSerial = !t.parent_task_id || (this.taskStatusMap[t.parent_task_id] === 'COMPLETED');
|
|
|
|
|
|
const rowIdx = isSerial ? (parentRowIdx >= 0 ? parentRowIdx + 1 : rows.length) : parentRowIdx;
|
|
|
|
|
|
while (rows.length <= rowIdx) rows.push({ y: 0, tasks: [] });
|
|
|
|
|
|
|
|
|
|
|
|
const row = rows[rowIdx];
|
|
|
|
|
|
const x = row.tasks.length === 0 ? 40 : row.tasks[row.tasks.length - 1]._xEnd + GAP_X;
|
|
|
|
|
|
const node = { ...t, x, y: 0, _rowIdx: rowIdx, _xEnd: x + CARD_W };
|
|
|
|
|
|
row.tasks.push(node);
|
|
|
|
|
|
nodes.push(node);
|
2026-08-05 17:44:01 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-06 10:21:15 +08:00
|
|
|
|
// 递归子任务
|
|
|
|
|
|
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);
|
2026-08-05 17:44:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-08-06 10:21:15 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-05 17:25:15 +08:00
|
|
|
|
return nodes;
|
|
|
|
|
|
},
|
2026-08-06 10:21:15 +08:00
|
|
|
|
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;
|
|
|
|
|
|
},
|
2026-08-05 17:44:01 +08:00
|
|
|
|
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); }
|
2026-08-05 17:25:15 +08:00
|
|
|
|
},
|
|
|
|
|
|
methods: {
|
2026-08-05 17:44:01 +08:00
|
|
|
|
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"; } }
|
|
|
|
|
|
}
|
2026-08-05 17:25:15 +08:00
|
|
|
|
};
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-08-05 17:44:01 +08:00
|
|
|
|
.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; }
|
2026-08-06 09:52:21 +08:00
|
|
|
|
.tree-movable-area { flex: 1; width: 100%; background-color: #f8fafc; background-image: linear-gradient(#e5e7eb 1px, transparent 1px), linear-gradient(90deg, #e5e7eb 1px, transparent 1px); background-size: 20px 20px; }
|
2026-08-05 17:25:15 +08:00
|
|
|
|
.canvas-inner { position: absolute; left: 0; top: 0; }
|
2026-08-06 10:02:50 +08:00
|
|
|
|
.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; }
|
2026-08-05 17:25:15 +08:00
|
|
|
|
.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; }
|
2026-08-06 10:02:50 +08:00
|
|
|
|
.cn-name { font-size: 24px; font-weight: 800; color: #1f2937; }
|
|
|
|
|
|
.cn-badge { font-size: 14px; padding: 4px 12px; border-radius: 8px; font-weight: 700; }
|
2026-08-05 17:25:15 +08:00
|
|
|
|
.cn-badge.s-yellow { background: #fef3c7; color: #b45309; }
|
|
|
|
|
|
.cn-badge.s-blue { background: #dbeafe; color: #1d4ed8; }
|
|
|
|
|
|
.cn-badge.s-green { background: #dcfce7; color: #15803d; }
|
2026-08-05 17:44:01 +08:00
|
|
|
|
.cn-badge.s-red { background: #fce4ec; color: #be123c; }
|
2026-08-06 10:02:50 +08:00
|
|
|
|
.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; }
|
|
|
|
|
|
.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; }
|
2026-08-05 17:25:15 +08:00
|
|
|
|
.cn-end-wh { color: #7c3aed; }
|
2026-08-06 10:02:50 +08:00
|
|
|
|
.cn-records { font-size: 14px; color: #2563eb; font-weight: 700; background: #eff6ff; padding: 4px 12px; border-radius: 12px; }
|
|
|
|
|
|
.canvas-line { position: absolute; height: 3px; background: #94a3b8; z-index: 1; transform-origin: 0 0; }
|
2026-08-05 17:25:15 +08:00
|
|
|
|
</style>
|