313 lines
18 KiB
Vue
313 lines
18 KiB
Vue
<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 v-if="isAdminProxy" class="admin-proxy-notice">
|
||
<text class="proxy-text">⚠️ 正在以管理员身份代 [{{ formatUserName(lockedTask.assignee_id) || lockedTask.assignee_id }}] 操作</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="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 { flex: 1; min-height: 0; margin: 0 4px; padding: 20px 16px calc(160rpx + env(safe-area-inset-bottom)); 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 { position: fixed; left: 0; right: 0; bottom: 0; z-index: 50; display: flex; gap: 12rpx; padding: 16rpx 20rpx; padding-bottom: calc(16rpx + env(safe-area-inset-bottom)); background: #ffffff; border-top: 1px solid #e5e7eb; box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.05); }
|
||
.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-bottom: 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>
|