diff --git a/track-uniapp/src/pages/login/login.vue b/track-uniapp/src/pages/login/login.vue index ff30caa..674bfea 100644 --- a/track-uniapp/src/pages/login/login.vue +++ b/track-uniapp/src/pages/login/login.vue @@ -55,8 +55,10 @@ async function handleLogin() { setTimeout(() => { uni.switchTab({ url: "/pages/scan/index" }); }, 500); - } catch { - // request.js 已弹 toast + } catch (e) { + // request.js 对 /auth/login 的 401 直接 reject 不弹 toast,此处补齐 + const detail = e?.data?.detail || ""; + uni.showToast({ title: detail || "账号或密码错误", icon: "none", duration: 2000 }); } finally { loading.value = false; } diff --git a/track-uniapp/src/pages/scan/components/TaskSwipeCards.vue b/track-uniapp/src/pages/scan/components/TaskSwipeCards.vue index efc79d9..31b4ab9 100644 --- a/track-uniapp/src/pages/scan/components/TaskSwipeCards.vue +++ b/track-uniapp/src/pages/scan/components/TaskSwipeCards.vue @@ -134,40 +134,35 @@ export default { if (!this.product || !this.product.task_tree) return result; 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 = 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); - }); + return [...tasks].sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); }; + // 🚀 终极递归收集算法 const followMain = (taskList, lane) => { if (!taskList || !taskList.length) return; const sorted = sortTasks(taskList); for (const t of sorted) { - if (isMainFn(t)) { - lane.cards.push({ ...t, _key: t.id + '_' + lane._key, _isMain: lane._key === 'main' }); - if (t.child_tasks && t.child_tasks.length) { - const nextMain = t.child_tasks.filter(c => - c.task_type === 'TRANSFER' || c.task_type === 'RECOVERY'); - followMain(nextMain, lane); - // SPAWN 子节点 → 新分支(含嵌套 spawn) - const spawns = t.child_tasks.filter(c => c.task_type === 'SPAWN'); - for (const sc of spawns) { - const blabel = this.branchLabelMap[sc.id] || '分支'; - const branchLane = { _key: 'branch_' + sc.id, label: blabel, cards: [], _cardIdx: 0 }; - branchLane.cards.push({ ...sc, _key: sc.id + '_' + branchLane._key, _isMain: false }); - if (sc.child_tasks && sc.child_tasks.length) { - followMain(sc.child_tasks, branchLane); - } - result.push(branchLane); - } + // 1. 无条件入列:既然进到了这个 lane,就属于这个 lane 的卡片 + lane.cards.push({ ...t, _key: t.id + '_' + lane._key, _isMain: lane._key === 'main' }); + + if (t.child_tasks && t.child_tasks.length) { + // 1. 同一分支线性延续 + const nextOnThisLane = t.child_tasks.filter(c => + c.task_type === 'TRANSFER' || c.task_type === 'RECOVERY' + ); + followMain(nextOnThisLane, lane); + + // 2. 凡是 SPAWN,必定开辟新分支 (不限层级) + const spawns = t.child_tasks.filter(c => c.task_type === 'SPAWN'); + for (const sc of spawns) { + const blabel = (this.branchLabelMap && this.branchLabelMap[sc.id]) || '协助分支'; + const branchLane = { _key: 'branch_' + sc.id, label: blabel, cards: [], _cardIdx: 0 }; + result.push(branchLane); // 先占坑:父分支排在前面 + followMain([sc], branchLane); // 再递归:孙子分支自然排在后面 } } } diff --git a/track-uniapp/src/pages/scan/components/TreeCanvas.vue b/track-uniapp/src/pages/scan/components/TreeCanvas.vue index 4f765d1..aab171b 100644 --- a/track-uniapp/src/pages/scan/components/TreeCanvas.vue +++ b/track-uniapp/src/pages/scan/components/TreeCanvas.vue @@ -74,7 +74,7 @@ export default { }, computed: { // ============================================================ - // 🚀 十字星并发拓扑算法 + // 🚀 父子相对坐标延伸算法 // ============================================================ treeNodes() { if (!this.product || !this.product.task_tree) return []; @@ -93,16 +93,6 @@ export default { // 🚀 主干判定 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 = {}; @@ -117,16 +107,14 @@ export default { const rootCopies = this.product.task_tree.map(t => flatMap[t.id]).filter(Boolean); rootCopies.forEach(t => { branchLabelMap[t.id] = '主分支'; traverse(t.child_tasks, ''); }); - // 🚀 真实父子分组(按直接 parent_task_id) - const subByParent = {}; + // 🚀 构建真实的全局 childMap(按 parent_task_id) + const childMap = {}; allTasks.forEach(t => { - if (t.parent_task_id) { - if (!subByParent[t.parent_task_id]) subByParent[t.parent_task_id] = []; - subByParent[t.parent_task_id].push(t); - } + const pid = t.parent_task_id || ''; + if (!childMap[pid]) childMap[pid] = []; + childMap[pid].push(t); }); - // 每个 parent 下按时间排序 - Object.values(subByParent).forEach(arr => arr.sort((a, b) => new Date(a.created_at) - new Date(b.created_at))); + Object.values(childMap).forEach(arr => arr.sort((a, b) => new Date(a.created_at) - new Date(b.created_at))); // 主管分类 const mains = []; @@ -135,67 +123,46 @@ export default { const nodes = []; - // 🚀 递归挂载:从父节点向外延伸 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 placeChildren = (parentNode, currentSide) => { + const children = childMap[parentNode.id] || []; + children.forEach((child, index) => { + // 主干的第一层协助分支:均衡分发左右 + let side = currentSide; + if (parentNode._isMain) { + side = index % 2 === 0 ? 'right' : 'left'; } + // 🚀 核心:永远基于真实父亲 (parentNode) 的坐标向外延伸! + const childX = side === 'right' + ? parentNode.x + CARD_W + GAP_X + : parentNode.x - CARD_W - GAP_X; + + // 同级多子节点略微错开 Y 轴防重叠 + const childY = parentNode.y + (index * (CARD_H + 20)); + const childNode = { - ...c, - x: childX, y: rootY, + ...child, + x: childX, y: childY, _isMain: false, _side: side, - _blabel: branchLabelMap[c.id] || '分支', - _rootMainY: rootY, - _rootMainId: parentIsMain ? parentNode.id : parentNode._rootMainId, + _blabel: branchLabelMap[child.id] || '分支', + _rootMainId: parentNode._isMain ? parentNode.id : parentNode._rootMainId, }; nodes.push(childNode); flatMap[childNode.id] = childNode; - // 递归挂载孙子节点 - placeChildren(childNode, rootY); + // 带着当前的方向(side)继续递归,保证孙子永远顺着儿子的方向长 + placeChildren(childNode, side); }); }; - // 遍历主干 + // 🚀 渲染入口 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); - }); - - // 遗留分支(父任务不在任何已渲染节点中) - 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; - } + placeChildren(mainNode, null); }); // 全局偏移 diff --git a/track-uniapp/src/pages/scan/detail.vue b/track-uniapp/src/pages/scan/detail.vue index f191945..b6e174e 100644 --- a/track-uniapp/src/pages/scan/detail.vue +++ b/track-uniapp/src/pages/scan/detail.vue @@ -6,6 +6,7 @@ + + + + 💬 + {{ msgUnreadCount }} + + + + + + + 💬 协同留言板 + + + {{ formatUserAvatar(msg.operator_id) }} + + + {{ formatUserName(msg.operator_id) }} + {{ fmtMsgTime(msg.created_at) }} + + {{ msg.content }} + + + + + + + 发送 + + + @@ -214,8 +226,10 @@ export default { messages: [], // 🚀 字典版本号:驱动子组件强制重建,解决 userNameMap 非响应式问题 dictVersion: 0, + showMsgDrawer: false, newMsgText: '', bottomMsgId: '', + lastMsgSeenAt: '', }; }, computed: { @@ -229,6 +243,7 @@ export default { const find = (tasks) => { if (!tasks) return false; for (const t of tasks) { if ((t.status === 'WIP' || t.status === 'PENDING') && (t.assignee_id == this.currentUserId || t.assignee_id == this.currentUsername)) return true; if (find(t.child_tasks)) return true; } return false; }; return this.product ? find(this.product.task_tree) : false; }, + msgUnreadCount() { if (!this.lastMsgSeenAt) return this.messages.length; return this.messages.filter(m => m.created_at > this.lastMsgSeenAt).length; }, }, onLoad(options) { this.loadUsers(); this.loadCurrentUser(); const sn = options.serial || ""; if (sn) this.doQuery(sn); }, methods: { @@ -307,6 +322,8 @@ export default { // 💬 留言板 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); } }, + openMsgDrawer() { this.showMsgDrawer = true; this.$nextTick(() => { this.scrollToBottom(); }); }, + closeMsgDrawer() { const last = this.messages[this.messages.length - 1]; this.lastMsgSeenAt = last ? last.created_at : new Date().toISOString(); this.showMsgDrawer = false; }, 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())}`; }, }, @@ -367,8 +384,15 @@ export default { .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; } +/* 💬 留言悬浮按钮 */ +.msg-fab { position: fixed; right: 20px; bottom: 100px; z-index: 99; width: 50px; height: 50px; border-radius: 25px; background: #3b82f6; color: #fff; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 12px rgba(59,130,246,0.4); } +.msg-fab-icon { font-size: 22px; } +.msg-fab-badge { position: absolute; top: -4px; right: -4px; min-width: 18px; height: 18px; border-radius: 9px; background: #ef4444; color: #fff; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; padding: 0 5px; } + +/* 💬 留言板底部抽屉 */ +.msg-drawer-overlay { position: fixed; inset: 0; z-index: 200; background: rgba(0,0,0,0.45); display: flex; align-items: flex-end; justify-content: center; } +.message-board-drawer { height: 65vh; display: flex; flex-direction: column; background: #fff; border-radius: 16px 16px 0 0; width: 100%; max-width: 480px; } +.mb-drawer-handle { width: 40px; height: 4px; border-radius: 2px; background: #d1d5db; margin: 8px auto; flex-shrink: 0; } .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; }