feat: 移动端全局人名映射 + 协同留言板 UI
- detail.vue: 留言板聊天室 UI + 乐观更新 + loadUsers 填充全局字典 - WorkspaceArea.vue: 任务列表/上游/下游负责人全部走 formatUserName - TreeCanvas.vue: 节点负责人 + 嵌套协助提示走 formatUserName - TaskSwipeCards.vue: 卡片负责人走 formatUserName - 留言板头像取中文名末字(formatUserAvatar)
This commit is contained in:
@ -41,7 +41,8 @@
|
||||
<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">{{ node.assignee_id || '—' }}</text>
|
||||
<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>
|
||||
@ -54,6 +55,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { formatUserName } from "../../../utils/format";
|
||||
export default {
|
||||
name: "TreeCanvas",
|
||||
props: { product: { type: Object, required: true } },
|
||||
@ -77,79 +79,129 @@ export default {
|
||||
treeNodes() {
|
||||
if (!this.product || !this.product.task_tree) return [];
|
||||
const CARD_W = 160, CARD_H = 80, GAP_X = 24, GAP_Y = 24;
|
||||
const CENTER_X = 0; // 中央主干 X
|
||||
const CENTER_X = 0;
|
||||
|
||||
// 🔧 浅拷贝所有 task 对象,避免修改响应式原始数据导致无限渲染循环
|
||||
// 拍平 + 浅拷贝
|
||||
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);
|
||||
|
||||
// 重建父子引用(child_tasks 指向副本)
|
||||
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;
|
||||
}
|
||||
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 getRootMainId = (t) => {
|
||||
let curr = t;
|
||||
while (curr.parent_task_id && flatMap[curr.parent_task_id]) {
|
||||
const p = flatMap[curr.parent_task_id];
|
||||
if (isMainFn(p)) return p.id;
|
||||
curr = p;
|
||||
}
|
||||
return curr.parent_task_id || curr.id;
|
||||
};
|
||||
|
||||
// 分支编号
|
||||
const branchLabelMap = {};
|
||||
const traverse = (tasks, prefix) => {
|
||||
if (!tasks) return;
|
||||
let si = 0;
|
||||
tasks.forEach(t => {
|
||||
const p = t.parent_task_id ? flatMap[t.parent_task_id] : null;
|
||||
const isM = !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY' || (!t.task_type && p && (p.status === 'COMPLETED' || p.status === 'CANCELED'));
|
||||
if (isM) { branchLabelMap[t.id] = '主分支'; traverse(t.child_tasks, prefix); }
|
||||
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, ''); });
|
||||
|
||||
// 主管分类
|
||||
const mains = []; const subs = []; const subByParent = {};
|
||||
// 🚀 真实父子分组(按直接 parent_task_id)
|
||||
const subByParent = {};
|
||||
allTasks.forEach(t => {
|
||||
const p = t.parent_task_id ? flatMap[t.parent_task_id] : null;
|
||||
const isM = !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY' || (!t.task_type && p && (p.status === 'COMPLETED' || p.status === 'CANCELED'));
|
||||
if (isM) { mains.push(t); }
|
||||
else { subs.push(t); const pid = t.parent_task_id || ''; if (!subByParent[pid]) subByParent[pid] = []; subByParent[pid].push(t); }
|
||||
if (t.parent_task_id) {
|
||||
if (!subByParent[t.parent_task_id]) subByParent[t.parent_task_id] = [];
|
||||
subByParent[t.parent_task_id].push(t);
|
||||
}
|
||||
});
|
||||
// 每个 parent 下按时间排序
|
||||
Object.values(subByParent).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 nodes = [];
|
||||
const childSeen = new Set();
|
||||
mains.forEach((m, mi) => {
|
||||
const node = { ...m, x: CENTER_X, y: mi * (CARD_H + GAP_Y) * 2, _isMain: true, _blabel: '主分支' };
|
||||
nodes.push(node);
|
||||
// 更新 flatMap 引用以便 lines/arrows 计算使用正确的坐标
|
||||
flatMap[node.id] = node;
|
||||
|
||||
const children = subByParent[m.id] || [];
|
||||
children.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||
children.forEach((c, ci) => {
|
||||
const isLeft = ci % 2 === 0;
|
||||
const dist = Math.floor(ci / 2) + 1;
|
||||
const childNode = { ...c, y: node.y,
|
||||
x: isLeft ? CENTER_X - dist * (CARD_W + GAP_X) : CENTER_X + dist * (CARD_W + GAP_X),
|
||||
_isMain: false, _blabel: branchLabelMap[c.id] || '分支' };
|
||||
// 🚀 递归挂载:从父节点向外延伸 X,Y 保持与根主干同高
|
||||
const placeChildren = (parentNode, rootY) => {
|
||||
const children = subByParent[parentNode.id] || [];
|
||||
if (!children.length) return;
|
||||
|
||||
// 父节点是主干 → 交替左右;父节点已有侧向 → 继承方向
|
||||
const parentIsMain = parentNode._isMain;
|
||||
let leftCount = 0, rightCount = 0;
|
||||
|
||||
children.forEach((c) => {
|
||||
let side, childX;
|
||||
if (parentIsMain) {
|
||||
// 主干直系子节点:交替左右,距离递增
|
||||
side = leftCount <= rightCount ? 'left' : 'right';
|
||||
if (side === 'left') leftCount++; else rightCount++;
|
||||
const dist = side === 'left' ? leftCount : rightCount;
|
||||
childX = side === 'left'
|
||||
? CENTER_X - dist * (CARD_W + GAP_X)
|
||||
: CENTER_X + dist * (CARD_W + GAP_X);
|
||||
} else {
|
||||
// 非主干子节点:继承父节点方向,继续向外延伸
|
||||
side = parentNode._side;
|
||||
childX = side === 'left'
|
||||
? parentNode.x - (CARD_W + GAP_X)
|
||||
: parentNode.x + (CARD_W + GAP_X);
|
||||
}
|
||||
|
||||
const childNode = {
|
||||
...c,
|
||||
x: childX, y: rootY,
|
||||
_isMain: false, _side: side,
|
||||
_blabel: branchLabelMap[c.id] || '分支',
|
||||
_rootMainY: rootY,
|
||||
_rootMainId: parentIsMain ? parentNode.id : parentNode._rootMainId,
|
||||
};
|
||||
nodes.push(childNode);
|
||||
flatMap[childNode.id] = childNode;
|
||||
childSeen.add(c.id);
|
||||
|
||||
// 递归挂载孙子节点
|
||||
placeChildren(childNode, rootY);
|
||||
});
|
||||
};
|
||||
|
||||
// 遍历主干
|
||||
mains.forEach((m, mi) => {
|
||||
const rootY = mi * (CARD_H + GAP_Y) * 2;
|
||||
const mainNode = { ...m, x: CENTER_X, y: rootY, _isMain: true, _blabel: '主分支', _rootMainId: m.id };
|
||||
nodes.push(mainNode);
|
||||
flatMap[mainNode.id] = mainNode;
|
||||
|
||||
placeChildren(mainNode, rootY);
|
||||
});
|
||||
|
||||
// 遗留分支(父任务不在 mains 中的)
|
||||
subs.forEach(s => { if (!childSeen.has(s.id)) { const legacyNode = { ...s, x: 200, y: nodes.length ? nodes[nodes.length - 1].y + CARD_H + GAP_Y : 0, _isMain: false, _blabel: '分支' }; nodes.push(legacyNode); flatMap[legacyNode.id] = legacyNode; } });
|
||||
// 遗留分支(父任务不在任何已渲染节点中)
|
||||
const seenIds = new Set(nodes.map(n => n.id));
|
||||
allTasks.forEach(t => {
|
||||
if (!seenIds.has(t.id) && !isMainFn(t)) {
|
||||
const legacyNode = { ...t, x: CENTER_X + CARD_W + GAP_X, y: nodes.length ? nodes[nodes.length - 1].y + CARD_H + GAP_Y : 0, _isMain: false, _blabel: '分支', _rootMainId: '' };
|
||||
nodes.push(legacyNode);
|
||||
flatMap[legacyNode.id] = legacyNode;
|
||||
}
|
||||
});
|
||||
|
||||
// 加上全局偏移
|
||||
// 全局偏移
|
||||
const minX = Math.min(...nodes.map(n => n.x));
|
||||
const padding = 40;
|
||||
nodes.forEach(n => { n.x += Math.abs(CENTER_X) + CARD_W * 3; n.y += padding; });
|
||||
nodes.forEach(n => { n.x += Math.abs(minX) + CARD_W * 2 + padding; n.y += padding; });
|
||||
return nodes;
|
||||
},
|
||||
// ============================================================
|
||||
@ -162,11 +214,12 @@ export default {
|
||||
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; // 主干左边缘 / 右分支左边缘
|
||||
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,
|
||||
@ -175,7 +228,6 @@ export default {
|
||||
});
|
||||
return result;
|
||||
},
|
||||
// 🔧 箭头:像素级贴合分支卡片边缘
|
||||
arrows() {
|
||||
const result = [];
|
||||
const flatMap = {};
|
||||
@ -187,8 +239,8 @@ export default {
|
||||
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, // 垂直居中 (border 4+4=8, -2 微调)
|
||||
x: isLeft ? n.x + 160 : n.x - 6,
|
||||
y: n.y + 40 - 2,
|
||||
rot: isLeft ? 180 : 0,
|
||||
dashed: isLegacy,
|
||||
});
|
||||
@ -200,6 +252,7 @@ export default {
|
||||
canvasHeight() { const ns = this.treeNodes; if (!ns.length) return 400; return Math.max(...ns.map(n => n.y)) + 200; },
|
||||
},
|
||||
methods: {
|
||||
formatUserName,
|
||||
// 🚀 手势结束后同步位置
|
||||
onChange(e) {
|
||||
if (e?.detail?.x !== undefined) this.mvX = e.detail.x;
|
||||
@ -277,6 +330,7 @@ export default {
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user