Files
track/track-uniapp/src/pages/scan/components/TaskTreeNode.vue
duxingchen 547c113b09 fix: 移动端 formatUserName 挂载到 methods,修复流转树渲染报错
- Vue2 Options API 中 import 的函数不暴露给模板,需挂到 methods
- FlowTree/TaskTreeNode 补 formatUserName 方法
2026-09-01 13:59:56 +08:00

247 lines
11 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="tnode">
<view v-if="hasChildren" class="tree-line-vertical" />
<!-- 卡片 -->
<view :class="['tnode-card', statusColor(task.status), { 'is-rework': task.is_rework }]">
<!-- 第一层:任务名称、标签、状态 Badge、负责人 -->
<view class="tnode-body">
<view class="tnode-name-row">
<text v-if="task.is_rework" class="tag tag-rework">⚠返工</text>
<text v-if="task.child_tasks && task.child_tasks.length > 1" class="tag tag-fission">裂变×{{ task.child_tasks.length }}</text>
<text class="tnode-name">{{ task.task_name }}</text>
</view>
<view class="tnode-meta">
<!-- 在库任务不单独显示"负责人",由下方"转入在库"统一展示实际操作人 -->
<text v-if="task.assignee_id && !isWarehouseTask">负责人: {{ formatUserName(task.assignee_id) }}</text>
<text v-if="task.reject_reason" class="reject-reason">驳回: {{ task.reject_reason }}</text>
</view>
<!-- 📥 在库任务:显示实际执行"转入在库"的操作人(任务负责人) -->
<text v-if="isWarehouseTask && task.assignee_id" class="warehouse-in">📥 转入在库: {{ formatUserName(task.assignee_id) }}</text>
<!-- 任务初始描述 / 交接备注(常驻显示,区别于动态日志 records) -->
<view v-if="task.remark || task.description" class="task-initial-remark">
<text class="remark-label">📌 初始说明:</text>
<text class="remark-text">{{ task.remark || task.description }}</text>
</view>
</view>
<!-- 状态标签 + 分支闭环标记 -->
<view style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;margin-left:8px;">
<text :class="['badge', statusColor(task.status)]">{{ statusLabel(task.status) }}</text>
<text v-if="task.status === 'COMPLETED' && (!task.child_tasks || !task.child_tasks.length)" class="end-marker">🏁 已终止</text>
<text v-if="task.status === 'ARCHIVED'" class="end-marker end-warehouse">📦 已入库</text>
</view>
</view>
<!-- 第二层:递归子任务树 -->
<view v-if="task.child_tasks && task.child_tasks.length" class="tnode-children">
<TaskTreeNode
v-for="(child, idx) in task.child_tasks"
:key="child.id"
:task="child"
:currentUser="currentUser"
:currentUserId="currentUserId"
:currentUsername="currentUsername"
:readonly="readonly"
:has-children="!!(child.child_tasks && child.child_tasks.length)"
:isLast="idx === task.child_tasks.length - 1"
@action="(e) => $emit('action', e)"
@editRecord="(e) => $emit('editRecord', e)"
@viewRecords="(t) => $emit('viewRecords', t)"
/>
</view>
<!-- 第三层(最底部):进度记录 — 最新一条 + 查看全部入口 -->
<view v-if="task.records && task.records.length" class="records-area">
<!-- 查看全部记录按钮 -->
<view class="records-bar" @tap.stop="$emit('viewRecords', task)">
<text class="records-bar-icon">📋</text>
<text class="records-bar-text">查看全部记录 ({{ task.records.length }})</text>
<text class="records-bar-arrow">›</text>
</view>
<!-- 最新一条预览 -->
<view v-if="latestRecord" class="record-item">
<view class="record-top">
<text class="record-time">{{ formatTime(latestRecord.created_at) }}</text>
<view v-if="canEditRecord" class="record-actions">
<text class="rec-act" @tap.stop="$emit('editRecord', { task, record: latestRecord })">✏️</text>
<text class="rec-act" @tap.stop="confirmDelete(latestRecord)">🗑️</text>
</view>
</view>
<text v-if="latestRecord.remark" class="record-remark">{{ latestRecord.remark }}</text>
<view v-if="latestRecord.images && latestRecord.images.length" class="record-images">
<image
v-for="(img, i) in latestRecord.images"
:key="i"
:src="imageUrl(img)"
class="record-thumb"
mode="aspectFill"
@tap.stop="previewImage(latestRecord.images, i)"
/>
</view>
</view>
</view>
</view>
</template>
<script>
import { getBaseUrl } from "../../../utils/request";
import { formatUserName as fmtUserName } from "../../../utils/format";
export default {
name: "TaskTreeNode",
props: {
task: { type: Object, required: true },
isLast: { type: Boolean, default: false },
hasChildren: { type: Boolean, default: false },
currentUser: { type: Object, default: null },
currentUserId: { type: [String, Number], default: "" },
currentUsername: { type: String, default: "" },
readonly: { type: Boolean, default: false },
},
emits: ["action", "editRecord", "viewRecords"],
mounted() {
console.log("TaskTreeNode task keys:", Object.keys(this.task));
console.log("task.remark =", this.task.remark);
console.log("task.description =", this.task.description);
},
computed: {
isWarehouseTask() {
return !!(this.task && (
String(this.task.task_name || '').includes('在库')
|| String(this.task.task_name || '').includes('入库')
|| this.task.assignee_id === 'virtual_warehouse'
));
},
canEditRecord() {
if (this.readonly) return false;
if (this.task.assignee_id == this.currentUserId) return true;
if (this.task.assignee_id == this.currentUsername) return true;
if (this.currentUser && this.currentUser.id == this.task.assignee_id) return true;
if (this.currentUser && this.currentUser.username == this.task.assignee_id) return true;
return false;
},
latestRecord() {
const recs = this.task.records;
if (!recs || !recs.length) return null;
return [...recs].sort((a, b) => new Date(b.created_at) - new Date(a.created_at))[0];
},
},
methods: {
imageUrl(url) {
if (!url) return "";
if (url.startsWith("http")) return url;
// 后端返回 /api/v1/upload/files/xxx.jpg,需要补全域名
const domain = getBaseUrl().replace(/\/api.*$/, '');
return domain + (url.startsWith("/") ? url : "/" + url);
},
previewImage(urls, index) {
const fullUrls = (urls || []).map(img => this.imageUrl(img));
uni.previewImage({ urls: fullUrls, current: index });
},
confirmDelete(rec) {
// 🚀 交给父页面统一做「双重确认倒计时」,避免原生 showModal 取消行为异常
this.$emit("action", { task: this.task, type: "deleteRecord", record: rec });
},
statusLabel(s) {
const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库" };
return map[s] || s;
},
// Vue2 Options API 中 import 的函数需挂到 methods 才能被模板访问
formatUserName(userId) {
return fmtUserName(userId);
},
statusColor(s) {
switch (s) {
case "PENDING": return "s-yellow";
case "WIP": return "s-blue";
case "COMPLETED": return "s-green";
case "REJECTED": return "s-red";
case "OUTBOUND": return "s-outbound";
default: return "s-gray";
}
},
formatTime(t) {
if (!t) return "";
const d = new Date(t);
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
},
},
};
</script>
<style scoped>
.tnode { position: relative; padding-left: 12px; margin-bottom: 6px; }
.tree-line-vertical { position: absolute; left: 8px; top: 28px; bottom: 0; width: 2px; background: #e5e7eb; }
.tnode-card {
display: flex; align-items: flex-start; justify-content: space-between;
padding: 10px 12px; border-radius: 10px 10px 0 0;
background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,0.04);
border-left: 3px solid transparent;
}
.is-rework { border-left-color: #ef4444 !important; }
.s-yellow { border-left-color: #f59e0b; }
.s-blue { border-left-color: #3b82f6; }
.s-green { border-left-color: #22c55e; }
.s-red { border-left-color: #ef4444; }
.s-outbound { border-left-color: #4f46e5; }
.tnode-body { flex: 1; min-width: 0; }
.tnode-name-row { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.tnode-name { font-size: 14px; font-weight: 700; color: #1f2937; }
.tag { font-size: 10px; padding: 1px 5px; border-radius: 6px; font-weight: 700; color: #fff; }
.tag-rework { background: #ef4444; }
.tag-fission { background: #7c3aed; }
.tnode-meta { margin-top: 2px; font-size: 11px; color: #9ca3af; display: flex; gap: 8px; flex-wrap: wrap; }
.reject-reason { color: #ef4444; }
.warehouse-in { display: block; margin-top: 4px; font-size: 11px; color: #059669; font-weight: 700; }
/* 任务初始描述(常驻,区别于动态记录) */
.task-initial-remark {
margin-top: 10rpx; padding: 12rpx 16rpx;
background: linear-gradient(135deg, #fefce8, #fef9c3);
border-left: 6rpx solid #eab308; border-radius: 8rpx;
}
.remark-label { font-size: 22rpx; font-weight: 700; color: #a16207; }
.remark-text { font-size: 26rpx; color: #713f12; line-height: 1.5; word-break: break-all; }
/* 进度记录 */
.records-area { background-color: #f9f9f9; padding: 0; border-radius: 10rpx; margin-top: 16rpx; margin-bottom: 6rpx; overflow: hidden; }
.records-bar {
display: flex; align-items: center; gap: 8rpx;
padding: 16rpx 20rpx;
background: linear-gradient(135deg, #eff6ff, #dbeafe);
border-bottom: 1px solid #bfdbfe;
}
.records-bar-icon { font-size: 28rpx; }
.records-bar-text { flex: 1; font-size: 26rpx; font-weight: 700; color: #2563eb; }
.records-bar-arrow { font-size: 32rpx; color: #93c5fd; font-weight: 700; }
.record-item { padding: 12rpx 16rpx; }
.record-item { padding: 8rpx 0; border-bottom: 1px dashed #e5e7eb; }
.record-item:last-child { border-bottom: none; }
.record-top { display: flex; align-items: center; justify-content: space-between; }
.record-actions { display: flex; gap: 12rpx; }
.rec-act { font-size: 28rpx; padding: 4rpx; }
.record-remark { font-size: 26rpx; color: #333; display: block; margin: 6rpx 0; line-height: 1.5; word-break: break-all; }
.record-images { display: flex; gap: 8rpx; margin-top: 8rpx; flex-wrap: wrap; }
.record-thumb { width: 100rpx; height: 100rpx; border-radius: 8rpx; border: 1px solid #e5e7eb; background: #f3f4f6; }
.record-time { font-size: 22rpx; color: #9ca3af; }
.badge {
font-size: 10px; padding: 2px 8px; border-radius: 20px; font-weight: 600;
white-space: nowrap; flex-shrink: 0;
background: #f3f4f6; color: #6b7280;
}
.end-marker { font-size: 9px; padding: 1px 6px; border-radius: 8px; font-weight: 600; white-space: nowrap; background: #dcfce7; color: #16a34a; }
.end-warehouse { background: #ede9fe; color: #7c3aed; }
.s-yellow .badge { background: #fef3c7; color: #b45309; }
.s-blue .badge { background: #dbeafe; color: #1d4ed8; }
.s-green .badge { background: #dcfce7; color: #15803d; }
.s-red .badge { background: #fce4ec; color: #be123c; }
.s-outbound .badge { background: #e0e7ff; color: #4338ca; }
.tnode-children { position: relative; }
</style>