流转树2D画布: scroll-view无限拖拽 + 水平分支并排 + 垂直时间轴 + 连线

This commit is contained in:
2026-08-05 16:58:12 +08:00
parent 9aa0687d8c
commit 1d759f3e8c

View File

@ -151,25 +151,35 @@
</view>
<!-- ================================================================ -->
<!-- 🌳 全局流转树 (tree 模式 只读) -->
<!-- 🌳 全局流转树 (tree 模式 2D 画布只读) -->
<!-- ================================================================ -->
<view v-if="currentMode === 'tree'" class="tree-area">
<view v-if="!product.task_tree || !product.task_tree.length" class="zero-task">
<text class="zero-icon">📋</text>
<text class="zero-text">该产品暂无流转任务</text>
</view>
<TaskTreeNode
v-for="(task, idx) in (product.task_tree || [])"
:key="task.id"
:task="task"
:currentUser="currentUser"
:currentUserId="currentUserId"
:currentUsername="currentUsername"
:hasChildren="task.child_tasks && task.child_tasks.length > 0"
:isLast="idx === (product.task_tree || []).length - 1"
:readonly="true"
@viewRecords="handleViewRecords"
/>
<view v-else class="canvas-hint">🖐 上下滑动看流程 · 左右滑动看分支</view>
<scroll-view v-else scroll-x scroll-y class="tree-canvas" :style="{ width: '100%', height: canvasHeight + 'px' }">
<view class="canvas-inner" :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }">
<!-- 节点 -->
<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 && handleViewRecords(node)">
<text class="cn-name">{{ node.task_name }}</text>
<text class="cn-assignee">{{ node.assignee_id || '—' }}</text>
<text :class="['cn-badge', statusColor(node.status)]">{{ statusLabel(node.status) }}</text>
<text v-if="node.remark" class="cn-remark">{{ node.remark }}</text>
<text v-if="node.status==='COMPLETED' && !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>
</view>
<!-- 连接线 -->
<view v-for="line in treeLines" :key="line.key" class="canvas-line"
:style="lineStyle(line)">
</view>
</view>
</scroll-view>
</view>
</template>
@ -475,6 +485,59 @@ export default {
hasMyActiveTask() {
return this.focusTasks.length > 0;
},
// 2D 画布布局
treeNodes() {
const nodes = [];
const CARD_W = 180, CARD_H = 120, GAP_X = 40, GAP_Y = 30;
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 || [] });
}
// Store parent center for line drawing
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) {
nodes._lines = [];
layout(this.product.task_tree, 0, 20);
}
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)) + 200);
},
canvasHeight() {
const nodes = this.treeNodes;
if (!nodes.length) return 400;
return Math.max(400, Math.max(...nodes.map(n => n.y)) + 140);
},
},
onLoad(options) {
this.loadUsers();
@ -516,6 +579,19 @@ export default {
lockTask(taskId) {
this.lockedTaskId = taskId;
},
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',
};
},
// ---- 状态定调 ----
async handleSetOverallStatus(status) {
@ -965,8 +1041,27 @@ export default {
.fc-records-bar { padding: 8px 12px; background: linear-gradient(135deg,#eff6ff,#dbeafe); border-radius: 8px; font-size: 13px; font-weight: 700; color: #2563eb; margin-bottom: 8px; }
.fc-time { font-size: 11px; color: #9ca3af; margin-top: auto; padding-top: 8px; border-top: 1px solid #f3f4f6; }
/* 流转树视图(只读) */
.tree-area { flex: 1; overflow-y: auto; min-height: 0; padding: 4px 0; -webkit-overflow-scrolling: touch; }
/* 流转树 2D 画布 */
.tree-area { flex: 1; overflow: hidden; min-height: 0; position: relative; }
.canvas-hint { font-size: 11px; color: #9ca3af; text-align: center; padding: 4px 0; flex-shrink: 0; }
.tree-canvas { flex: 1; min-height: 0; }
.canvas-inner { position: relative; }
.canvas-node { position: absolute; width: 180px; min-height: 100px; background: #fff; border-radius: 10px; padding: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); border-top: 4px solid #3b82f6; }
.canvas-node.s-yellow { border-top-color: #f59e0b; }
.canvas-node.s-blue { border-top-color: #3b82f6; }
.canvas-node.s-green { border-top-color: #22c55e; }
.canvas-node.s-red { border-top-color: #ef4444; }
.cn-name { font-size: 13px; font-weight: 700; color: #1f2937; display: block; }
.cn-assignee { font-size: 10px; color: #9ca3af; display: block; margin-top: 2px; }
.cn-badge { font-size: 9px; padding: 1px 6px; border-radius: 8px; font-weight: 600; margin-top: 4px; display: inline-block; }
.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-remark { font-size: 10px; color: #6b7280; margin-top: 4px; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cn-end { font-size: 9px; font-weight: 600; color: #16a34a; margin-top: 2px; }
.cn-end-wh { color: #7c3aed; }
.cn-records { font-size: 10px; color: #2563eb; font-weight: 600; margin-top: 2px; }
.canvas-line { position: absolute; height: 2px; background: #d1d5db; }
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.card-header-right { display: flex; align-items: center; gap: 8px; }
.card-title { font-size: 15px; font-weight: 700; }