组件化拆分: TreeCanvas(2D画布) + WorkspaceArea(任务列表+锁定工作区) + detail瘦身(仅弹窗)
This commit is contained in:
101
track-uniapp/src/pages/scan/components/TreeCanvas.vue
Normal file
101
track-uniapp/src/pages/scan/components/TreeCanvas.vue
Normal file
@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<view class="tree-area">
|
||||
<view v-if="!product.task_tree || !product.task_tree.length" class="zero-task">
|
||||
<text class="zero-icon">📋</text>
|
||||
<text class="zero-text">该产品暂无流转任务</text>
|
||||
</view>
|
||||
<template v-else>
|
||||
<view class="canvas-hint">🖐 2D 蓝图画布:支持随意拖拽探索分支走向</view>
|
||||
<movable-area class="tree-movable-area">
|
||||
<movable-view class="tree-movable-view" direction="all" :x="0" :y="0"
|
||||
:scale="true" :scale-min="0.5" :scale-max="2"
|
||||
:style="{ width: Math.max(canvasWidth, 1200) + 'px', height: Math.max(canvasHeight, 1200) + 'px' }">
|
||||
<view class="canvas-inner">
|
||||
<view v-for="line in treeLines" :key="line.key" class="canvas-line" :style="lineStyle(line)"></view>
|
||||
<view v-for="node in treeNodes" :key="node.id" class="canvas-node" :class="statusColor(node.status)"
|
||||
:style="{ left: node.x + 'px', top: node.y + 'px' }"
|
||||
@tap="node.records && node.records.length && $emit('viewRecords', node)">
|
||||
<view class="cn-header">
|
||||
<text class="cn-name">{{ node.task_name }}</text>
|
||||
<text :class="['cn-badge', statusColor(node.status)]">{{ statusLabel(node.status) }}</text>
|
||||
</view>
|
||||
<text class="cn-assignee">负责人: {{ node.assignee_id || '—' }}</text>
|
||||
<text v-if="getTaskRemark(node)" class="cn-remark">📌 {{ getTaskRemark(node) }}</text>
|
||||
<view class="cn-footer">
|
||||
<text v-if="node.status==='COMPLETED' && (!node.child_tasks || !node.child_tasks.length)" class="cn-end">🏁 终止</text>
|
||||
<text v-if="node.status==='ARCHIVED'" class="cn-end cn-end-wh">📦 入库</text>
|
||||
<text v-if="node.records && node.records.length" class="cn-records">📋 {{ node.records.length }}记录 ›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</movable-view>
|
||||
</movable-area>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库" };
|
||||
|
||||
export default {
|
||||
name: "TreeCanvas",
|
||||
props: { product: { type: Object, default: null } },
|
||||
emits: ["viewRecords"],
|
||||
computed: {
|
||||
treeNodes() {
|
||||
const nodes = []; const CARD_W = 200, CARD_H = 140, GAP_X = 60, GAP_Y = 60;
|
||||
const layout = (tasks, depth, startX) => {
|
||||
if (!tasks || !tasks.length) return startX;
|
||||
const y = 20 + depth * (CARD_H + GAP_Y); let x = startX; const childXs = [];
|
||||
for (const t of tasks) {
|
||||
nodes.push({ ...t, x, y }); const childStartX = x;
|
||||
if (t.child_tasks && t.child_tasks.length) { x = layout(t.child_tasks, depth + 1, x); } else { x += CARD_W + GAP_X; }
|
||||
childXs.push({ id: t.id, cx: childStartX + CARD_W / 2, cy: y + CARD_H, children: t.child_tasks || [] });
|
||||
}
|
||||
nodes._lines = nodes._lines || [];
|
||||
for (const cx of childXs) { if (cx.children.length) { const midX = cx.cx; for (const c of cx.children) { const childNode = nodes.find(n => n.id === c.id); if (childNode) { nodes._lines.push({ key: cx.id + '-' + c.id, x1: midX, y1: cx.cy, x2: childNode.x + CARD_W / 2, y2: childNode.y }); } } } }
|
||||
return x;
|
||||
};
|
||||
if (this.product && this.product.task_tree) { nodes._lines = []; layout(this.product.task_tree, 0, 40); }
|
||||
return nodes;
|
||||
},
|
||||
treeLines() { return this.treeNodes._lines || []; },
|
||||
canvasWidth() { const nodes = this.treeNodes; if (!nodes.length) return 400; return Math.max(400, Math.max(...nodes.map(n => n.x)) + 300); },
|
||||
canvasHeight() { const nodes = this.treeNodes; if (!nodes.length) return 400; return Math.max(400, Math.max(...nodes.map(n => n.y)) + 300); },
|
||||
},
|
||||
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"; } },
|
||||
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 ""; },
|
||||
lineStyle(line) { const dx = line.x2 - line.x1; const dy = line.y2 - line.y1; const len = Math.sqrt(dx * dx + dy * dy); const angle = Math.atan2(dy, dx) * 180 / Math.PI; return { left: line.x1 + 'px', top: line.y1 + 'px', width: len + 'px', transform: `rotate(${angle}deg)`, transformOrigin: '0 0' }; },
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tree-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; margin-top: 4px; }
|
||||
.canvas-hint { font-size: 11px; color: #9ca3af; text-align: center; padding: 4px 0; flex-shrink: 0; }
|
||||
.tree-movable-area { flex: 1; width: 100%; background-color: #ebedf0; border-radius: 12px; overflow: hidden; box-shadow: inset 0 0 20px rgba(0,0,0,0.05); }
|
||||
.canvas-inner { position: absolute; left: 0; top: 0; }
|
||||
.canvas-node { position: absolute; width: 180px; min-height: 100px; background: #ffffff; border-radius: 12px; padding: 14px; box-shadow: 0 4px 15px rgba(0,0,0,0.08); border-left: 6px solid #3b82f6; border-top: none; z-index: 10; display: flex; flex-direction: column; gap: 6px; }
|
||||
.canvas-node.s-yellow { border-left-color: #f59e0b; }
|
||||
.canvas-node.s-blue { border-left-color: #3b82f6; }
|
||||
.canvas-node.s-green { border-left-color: #22c55e; }
|
||||
.canvas-node.s-red { border-left-color: #ef4444; }
|
||||
.cn-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.cn-name { font-size: 14px; font-weight: 800; color: #1f2937; }
|
||||
.cn-badge { font-size: 10px; padding: 2px 6px; border-radius: 8px; font-weight: 600; }
|
||||
.cn-badge.s-yellow { background: #fef3c7; color: #b45309; }
|
||||
.cn-badge.s-blue { background: #dbeafe; color: #1d4ed8; }
|
||||
.cn-badge.s-green { background: #dcfce7; color: #15803d; }
|
||||
.cn-assignee { font-size: 11px; color: #6b7280; }
|
||||
.cn-remark { font-size: 11px; color: #a16207; background: #fefce8; padding: 4px 8px; border-radius: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cn-footer { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 6px; border-top: 1px solid #f3f4f6; }
|
||||
.cn-end { font-size: 10px; font-weight: 600; color: #16a34a; }
|
||||
.cn-end-wh { color: #7c3aed; }
|
||||
.cn-records { font-size: 11px; color: #2563eb; font-weight: 600; }
|
||||
.canvas-line { position: absolute; height: 3px; background: #94a3b8; z-index: 1; border-radius: 2px; box-shadow: 0 1px 2px rgba(0,0,0,0.1); }
|
||||
.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; }
|
||||
</style>
|
||||
192
track-uniapp/src/pages/scan/components/WorkspaceArea.vue
Normal file
192
track-uniapp/src/pages/scan/components/WorkspaceArea.vue
Normal file
@ -0,0 +1,192 @@
|
||||
<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="tli-name">{{ t.task_name }}</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="fc-status">{{ statusLabel(lockedTask.status) }}</text>
|
||||
<text v-if="lockedTask.is_rework" class="tag tag-rework-sm">⚠返工</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 class="footer-actions">
|
||||
<button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-transfer"
|
||||
@tap="$emit('action', { task: lockedTask, type: 'transfer' })">🔄 完工转交</button>
|
||||
<button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-record"
|
||||
@tap="$emit('action', { task: lockedTask, type: 'record' })">📝 记录/拍照</button>
|
||||
<button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-end"
|
||||
@tap="$emit('action', { task: lockedTask, type: 'end' })">🏁 结束分支</button>
|
||||
<button v-if="lockedTask.status === 'PENDING'" class="footer-btn footer-receive"
|
||||
@tap="$emit('action', { task: lockedTask, type: 'receive' })">✅ 接收任务</button>
|
||||
<button v-if="lockedTask.status === 'PENDING'" class="footer-btn footer-reject"
|
||||
@tap="$emit('action', { task: lockedTask, type: 'reject' })">❌ 驳回任务</button>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库" };
|
||||
|
||||
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) {
|
||||
const isMine = t.status === 'WIP' || t.status === 'PENDING';
|
||||
if (isMine && (t.assignee_id == this.currentUserId || t.assignee_id == this.currentUsername)) result.push(t);
|
||||
walk(t.child_tasks);
|
||||
}
|
||||
};
|
||||
if (this.product) walk(this.product.task_tree);
|
||||
return result;
|
||||
},
|
||||
lockedTask() { return this.focusTasks.find(t => t.id === this.lockedTaskId) || null; },
|
||||
},
|
||||
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; }
|
||||
.tli-name { font-size: 15px; font-weight: 700; color: #1f2937; display: block; }
|
||||
.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; }
|
||||
.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: 20rpx; padding: 20rpx 30rpx; padding-bottom: calc(20rpx + 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; height: 88rpx; border: none; border-radius: 12rpx; font-size: 30rpx; font-weight: 700; line-height: 88rpx; }
|
||||
.footer-btn::after { border: none; }
|
||||
.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; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user