Files
track/track-uniapp/src/pages/scan/components/TreeCanvas.vue
duxingchen 30a48d90fc fix(frontend): 流转蓝图 + 聊天室 + 卡片布局 + 打印功能修复
1. TreeCanvas.vue - Y轴坐标算法重构:
   - calcSubtreeHeight 改为兄弟累加 (reduce) 替代 Math.max,彻底杜绝垂直重叠
   - childNode.y = parentNode.y + CARD_H + VERTICAL_GAP 严格向下延伸
   - 同级子节点按子树高度累加 startY,各占独立 Y 区域
   - 加宽画布默认尺寸 (400→800, padding +300/+400)

2. detail.vue - onShow 生命周期修复:
   - 新增 onShow() 钩子调用 fetchMessages(),解决聊天室退回不刷新问题

3. TaskSwipeCards.vue + detail.vue - CSS 防跳动:
   - .ss-swiper-h / .ss-card-wrapper 添加 min-height: 220px
   - .card 添加 min-height: 120px

4. detail.vue - 移动端打印标签:
   - 新增「🖨️ 打印标签」按钮
   - 双方案 ActionSheet: 网络打印机(后端API) / 蓝牙打印机(ESC/POS)
   - 对接现有 /print/execute 端点
2026-08-11 15:05:39 +08:00

