Files
track/track-uniapp/src/pages/scan/components/WorkspaceArea.vue

253 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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">
<!-- 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">→ {{ 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.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">⬆ 上游工序</text>
<text class="fc-link-name">{{ parentTaskOf(lockedTask).task_name }} → {{ 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 }} → {{ 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>
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" };
export default {
name: "WorkspaceArea",
props: {
product: { type: Object, default: null },
currentUserId: { type: String, default: "" },
currentUsername: { type: String, default: "" },
},
emits: ["action", "viewRecords"],
data() {
return { lockedTaskId: null };
},
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 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 = !a.parent_task_id || a.task_type !== 'SPAWN';
const bIsMain = !b.parent_task_id || b.task_type !== 'SPAWN';
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; },
isAssignee() { if (!this.lockedTask) return false; return this.lockedTask.assignee_id == this.currentUserId || this.lockedTask.assignee_id == this.currentUsername; },
canRecall() {
if (!this.lockedTask || this.lockedTask.status !== 'PENDING') return false;
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;
},
branchLabelsMap() {
const map = {};
if (!this.product || !this.product.task_tree) return map;
const traverse = (tasks, prefix) => {
if (!tasks) return;
let spawnIndex = 0;
tasks.forEach((t) => {
if (t.task_type !== 'SPAWN') {
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: {
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 { flex: 1; display: flex; flex-direction: column; min-height: 0; 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 { flex: 1; overflow-y: auto; min-height: 0; }
.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; }
.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>