feat: 移动端全局人名映射 + 协同留言板 UI
- detail.vue: 留言板聊天室 UI + 乐观更新 + loadUsers 填充全局字典 - WorkspaceArea.vue: 任务列表/上游/下游负责人全部走 formatUserName - TreeCanvas.vue: 节点负责人 + 嵌套协助提示走 formatUserName - TaskSwipeCards.vue: 卡片负责人走 formatUserName - 留言板头像取中文名末字(formatUserAvatar)
This commit is contained in:
@ -44,7 +44,7 @@
|
||||
<view class="tc-meta">
|
||||
<view class="tc-meta-row">
|
||||
<text class="tc-meta-label">👤 负责人</text>
|
||||
<text class="tc-meta-val">{{ card.assignee_id || '未分配' }}</text>
|
||||
<text class="tc-meta-val">{{ formatUserName(card.assignee_id) || '未分配' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@ -94,6 +94,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { formatUserName } from "../../../utils/format";
|
||||
export default {
|
||||
name: "TaskSwipeCards",
|
||||
props: {
|
||||
@ -116,14 +117,12 @@ export default {
|
||||
tasks.forEach(t => { flatMap[t.id] = t; flatten(t.child_tasks); });
|
||||
};
|
||||
flatten(this.product.task_tree);
|
||||
const isMainFn = (t) => !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY';
|
||||
const traverse = (tasks, prefix) => {
|
||||
if (!tasks) return;
|
||||
let spawnIndex = 0;
|
||||
tasks.forEach((t) => {
|
||||
const parent = t.parent_task_id ? flatMap[t.parent_task_id] : null;
|
||||
const isMain = !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY'
|
||||
|| (!t.task_type && parent && (parent.status === 'COMPLETED' || parent.status === 'CANCELED'));
|
||||
if (isMain) { map[t.id] = '主分支'; traverse(t.child_tasks, prefix); }
|
||||
if (isMainFn(t)) { map[t.id] = '主分支'; traverse(t.child_tasks, prefix); }
|
||||
else { spawnIndex++; const num = prefix ? `${prefix}.${spawnIndex}` : `${spawnIndex}`; map[t.id] = `分支 ${num}`; traverse(t.child_tasks, num); }
|
||||
});
|
||||
};
|
||||
@ -134,14 +133,14 @@ export default {
|
||||
const result = [];
|
||||
if (!this.product || !this.product.task_tree) return result;
|
||||
|
||||
// 分支0:主分支
|
||||
const mainLane = { _key: 'main', label: '主分支', cards: [], _cardIdx: 0 };
|
||||
const isMainFn = (t) => !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY';
|
||||
|
||||
const sortTasks = (tasks) => {
|
||||
if (!tasks) return [];
|
||||
return [...tasks].sort((a, b) => {
|
||||
const aMain = (!a.parent_task_id || a.task_type === 'TRANSFER' || a.task_type === 'RECOVERY') ? -1 : 1;
|
||||
const bMain = (!b.parent_task_id || b.task_type === 'TRANSFER' || b.task_type === 'RECOVERY') ? -1 : 1;
|
||||
const aMain = isMainFn(a) ? -1 : 1;
|
||||
const bMain = isMainFn(b) ? -1 : 1;
|
||||
if (aMain !== bMain) return aMain - bMain;
|
||||
return new Date(a.created_at) - new Date(b.created_at);
|
||||
});
|
||||
@ -152,17 +151,13 @@ export default {
|
||||
const sorted = sortTasks(taskList);
|
||||
|
||||
for (const t of sorted) {
|
||||
const isMain = !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY'
|
||||
|| (!t.task_type && t.parent_task_id);
|
||||
|
||||
if (isMain) {
|
||||
if (isMainFn(t)) {
|
||||
lane.cards.push({ ...t, _key: t.id + '_' + lane._key, _isMain: lane._key === 'main' });
|
||||
// 递归:本分支下一步 = TRANSFER 子节点
|
||||
if (t.child_tasks && t.child_tasks.length) {
|
||||
const nextMain = t.child_tasks.filter(c =>
|
||||
c.task_type === 'TRANSFER' || c.task_type === 'RECOVERY' || !c.task_type);
|
||||
c.task_type === 'TRANSFER' || c.task_type === 'RECOVERY');
|
||||
followMain(nextMain, lane);
|
||||
// SPAWN 子节点 → 新分支
|
||||
// SPAWN 子节点 → 新分支(含嵌套 spawn)
|
||||
const spawns = t.child_tasks.filter(c => c.task_type === 'SPAWN');
|
||||
for (const sc of spawns) {
|
||||
const blabel = this.branchLabelMap[sc.id] || '分支';
|
||||
@ -190,6 +185,7 @@ export default {
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
methods: {
|
||||
formatUserName,
|
||||
onLaneSwipe(e) { this.currentLane = e.detail.current; },
|
||||
onCardSwipe(e, laneIdx) {
|
||||
if (this.lanes[laneIdx]) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
<view class="tli-left">
|
||||
<text class="tag-branch">{{ branchLabelsMap[t.id] || '' }}</text>
|
||||
<text class="tli-name">{{ t.task_name }}</text>
|
||||
<text class="tli-assignee">→ {{ t.assignee_id || '未分配' }}</text>
|
||||
<text class="tli-assignee">→ {{ formatUserName(t.assignee_id) || '未分配' }}</text>
|
||||
<text v-if="getTaskRemark(t)" class="tli-remark">{{ getTaskRemark(t) }}</text>
|
||||
</view>
|
||||
<view class="tli-right">
|
||||
@ -60,13 +60,13 @@
|
||||
</view>
|
||||
|
||||
<view v-if="lockedTask.parent_task_id && parentTaskOf(lockedTask)" class="fc-link fc-up">
|
||||
<text class="fc-link-label">⬆ 上游工序</text>
|
||||
<text class="fc-link-name">{{ parentTaskOf(lockedTask).task_name }} → {{ parentTaskOf(lockedTask).assignee_id || '—' }}</text>
|
||||
<text class="fc-link-label">{{ isNestedSpawn ? '⬆ 上游协助 (嵌套)' : '⬆ 上游工序' }}</text>
|
||||
<text class="fc-link-name">{{ parentTaskOf(lockedTask).task_name }} → {{ formatUserName(parentTaskOf(lockedTask).assignee_id) || '—' }}</text>
|
||||
</view>
|
||||
<view v-if="lockedTask.child_tasks && lockedTask.child_tasks.length" class="fc-link fc-down">
|
||||
<text class="fc-link-label">⬇ 下游分支 ({{ lockedTask.child_tasks.length }})</text>
|
||||
<text v-for="c in lockedTask.child_tasks" :key="c.id" class="fc-link-name">
|
||||
· {{ c.task_name }} → {{ c.assignee_id || '—' }}
|
||||
· {{ c.task_name }} → {{ formatUserName(c.assignee_id) || '—' }}
|
||||
<text v-if="c.status==='COMPLETED'" class="branch-done">✓已完成</text>
|
||||
<text v-else-if="c.status==='ARCHIVED'" class="branch-done">📦已入库</text>
|
||||
</text>
|
||||
@ -102,6 +102,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { formatUserName } from "../../../utils/format";
|
||||
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" };
|
||||
|
||||
export default {
|
||||
@ -142,6 +143,7 @@ export default {
|
||||
},
|
||||
focusTasks() {
|
||||
const result = [];
|
||||
const isMainFn = (t) => !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY';
|
||||
const walk = (tasks) => {
|
||||
if (!tasks) return;
|
||||
for (const t of tasks) {
|
||||
@ -151,8 +153,8 @@ export default {
|
||||
};
|
||||
if (this.product) walk(this.product.task_tree);
|
||||
result.sort((a, b) => {
|
||||
const aIsMain = !a.parent_task_id || a.task_type === 'TRANSFER' || a.task_type === 'RECOVERY';
|
||||
const bIsMain = !b.parent_task_id || b.task_type === 'TRANSFER' || b.task_type === 'RECOVERY';
|
||||
const aIsMain = isMainFn(a);
|
||||
const bIsMain = isMainFn(b);
|
||||
if (aIsMain && !bIsMain) return -1;
|
||||
if (!aIsMain && bIsMain) return 1;
|
||||
return new Date(a.created_at) - new Date(b.created_at);
|
||||
@ -169,11 +171,17 @@ export default {
|
||||
if (!parent) return false;
|
||||
return parent.assignee_id == this.currentUserId || parent.assignee_id == this.currentUsername;
|
||||
},
|
||||
// 🚀 嵌套协助判定:直接父任务不是主线 → 孙子/曾孙协助
|
||||
isNestedSpawn() {
|
||||
if (!this.lockedTask || !this.lockedTask.parent_task_id) return false;
|
||||
const parent = this.taskMap[this.lockedTask.parent_task_id];
|
||||
if (!parent) return false;
|
||||
return parent.parent_task_id && parent.task_type !== 'TRANSFER' && parent.task_type !== 'RECOVERY';
|
||||
},
|
||||
branchLabelsMap() {
|
||||
const map = {};
|
||||
if (!this.product || !this.product.task_tree) return map;
|
||||
|
||||
// 拍平树结构,方便随时查父节点状态
|
||||
const flatMap = {};
|
||||
const flatten = (tasks) => {
|
||||
if (!tasks) return;
|
||||
@ -181,22 +189,14 @@ export default {
|
||||
};
|
||||
flatten(this.product.task_tree);
|
||||
|
||||
// 🚀 仅凭基因字段判定,移除危险的状态兜底
|
||||
const isMainFn = (t) => !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY';
|
||||
|
||||
const traverse = (tasks, prefix) => {
|
||||
if (!tasks) return;
|
||||
let spawnIndex = 0;
|
||||
tasks.forEach((t) => {
|
||||
const parent = t.parent_task_id ? flatMap[t.parent_task_id] : null;
|
||||
|
||||
// 🚀 终极防御判定:
|
||||
// 1. 没有父节点(打底任务) -> 主分支
|
||||
// 2. 明确的基因 (TRANSFER/RECOVERY) -> 主分支
|
||||
// 3. 兜底:如果基因丢失,但它的父节点已经完工(COMPLETED),那它必然是接力的转交任务 -> 主分支
|
||||
const isMain = !t.parent_task_id
|
||||
|| t.task_type === 'TRANSFER'
|
||||
|| t.task_type === 'RECOVERY'
|
||||
|| (!t.task_type && parent && (parent.status === 'COMPLETED' || parent.status === 'CANCELED'));
|
||||
|
||||
if (isMain) {
|
||||
if (isMainFn(t)) {
|
||||
map[t.id] = '主分支';
|
||||
traverse(t.child_tasks, prefix);
|
||||
} else {
|
||||
@ -215,6 +215,7 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
formatUserName,
|
||||
statusLabel(s) { return STATUS_MAP[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"; default: return "s-gray"; } },
|
||||
countTasks(tree) { return tree ? tree.reduce((s, t) => s + 1 + this.countTasks(t.child_tasks), 0) : 0; },
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
<view class="info-item"><text class="label">订单编号</text><text class="value">{{ product.order_no || '—' }}</text></view>
|
||||
<view class="info-item" v-if="product.current_location_id">
|
||||
<text class="label">当前位置</text>
|
||||
<text :class="['value', product.current_location_id === 'virtual_warehouse' ? 'warehouse' : '']">{{ product.current_location_id === 'virtual_warehouse' ? '🏭 仓库' : product.current_location_id }}</text>
|
||||
<text :class="['value', product.current_location_id === 'virtual_warehouse' ? 'warehouse' : '']">{{ formatUserName(product.current_location_id) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@ -46,6 +46,28 @@
|
||||
:initialLockTaskId="autoLockTaskId"
|
||||
@action="handleTaskAction" @viewRecords="handleViewRecords" />
|
||||
|
||||
<!-- 💬 协同留言板 -->
|
||||
<view v-if="currentMode === 'workspace'" class="message-board">
|
||||
<view class="mb-title">💬 协同留言板</view>
|
||||
<scroll-view scroll-y class="mb-scroll-area" :scroll-into-view="bottomMsgId" scroll-with-animation>
|
||||
<view v-for="msg in messages" :key="msg.id" class="mb-item" :id="'msg-' + msg.id">
|
||||
<view class="mb-avatar">{{ formatUserAvatar(msg.operator_id) }}</view>
|
||||
<view class="mb-content-wrapper">
|
||||
<view class="mb-header-info">
|
||||
<text class="mb-name">{{ formatUserName(msg.operator_id) }}</text>
|
||||
<text class="mb-time">{{ fmtMsgTime(msg.created_at) }}</text>
|
||||
</view>
|
||||
<view class="mb-bubble">{{ msg.content }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view id="msg-bottom" class="mb-bottom-anchor"></view>
|
||||
</scroll-view>
|
||||
<view class="mb-input-bar">
|
||||
<input v-model="newMsgText" class="mb-input" placeholder="输入交接注意事项..." confirm-type="send" @confirm="submitMessage" />
|
||||
<view :class="['mb-send-btn', !newMsgText.trim() ? 'btn-disabled' : '']" @tap="submitMessage">发送</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 📇 流转卡片:探探式单张滑动 -->
|
||||
<TaskSwipeCards v-if="currentMode === 'swipe'" :product="product"
|
||||
@back="currentMode = 'workspace'" @overview="currentMode = 'tree'"
|
||||
@ -163,6 +185,7 @@
|
||||
|
||||
<script>
|
||||
import request, { get, post, patch, put } from "../../utils/request";
|
||||
import { setUserNameMap, formatUserName, formatUserAvatar } from "../../utils/format";
|
||||
import WorkspaceArea from "./components/WorkspaceArea.vue";
|
||||
import TreeCanvas from "./components/TreeCanvas.vue";
|
||||
import TaskSwipeCards from "./components/TaskSwipeCards.vue";
|
||||
@ -185,6 +208,10 @@ export default {
|
||||
actionPopup: { visible: false, type: "", task: null }, actionLoading: false, rejectReason: "", receiveRemark: "", receiveTaskName: "",
|
||||
transferForm: { selectedUserId: "", isWarehouse: false, note: "" },
|
||||
spawnForm: { assignee_id: "", remark: "" },
|
||||
// 💬 留言板
|
||||
messages: [],
|
||||
newMsgText: '',
|
||||
bottomMsgId: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@ -201,11 +228,12 @@ export default {
|
||||
},
|
||||
onLoad(options) { this.loadUsers(); this.loadCurrentUser(); const sn = options.serial || ""; if (sn) this.doQuery(sn); },
|
||||
methods: {
|
||||
formatUserName, formatUserAvatar,
|
||||
formatName(name) { if (!name) return ""; return name.length === 2 ? name[0] + " " + name[1] : name; },
|
||||
statusLabel(s) { return STATUS_MAP[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"; default: return "s-gray"; } },
|
||||
|
||||
async doQuery(sn) { this.loading = true; this.error = ""; try { this.product = await get(`/products/scan/${sn}`); this.$nextTick(() => { this.currentMode = 'workspace'; this.autoLockTaskId = this.findMyImmersiveTask() || ''; if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } }); } catch (e) { this.error = e?.data?.detail || "查询失败"; } finally { this.loading = false; } },
|
||||
async doQuery(sn) { this.loading = true; this.error = ""; try { this.product = await get(`/products/scan/${sn}`); this.$nextTick(() => { this.currentMode = 'workspace'; this.autoLockTaskId = this.findMyImmersiveTask() || ''; if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } }); this.fetchMessages(); } catch (e) { this.error = e?.data?.detail || "查询失败"; } finally { this.loading = false; } },
|
||||
findMyImmersiveTask() {
|
||||
// 🚀 扫描用户的 WIP/PENDING 任务,自动沉浸锁定
|
||||
if (!this.product || !this.product.task_tree) return null;
|
||||
@ -228,7 +256,7 @@ export default {
|
||||
openEditProduct() { this.editForm = { order_no: this.product.order_no || "", external_serial: this.product.external_serial || "" }; this.editProductVisible = true; },
|
||||
async doEditProduct() { this.editSaving = true; try { this.product = await patch(`/products/${this.product.id}`, { order_no: this.editForm.order_no.trim(), external_serial: this.editForm.external_serial.trim() }); uni.showToast({ title: "已保存", icon: "success" }); this.editProductVisible = false; } catch {} finally { this.editSaving = false; } },
|
||||
|
||||
async loadUsers() { try { const res = await get("/users/", { limit: 200, dept: "IRIS" }); this.users = (res || []).filter(u => u.department === "IRIS"); this.userOptions = this.users.map(u => ({ id: u.username || u.id, name: u.full_name || u.username })); } catch {} },
|
||||
async loadUsers() { try { const res = await get("/users/", { limit: 200, dept: "IRIS" }); this.users = (res || []).filter(u => u.department === "IRIS"); this.userOptions = this.users.map(u => ({ id: u.username || u.id, name: u.full_name || u.username })); setUserNameMap(this.users); } catch {} },
|
||||
loadCurrentUser() { try { let user = uni.getStorageSync("user"); if (typeof user === "string" && user) { try { user = JSON.parse(user); } catch (e) { user = null; } } if (user && typeof user === "object") { this.currentUser = user; this.currentUserId = String(user.id || ""); this.currentUsername = user.username || ""; } } catch {} },
|
||||
|
||||
onTaskNameChange(e) { const idx = e.detail.value; this.firstForm.taskNameIdx = idx; this.firstForm.task_name = TASK_NAME_OPTIONS[idx]; },
|
||||
@ -272,6 +300,11 @@ export default {
|
||||
async doTransfer() { this.actionLoading = true; try { const assignees = this.transferForm.isWarehouse ? ["virtual_warehouse"] : [this.transferForm.selectedUserId]; const taskName = this.transferForm.isWarehouse ? "🏭 入库 (virtual_warehouse)" : "待确认"; await post(`/tasks/${this.actionPopup.task.id}/transfer`, { next_tasks: [{ task_name: taskName, assignees }], note: this.transferForm.note.trim() || undefined }); uni.showToast({ title: this.transferForm.isWarehouse ? "已入库" : "转交成功", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } },
|
||||
// 派发协助分支
|
||||
async doSpawn() { this.actionLoading = true; try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/spawn?operator_id=${encodeURIComponent(opId)}`, { task_name: "待确认", assignee_id: this.spawnForm.assignee_id, remark: this.spawnForm.remark.trim() || undefined }); uni.showToast({ title: "协助分支已派发", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } },
|
||||
// 💬 留言板
|
||||
async fetchMessages() { if (!this.product?.id) return; try { const res = await get(`/products/${this.product.id}/messages`); this.messages = res || []; this.scrollToBottom(); } catch (e) { console.error('获取留言失败', e); } },
|
||||
async submitMessage() { const content = this.newMsgText.trim(); if (!content) return; this.newMsgText = ''; const tempId = 'temp_' + Date.now(); const tempMsg = { id: tempId, operator_id: this.currentUsername || this.currentUserId || '?', content, created_at: new Date().toISOString() }; this.messages.push(tempMsg); this.scrollToBottom(); try { await post(`/products/${this.product.id}/messages`, { operator_id: this.currentUsername || this.currentUserId, content }); this.fetchMessages(); } catch (e) { uni.showToast({ title: '发送失败', icon: 'none' }); this.messages = this.messages.filter(m => m.id !== tempId); } },
|
||||
scrollToBottom() { this.$nextTick(() => { this.bottomMsgId = 'msg-bottom'; }); },
|
||||
fmtMsgTime(d) { if (!d) return ''; const dt = new Date(d); const pad = (n) => String(n).padStart(2, '0'); return `${pad(dt.getMonth()+1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; },
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@ -329,6 +362,23 @@ export default {
|
||||
.btn-add-branch { width: 100%; height: 40px; border: 2px dashed #93c5fd; border-radius: 10px; background: #eff6ff; color: #2563eb; font-size: 14px; font-weight: 700; line-height: 40px; margin: 4px 0; }
|
||||
.btn-add-branch::after { border: none; }
|
||||
.field-label { font-size: 14px; font-weight: 600; color: #374151; margin-top: 10px; margin-bottom: 4px; }
|
||||
|
||||
/* 💬 协同留言板 */
|
||||
.message-board { margin: 16px; background: #ffffff; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); display: flex; flex-direction: column; height: 400px; }
|
||||
.mb-title { font-size: 14px; font-weight: bold; padding: 12px 16px; border-bottom: 1px solid #f3f4f6; color: #374151; flex-shrink: 0; }
|
||||
.mb-scroll-area { flex: 1; padding: 12px; overflow-y: auto; }
|
||||
.mb-item { display: flex; margin-bottom: 16px; }
|
||||
.mb-avatar { width: 36px; height: 36px; border-radius: 18px; background: #3b82f6; color: #fff; font-weight: bold; display: flex; align-items: center; justify-content: center; margin-right: 12px; flex-shrink: 0; font-size: 14px; }
|
||||
.mb-content-wrapper { flex: 1; min-width: 0; }
|
||||
.mb-header-info { margin-bottom: 4px; display: flex; align-items: baseline; }
|
||||
.mb-name { font-size: 12px; color: #6b7280; margin-right: 8px; font-weight: 600; }
|
||||
.mb-time { font-size: 10px; color: #9ca3af; }
|
||||
.mb-bubble { background: #f3f4f6; padding: 8px 12px; border-radius: 0 12px 12px 12px; font-size: 14px; color: #1f2937; word-break: break-all; line-height: 1.5; }
|
||||
.mb-input-bar { display: flex; padding: 10px 16px; border-top: 1px solid #e5e7eb; align-items: center; background: #f9fafb; border-radius: 0 0 12px 12px; flex-shrink: 0; }
|
||||
.mb-input { flex: 1; background: #ffffff; border: 1px solid #d1d5db; padding: 6px 12px; border-radius: 16px; font-size: 14px; height: 36px; }
|
||||
.mb-send-btn { margin-left: 12px; background: #3b82f6; color: #fff; padding: 6px 16px; border-radius: 16px; font-size: 14px; font-weight: 600; transition: all 0.2s; }
|
||||
.btn-disabled { background: #9ca3af; opacity: 0.5; }
|
||||
.mb-bottom-anchor { height: 1px; }
|
||||
.required { color: #ef4444; }
|
||||
.picker-box { width: 100%; height: 42px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 0 12px; font-size: 14px; color: #1f2937; line-height: 42px; box-sizing: border-box; background: #fff; }
|
||||
.img-grid { display: flex; flex-wrap: wrap; margin: 8px -5px; }
|
||||
|
||||
Reference in New Issue
Block a user