Files
track/track-uniapp/src/pages/scan/components/WorkspaceArea.vue
duxingchen e194b97649 fix: 全状态对齐 - 已出库(OUTBOUND)覆盖所有看板
- 看板产品流转 finished_cond 加'已出库',已出库归入已完结
- WIP 矩阵区分'已出库'维度
- MyTasksPage tab 加'已出库'
- 移动端 detail/WorkspaceArea STATUS_MAP 补 OUTBOUND
2026-09-02 09:07:37 +08:00

312 lines
17 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="workspace-area" :class="{ 'is-locked': !!lockedTaskId }">
<!-- 0任务 -->
<view v-if="!product.task_tree || !product.task_tree.length" class="zero-task">
<text class="zero-icon">📋</text>
<text class="zero-text">该产品暂无流转任务</text>
</view>
<!-- 无本人任务 -->
<view v-else-if="!focusTasks.length" class="zero-task">
<text class="zero-icon">🔒</text>
<text class="zero-text">当前没有需要你处理的任务</text>
<text class="task-count"> {{ countTasks(product.task_tree) }} 个任务</text>
</view>
<!-- 任务列表未锁定 -->
<template v-else-if="!lockedTaskId">
<view class="list-header">
<text class="list-title">📋 待处理任务 ({{ focusTasks.length }})</text>
<text class="list-hint">点击任务进入绝对锁定工作区</text>
</view>
<view class="task-list">
<view v-for="t in focusTasks" :key="t.id" class="task-list-item"
:class="statusColor(t.status)" @tap="lockTask(t.id)">
<view class="tli-left">
<text class="tag-branch">{{ branchLabelsMap[t.id] || '' }}</text>
<text class="tli-name">{{ t.task_name }}</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">
<text :class="['tli-badge', statusColor(t.status)]">{{ statusLabel(t.status) }}</text>
<text v-if="t.is_rework" class="tag tag-rework-sm" style="margin-top:2px;">返工</text>
<text class="tli-arrow"></text>
</view>
</view>
</view>
</template>
<!-- 锁定工作区单任务详情 -->
<template v-else>
<view class="lock-bar">
<text class="lock-back" @tap="lockedTaskId = null"> 返回列表</text>
<text class="lock-title">🔒 沉浸工作区</text>
</view>
<view class="focus-card" :class="statusColor(lockedTask.status)">
<view class="fc-header">
<text class="tag-branch">{{ branchLabelsMap[lockedTask.id] || '' }}</text>
<text class="fc-status">{{ statusLabel(lockedTask.status) }}</text>
<text v-if="lockedTask.is_rework" class="tag tag-rework-sm">返工</text>
<text v-if="lockedTask.parent_task_id && lockedTask.task_type === 'SPAWN' && lockedTask.status === 'WIP'" class="sub-branch-end"
@tap="$emit('action', { task: lockedTask, type: 'end' })">🛑 结束协助</text>
</view>
<text class="fc-name">{{ lockedTask.task_name }}</text>
<view v-if="getTaskRemark(lockedTask)" style="margin-top:20rpx;padding:20rpx;background:#FFFBE8;border-left:8rpx solid #FADB14;border-radius:12rpx;">
<text style="font-size:26rpx;color:#8C6A00;font-weight:bold;">📌 初始/交接备注</text>
<view style="font-size:28rpx;color:#333;margin-top:10rpx;">{{ getTaskRemark(lockedTask) }}</view>
</view>
<view v-if="lockedTask.parent_task_id && parentTaskOf(lockedTask)" class="fc-link fc-up">
<text class="fc-link-label">{{ isNestedSpawn ? '⬆ 上游协助 (嵌套)' : '⬆ 上游工序' }}</text>
<text class="fc-link-name">{{ parentTaskOf(lockedTask).task_name }} {{ formatUserName(lockedTask.created_by) || 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 }} {{ 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>
</view>
<view v-if="lockedTask.records && lockedTask.records.length" class="fc-records-bar" @tap="$emit('viewRecords', lockedTask)">
📋 {{ lockedTask.records.length }} 条干活记录
</view>
<view class="fc-time">{{ formatTaskTime(lockedTask) }}</view>
</view>
<view v-if="isAdminProxy" class="admin-proxy-notice">
<text class="proxy-text"> 正在以管理员身份代 [{{ formatUserName(lockedTask.assignee_id) || lockedTask.assignee_id }}] 操作</text>
</view>
<view v-if="isAssignee" class="footer-actions">
<button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-transfer"
@tap="$emit('action', { task: lockedTask, type: 'transfer' })"><text class="btn-icon">🔄</text><text class="btn-txt">完工转交</text></button>
<button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-record"
@tap="$emit('action', { task: lockedTask, type: 'record' })"><text class="btn-icon">📝</text><text class="btn-txt">记录拍照</text></button>
<button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-spawn"
@tap="$emit('action', { task: lockedTask, type: 'spawn' })"><text class="btn-icon"></text><text class="btn-txt">派发协助</text></button>
<button v-if="lockedTask.status === 'PENDING'" class="footer-btn footer-receive"
@tap="$emit('action', { task: lockedTask, type: 'receive' })"><text class="btn-icon"></text><text class="btn-txt">接收任务</text></button>
<button v-if="lockedTask.status === 'PENDING'" class="footer-btn footer-reject"
@tap="$emit('action', { task: lockedTask, type: 'reject' })"><text class="btn-icon"></text><text class="btn-txt">驳回任务</text></button>
</view>
<view v-else-if="lockedTask.status === 'PENDING' && canRecall" class="footer-actions">
<button class="footer-btn footer-recall"
@tap="$emit('action', { task: lockedTask, type: 'recall' })"><text class="btn-icon">🔄</text><text class="btn-txt">撤回转交</text></button>
</view>
<view v-else class="footer-actions footer-readonly">
<text class="readonly-hint">🔒 非当前任务指派人仅可查看</text>
</view>
</template>
</view>
</template>
<script>
import { formatUserName } from "../../../utils/format";
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", OUTBOUND: "已出库", CANCELED: "已撤回" };
export default {
name: "WorkspaceArea",
props: {
product: { type: Object, default: null },
currentUserId: { type: String, default: "" },
currentUsername: { type: String, default: "" },
currentUserRole: { type: String, default: "" },
initialLockTaskId: { type: String, default: "" },
},
emits: ["action", "viewRecords"],
data() {
return { lockedTaskId: null };
},
watch: {
initialLockTaskId: {
immediate: true,
handler(id) {
if (id && this.focusTasks.some(t => t.id === id)) {
this.lockedTaskId = id;
}
},
},
product: {
handler() {
if (this.initialLockTaskId && this.focusTasks.some(t => t.id === this.initialLockTaskId)) {
this.lockedTaskId = this.initialLockTaskId;
}
},
},
},
computed: {
taskMap() {
const m = {};
const walk = (tasks) => { if (!tasks) return; for (const t of tasks) { m[t.id] = t; walk(t.child_tasks); } };
if (this.product) walk(this.product.task_tree);
return m;
},
focusTasks() {
const result = [];
const isMainFn = (t) => !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY' || t.task_type === 'WAREHOUSE';
const walk = (tasks) => {
if (!tasks) return;
for (const t of tasks) {
if (t.status === 'WIP' || t.status === 'PENDING') result.push(t);
walk(t.child_tasks);
}
};
if (this.product) walk(this.product.task_tree);
result.sort((a, b) => {
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);
});
return result;
},
lockedTask() { return this.focusTasks.find(t => t.id === this.lockedTaskId) || null; },
isAdmin() { return this.currentUserRole === 'SUPER_ADMIN' || this.currentUserRole === 'SUPERVISOR'; },
isAssignee() {
if (!this.lockedTask) return false;
if (this.isAdmin) return true; // 管理员上帝视角
return this.lockedTask.assignee_id == this.currentUserId
|| this.lockedTask.assignee_id == this.currentUsername;
},
isAdminProxy() {
// 管理员正在代操作非本人任务
return this.isAdmin && this.lockedTask
&& this.lockedTask.assignee_id != this.currentUserId
&& this.lockedTask.assignee_id != this.currentUsername;
},
canRecall() {
if (!this.lockedTask || this.lockedTask.status !== 'PENDING') return false;
if (this.isAdmin) return true; // 管理员可撤回任何转交
if (this.isAssignee) return false;
if (!this.lockedTask.parent_task_id) return false;
const parent = this.taskMap[this.lockedTask.parent_task_id];
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;
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' || t.task_type === 'WAREHOUSE';
const traverse = (tasks, prefix) => {
if (!tasks) return;
let spawnIndex = 0;
tasks.forEach((t) => {
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);
}
});
};
this.product.task_tree.forEach((t) => {
map[t.id] = '主分支';
traverse(t.child_tasks, '');
});
return map;
},
},
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; },
getTaskRemark(t) { if (!t) return ""; if (t.remark) return t.remark; if (t.records && t.records.length > 0) { const recs = [...t.records].reverse(); const rec = recs.find(r => r.remark && r.remark.length > 0); if (rec) return rec.remark; } return ""; },
lockTask(taskId) { this.lockedTaskId = taskId; },
parentTaskOf(t) { return t && t.parent_task_id ? (this.taskMap[t.parent_task_id] || null) : null; },
formatTaskTime(t) { if (!t) return ""; const parts = []; if (t.created_at) parts.push("创建: " + this.formatTime(t.created_at)); if (t.received_at) parts.push("接收: " + this.formatTime(t.received_at)); return parts.join(" | "); },
formatTime(d) { if (!d) return ""; const dt = new Date(d); const pad = (n) => String(n).padStart(2, "0"); return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; },
},
};
</script>
<style scoped>
.workspace-area { height: auto; display: flex; flex-direction: column; }
.workspace-area.is-locked { height: 100vh; overflow: hidden; }
.list-header { display: flex; align-items: baseline; justify-content: space-between; padding: 8px 4px; flex-shrink: 0; }
.list-title { font-size: 14px; font-weight: 700; color: #1f2937; }
.list-hint { font-size: 11px; color: #9ca3af; }
.task-list { height: auto; }
.task-list-item { display: flex; align-items: flex-start; justify-content: space-between; background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); border-left: 4px solid transparent; }
.task-list-item.s-yellow { border-left-color: #f59e0b; }
.task-list-item.s-blue { border-left-color: #3b82f6; }
.tli-left { flex: 1; min-width: 0; }
.tag-branch { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: #ede9fe; color: #7c3aed; font-weight: 700; display: inline-block; width: max-content; margin-bottom: 4px; }
.tli-name { font-size: 15px; font-weight: 700; color: #1f2937; display: block; }
.tli-assignee { font-size: 11px; color: #9ca3af; display: block; margin-top: 2px; }
.tli-remark { font-size: 12px; color: #a16207; font-weight: bold; margin-top: 4px; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tli-right { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; flex-shrink: 0; margin-left: 8px; }
.tli-badge { font-size: 11px; padding: 2px 10px; border-radius: 12px; font-weight: 600; }
.tli-badge.s-yellow { background: #fef3c7; color: #b45309; }
.tli-badge.s-blue { background: #dbeafe; color: #1d4ed8; }
.tli-arrow { font-size: 18px; color: #d1d5db; }
.lock-bar { display: flex; align-items: center; gap: 12px; padding: 8px 0; flex-shrink: 0; }
.lock-back { font-size: 13px; color: #2563eb; font-weight: 600; }
.lock-title { font-size: 14px; font-weight: 700; color: #1f2937; }
.branch-done { font-size: 11px; font-weight: 600; color: #16a34a; }
.task-count { font-size: 12px; color: #9ca3af; display: block; margin-top: 4px; }
.focus-card { height: 100%; margin: 0 4px; padding: 20px 16px; border-radius: 16px; background: #fff; box-shadow: 0 2px 12px rgba(0,0,0,0.08); overflow-y: auto; border-top: 5px solid #3b82f6; }
.focus-card.s-yellow { border-top-color: #f59e0b; }
.focus-card.s-blue { border-top-color: #3b82f6; }
.focus-card.s-green { border-top-color: #22c55e; }
.focus-card.s-red { border-top-color: #ef4444; }
.fc-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.fc-status { font-size: 12px; padding: 2px 10px; border-radius: 12px; font-weight: 700; background: #f3f4f6; color: #6b7280; }
.s-yellow .fc-status { background: #fef3c7; color: #b45309; }
.s-blue .fc-status { background: #dbeafe; color: #1d4ed8; }
.tag-rework-sm { font-size: 10px; padding: 2px 6px; border-radius: 8px; background: #ef4444; color: #fff; }
.sub-branch-end { font-size: 11px; color: #ef4444; font-weight: 600; margin-left: auto; padding: 2px 6px; }
.fc-name { font-size: 22px; font-weight: 800; color: #1f2937; display: block; margin-bottom: 10px; }
.fc-link { padding: 6px 10px; border-radius: 8px; margin-bottom: 4px; font-size: 12px; margin-top: 10px; }
.fc-up { background: #f0fdf4; color: #16a34a; }
.fc-down { background: #eff6ff; color: #2563eb; }
.fc-link-label { font-weight: 700; display: block; }
.fc-link-name { display: block; margin-top: 2px; }
.fc-records-bar { padding: 8px 12px; background: linear-gradient(135deg,#eff6ff,#dbeafe); border-radius: 8px; font-size: 13px; font-weight: 700; color: #2563eb; margin-top: 10px; margin-bottom: 8px; }
.fc-time { font-size: 11px; color: #9ca3af; margin-top: auto; padding-top: 8px; border-top: 1px solid #f3f4f6; }
.zero-task { display: flex; flex-direction: column; align-items: center; padding: 24px 0; }
.zero-icon { font-size: 40px; margin-bottom: 8px; }
.zero-text { font-size: 14px; color: #9ca3af; }
.footer-actions { display: flex; gap: 12rpx; padding: 16rpx 20rpx; padding-bottom: calc(16rpx + env(safe-area-inset-bottom)); background: #ffffff; box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.05); flex-shrink: 0; }
.footer-btn { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100rpx; border: none; border-radius: 16rpx; line-height: 1.2; padding: 0; }
.footer-btn::after { border: none; }
.btn-icon { font-size: 36rpx; margin-bottom: 6rpx; }
.btn-txt { font-size: 24rpx; font-weight: 700; }
.footer-readonly { justify-content: center; background: #fef2f2; }
.admin-proxy-notice { padding: 10rpx 20rpx; background: #fef9e7; border-top: 2rpx solid #fde68a; flex-shrink: 0; }
.proxy-text { font-size: 22rpx; color: #b45309; font-weight: 600; }
.readonly-hint { font-size: 24rpx; color: #dc2626; font-weight: 600; }
.footer-transfer { background: #dcfce7; color: #16a34a; }
.footer-record { background: #eff6ff; color: #2563eb; }
.footer-receive { background: #dbeafe; color: #1d4ed8; }
.footer-reject { background: #fce4ec; color: #dc2626; }
.footer-end { background: #fef3c7; color: #b45309; }
.footer-spawn { background: #ede9fe; color: #7c3aed; }
.footer-recall { background: #fef2f2; color: #dc2626; }
</style>