feat: 移动端全局人名映射 + 协同留言板 UI

- detail.vue: 留言板聊天室 UI + 乐观更新 + loadUsers 填充全局字典
- WorkspaceArea.vue: 任务列表/上游/下游负责人全部走 formatUserName
- TreeCanvas.vue: 节点负责人 + 嵌套协助提示走 formatUserName
- TaskSwipeCards.vue: 卡片负责人走 formatUserName
- 留言板头像取中文名末字(formatUserAvatar)
This commit is contained in:
2026-08-10 17:37:25 +08:00
parent 05003d3053
commit f9451b104b
4 changed files with 184 additions and 83 deletions

View File

@ -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>