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

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