324 lines
16 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>
<text class="tc-title">🌳 流转蓝图</text>
</view>
<view class="tc-toolbar-right">
<view class="tc-zoom-btn tc-mode-btn" @tap="$emit('swipe')">📇 卡片</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" :style="{ width: viewportW + 'px', height: areaHeight + 'px' }">
<movable-view class="tree-movable-view" direction="all"
:x="mvX" :y="mvY" :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
:inertia="true" :friction="2" :damping="40"
@change="onChange">
<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="handleNodeTap(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">{{ formatUserName(node.assignee_id) || '—' }}</text>
<text v-if="!node._isMain && node.parent && !node.parent._isMain" class="cn-nested-hint">协助: {{ formatUserName(node.parent.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>
import { formatUserName } from "../../../utils/format";
export default {
name: "TreeCanvas",
props: { product: { type: Object, required: true } },
emits: ["viewRecords", "back", "swipe"],
data() {
return {
mvX: 0, mvY: 0,
viewportW: 375, viewportH: 600,
areaHeight: 500,
};
},
mounted() {
try { const info = uni.getSystemInfoSync(); this.viewportW = info.windowWidth || 375; this.viewportH = info.windowHeight || 600; } catch {}
this.areaHeight = Math.max(this.viewportH - 100, 400);
this.$nextTick(() => this.fitAll());
},
computed: {
// ============================================================
// 🚀 父子相对坐标延伸算法v3 — 无重叠 Y 轴栈式布局)
// ============================================================
treeNodes() {
if (!this.product || !this.product.task_tree) return [];
const CARD_W = 160, CARD_H = 80, GAP_X = 24, VERTICAL_GAP = 28, MAIN_GAP = 40;
const CENTER_X = 0;
// 拍平 + 浅拷贝
const flatMap = {};
const allTasks = [];
const walk = (tasks) => { if (!tasks) return; tasks.forEach(t => { const copy = { ...t }; allTasks.push(copy); flatMap[copy.id] = copy; walk(t.child_tasks); }); };
walk(this.product.task_tree);
allTasks.forEach(t => {
if (t.child_tasks && t.child_tasks.length) t.child_tasks = t.child_tasks.map(c => flatMap[c.id]).filter(Boolean);
if (t.parent_task_id) t.parent = flatMap[t.parent_task_id] || null;
});
// 🚀 主干判定
const isMainFn = (t) => !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY';
// 分支编号
const branchLabelMap = {};
const traverse = (tasks, prefix) => {
if (!tasks) return;
let si = 0;
tasks.forEach(t => {
if (isMainFn(t)) { 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); }
});
};
const rootCopies = this.product.task_tree.map(t => flatMap[t.id]).filter(Boolean);
rootCopies.forEach(t => { branchLabelMap[t.id] = '主分支'; traverse(t.child_tasks, ''); });
// 🚀 构建真实的全局 childMap按 parent_task_id
const childMap = {};
allTasks.forEach(t => {
const pid = t.parent_task_id || '';
if (!childMap[pid]) childMap[pid] = [];
childMap[pid].push(t);
});
Object.values(childMap).forEach(arr => arr.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)));
// 主管分类
const mains = [];
allTasks.forEach(t => { if (isMainFn(t)) mains.push(t); });
mains.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
// 🚀 预计算每个节点的子树总高度(兄弟累加,用于防重叠占位)
const calcSubtreeHeight = (nodeId) => {
const children = childMap[nodeId] || [];
if (children.length === 0) return CARD_H + VERTICAL_GAP;
const totalChildrenHeight = children.reduce((sum, c) => sum + calcSubtreeHeight(c.id), 0);
return CARD_H + VERTICAL_GAP + totalChildrenHeight;
};
const nodes = [];
// 🚀 递归放置算法childNode.y = parentNode.y + CARD_H + VERTICAL_GAP
// 同级子节点按 index 错开 Y 轴,各占其子树高度防止重叠
const placeChildren = (parentNode, currentSide) => {
const children = childMap[parentNode.id] || [];
// 🚀 首个子节点 Y 起始parent.y 正下方
let startY = parentNode.y + CARD_H + VERTICAL_GAP;
children.forEach((child, index) => {
const subtreeH = calcSubtreeHeight(child.id);
// 主干的第一层协助分支:均衡分发左右
let side = currentSide;
if (parentNode._isMain) {
side = index % 2 === 0 ? 'right' : 'left';
}
// 🚀 X 轴:永远基于真实父亲向外延伸
const childX = side === 'right'
? parentNode.x + CARD_W + GAP_X
: parentNode.x - CARD_W - GAP_X;
// 🚀 Y 轴:严格基于父节点向下延伸,同级按 index 错开
const childY = startY;
const childNode = {
...child,
x: childX, y: childY,
_isMain: false, _side: side,
_blabel: branchLabelMap[child.id] || '分支',
_rootMainId: parentNode._isMain ? parentNode.id : parentNode._rootMainId,
};
nodes.push(childNode);
flatMap[childNode.id] = childNode;
// 递归放置孙子节点(沿相同方向)
placeChildren(childNode, side);
// 🚀 下一个兄弟节点跳到当前子树高度之后,严禁 Y 坐标重叠
startY += subtreeH + VERTICAL_GAP;
});
};
// 🚀 渲染入口:主干节点也按子树深度排布
let mainStartY = 0;
mains.forEach((m, mi) => {
const subtreeH = calcSubtreeHeight(m.id);
const mainNode = { ...m, x: CENTER_X, y: mainStartY, _isMain: true, _blabel: '主分支', _rootMainId: m.id };
nodes.push(mainNode);
flatMap[mainNode.id] = mainNode;
placeChildren(mainNode, null);
mainStartY += subtreeH + MAIN_GAP;
});
// 全局偏移(给左侧分支预留空间)
const minX = Math.min(...nodes.map(n => n.x));
const minY = Math.min(...nodes.map(n => n.y));
const padding = 40;
nodes.forEach(n => { n.x += Math.abs(minX) + CARD_W * 2 + padding; n.y += padding - Math.min(0, minY); });
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,
rot: isLeft ? 180 : 0,
dashed: isLegacy,
});
});
return result;
},
// ============================================================
canvasWidth() { const ns = this.treeNodes; if (!ns.length) return 800; return Math.max(...ns.map(n => n.x)) + 400; },
canvasHeight() { const ns = this.treeNodes; if (!ns.length) return 800; return Math.max(...ns.map(n => n.y)) + 300; },
},
methods: {
formatUserName,
// 🚀 手势结束后同步位置
onChange(e) {
if (e?.detail?.x !== undefined) this.mvX = e.detail.x;
if (e?.detail?.y !== undefined) this.mvY = e.detail.y;
},
handleNodeTap(node) {
if (node.records && node.records.length > 0) {
this.$emit('viewRecords', node);
} else {
uni.showToast({ title: '当前节点暂无流转记录', icon: 'none' });
}
},
// 🚀 全览居中:固定 100% 比例,内容整体在视口中居中
fitAll() {
const canvasW = this.canvasWidth;
const canvasH = this.canvasHeight;
const viewW = this.viewportW;
const viewH = this.areaHeight;
this.mvX = (viewW - canvasW) / 2;
this.mvY = (viewH - canvasH) / 2;
},
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 { width: 100%; background-color: #f0f2f5; background-image: linear-gradient(#dde1e6 1px, transparent 1px), linear-gradient(90deg, #dde1e6 1px, transparent 1px); background-size: 24px 24px; overflow: hidden; }
.canvas-inner { position: absolute; left: 0; top: 0; width: 100%; height: 100%; }
/* 🚀 movable-view 填充整个父容器,确保空白区域也能响应拖拽/缩放手势 */
.tree-movable-view { width: 100%; height: 100%; }
/* 🔧 连线(禁止吞事件) */
.cn-line { position: absolute; height: 2px; pointer-events: none; }
.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; pointer-events: none; }
.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-nested-hint { font-size: 8px; color: #a78bfa; font-weight: 600; }
.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>