流转树2D画布: scroll-view无限拖拽 + 水平分支并排 + 垂直时间轴 + 连线
This commit is contained in:
@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user