feat: 移动端双Token队列拦截器 + 通知列表 + 登录页 + TabBar红点 + v1.0.1
This commit is contained in:
@ -1,41 +1,59 @@
|
||||
<template>
|
||||
<view class="tree-canvas-container">
|
||||
<view class="tree-canvas-fullscreen">
|
||||
<!-- 顶部控制栏 -->
|
||||
<view class="tc-toolbar">
|
||||
<view class="tc-toolbar-left">
|
||||
<view class="tc-back-btn" @tap="$emit('back')">← 返回</view>
|
||||
<view style="display:flex;flex-direction:column;gap:2px;">
|
||||
<text class="tc-title">🌳 流转蓝图</text>
|
||||
<text class="tc-hint">双指缩放 · 单指拖拽</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="tc-toolbar-right">
|
||||
<view class="tc-zoom-btn tc-mode-btn" @tap="$emit('swipe')">📇 卡片</view>
|
||||
<view class="tc-zoom-btn" @tap="zoomOut">-</view>
|
||||
<text class="tc-zoom-label">{{ Math.round(mvScale * 100) }}%</text>
|
||||
<view class="tc-zoom-btn" @tap="zoomIn">+</view>
|
||||
<view class="tc-zoom-btn tc-fit-btn" @tap="fitAll">⊡ 全览</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<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.3" :scale-max="2"
|
||||
:x="mvX" :y="mvY"
|
||||
:scale="true" :scale-min="0.1" :scale-max="5"
|
||||
:scale-value="mvScale"
|
||||
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
|
||||
@scale="onScale"
|
||||
>
|
||||
<view class="canvas-inner">
|
||||
<!-- 节点卡片层 -->
|
||||
<view v-for="node in treeNodes" :key="node.id"
|
||||
class="canvas-node" :class="statusColor(node.status)"
|
||||
class="canvas-node" :class="[statusColor(node.status), node.status === 'ARCHIVED' ? 'node-archived' : '']"
|
||||
:style="{ left: node.x + 'px', top: node.y + 'px' }"
|
||||
@tap="node.records && node.records.length && $emit('viewRecords', node)">
|
||||
|
||||
<view class="cn-header">
|
||||
<text :class="node.parent_task_id ? 'badge-sub' : 'badge-main'">{{ node.parent_task_id ? branchLabelMap[node.id] || '分支' : '主分支' }}</text>
|
||||
<text :class="['cn-badge', statusColor(node.status)]">{{ statusLabel(node.status) }}</text>
|
||||
<text :class="['cn-badge', statusColor(node.status), node.status === 'ARCHIVED' ? 'cn-badge-archived' : '']">{{ statusLabel(node.status) }}</text>
|
||||
</view>
|
||||
|
||||
<text class="cn-assignee">负责人: {{ node.assignee_id || '—' }}</text>
|
||||
<text v-if="node.is_rework" class="tag-rework">⚠ 返工工序</text>
|
||||
<text v-if="node.status === 'ARCHIVED'" class="tag-archived">📦 已入库</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-else-if="node.status==='ARCHIVED'" class="cn-end cn-end-wh">📦 已入库</text>
|
||||
<text v-else-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>
|
||||
@ -52,7 +70,24 @@ export default {
|
||||
props: {
|
||||
product: { type: Object, required: true }
|
||||
},
|
||||
emits: ["viewRecords"],
|
||||
emits: ["viewRecords", "back", "swipe"],
|
||||
data() {
|
||||
return {
|
||||
mvX: 0,
|
||||
mvY: 0,
|
||||
mvScale: 1,
|
||||
viewportW: 375,
|
||||
viewportH: 600,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
try {
|
||||
const info = uni.getSystemInfoSync();
|
||||
this.viewportW = info.windowWidth || 375;
|
||||
this.viewportH = info.windowHeight || 600;
|
||||
} catch (e) { /* ignore */ }
|
||||
this.$nextTick(() => { this.fitAll(); });
|
||||
},
|
||||
computed: {
|
||||
treeNodes() {
|
||||
if (!this.product || !this.product.task_tree) return [];
|
||||
@ -62,12 +97,23 @@ export default {
|
||||
const rowMaxX = {};
|
||||
let currentMaxYIdx = -1;
|
||||
|
||||
// 🚀 深拷贝:防止 Vue props readonly Proxy 异常导致白屏!
|
||||
const cloneTree = (tasks) => {
|
||||
if (!tasks) return [];
|
||||
return tasks.map(t => ({
|
||||
...t,
|
||||
child_tasks: cloneTree(t.child_tasks),
|
||||
records: t.records ? [...t.records] : []
|
||||
}));
|
||||
};
|
||||
const workTree = cloneTree(this.product.task_tree);
|
||||
|
||||
const flatMap = {};
|
||||
const flatten = (tasks) => {
|
||||
if (!tasks) return;
|
||||
tasks.forEach(t => { flatMap[t.id] = t; flatten(t.child_tasks); });
|
||||
};
|
||||
flatten(this.product.task_tree);
|
||||
flatten(workTree);
|
||||
|
||||
const layoutNode = (t) => {
|
||||
if (t._visited) return;
|
||||
@ -85,7 +131,8 @@ export default {
|
||||
const type = t.task_type || (parent && parent.status === 'CANCELED' ? 'RECOVERY' : 'SPAWN');
|
||||
|
||||
if (type === 'SPAWN') {
|
||||
t._yIdx = parent._yIdx;
|
||||
// 🚀 增加 parent 存在性防御
|
||||
t._yIdx = parent ? parent._yIdx : 0;
|
||||
t._xIdx = (rowMaxX[t._yIdx] !== undefined ? rowMaxX[t._yIdx] : 0) + 1;
|
||||
} else if (type === 'TRANSFER' || type === 'MAIN') {
|
||||
currentMaxYIdx++;
|
||||
@ -94,7 +141,8 @@ export default {
|
||||
} else if (type === 'RECOVERY') {
|
||||
currentMaxYIdx++;
|
||||
t._yIdx = currentMaxYIdx;
|
||||
t._xIdx = parent._xIdx;
|
||||
// 🚀 增加 parent 存在性防御
|
||||
t._xIdx = parent ? parent._xIdx : 0;
|
||||
}
|
||||
}
|
||||
|
||||
@ -104,23 +152,49 @@ export default {
|
||||
nodes.push(t);
|
||||
|
||||
if (t.child_tasks && t.child_tasks.length) {
|
||||
t.child_tasks
|
||||
.sort((a, b) => (a.task_type === 'TRANSFER' ? -1 : 1))
|
||||
// 🚀 先按类型排序,同类型再按创建时间(旧在上、新在下)
|
||||
[...t.child_tasks]
|
||||
.sort((a, b) => {
|
||||
const aTrans = a.task_type === 'TRANSFER' ? -1 : 1;
|
||||
const bTrans = b.task_type === 'TRANSFER' ? -1 : 1;
|
||||
if (aTrans !== bTrans) return aTrans - bTrans;
|
||||
return new Date(a.created_at) - new Date(b.created_at);
|
||||
})
|
||||
.forEach(child => layoutNode(child));
|
||||
}
|
||||
};
|
||||
|
||||
this.product.task_tree.forEach(root => layoutNode(root));
|
||||
workTree.forEach(root => layoutNode(root));
|
||||
return nodes;
|
||||
},
|
||||
branchLabelMap() {
|
||||
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 traverse = (tasks, prefix) => {
|
||||
if (!tasks) return;
|
||||
let spawnIndex = 0;
|
||||
tasks.forEach((t) => {
|
||||
if (t.task_type !== 'SPAWN') {
|
||||
const parent = t.parent_task_id ? flatMap[t.parent_task_id] : null;
|
||||
|
||||
// 🚀 终极防御判定:
|
||||
// 1. 没有父节点(打底任务) -> 主分支
|
||||
// 2. 明确的基因 (TRANSFER/RECOVERY) -> 主分支
|
||||
// 3. 兜底:如果基因丢失,但它的父节点已经完工(COMPLETED),那它必然是接力的转交任务 -> 主分支
|
||||
const isMain = !t.parent_task_id
|
||||
|| t.task_type === 'TRANSFER'
|
||||
|| t.task_type === 'RECOVERY'
|
||||
|| (!t.task_type && parent && (parent.status === 'COMPLETED' || parent.status === 'CANCELED'));
|
||||
|
||||
if (isMain) {
|
||||
map[t.id] = '主分支';
|
||||
traverse(t.child_tasks, prefix);
|
||||
} else {
|
||||
@ -137,10 +211,29 @@ export default {
|
||||
});
|
||||
return map;
|
||||
},
|
||||
canvasWidth() { if (!this.treeNodes.length) return 400; return Math.max(800, Math.max(...this.treeNodes.map(n => n.x)) + 300); },
|
||||
canvasHeight() { if (!this.treeNodes.length) return 400; return Math.max(800, Math.max(...this.treeNodes.map(n => n.y)) + 300); }
|
||||
canvasWidth() { if (!this.treeNodes.length) return 400; return Math.max(1200, Math.max(...this.treeNodes.map(n => n.x)) + 400); },
|
||||
canvasHeight() { if (!this.treeNodes.length) return 400; return Math.max(1600, Math.max(...this.treeNodes.map(n => n.y)) + 400); },
|
||||
fitScale() {
|
||||
if (!this.treeNodes.length) return 1;
|
||||
const vw = this.viewportW || 375;
|
||||
const vh = this.viewportH || 600;
|
||||
const cw = this.canvasWidth;
|
||||
const ch = this.canvasHeight;
|
||||
const sx = (vw - 32) / cw;
|
||||
const sy = (vh - 100) / ch;
|
||||
return Math.max(0.1, Math.min(sx, sy, 1));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onScale(e) { if (e && e.detail && typeof e.detail.scale === 'number') this.mvScale = e.detail.scale; },
|
||||
zoomIn() { this.mvScale = Math.min(5, +(this.mvScale + 0.2).toFixed(1)); },
|
||||
zoomOut() { this.mvScale = Math.max(0.1, +(this.mvScale - 0.2).toFixed(1)); },
|
||||
fitAll() {
|
||||
const s = this.fitScale;
|
||||
this.mvScale = s;
|
||||
this.mvX = 0;
|
||||
this.mvY = 0;
|
||||
},
|
||||
getTaskRemark(t) {
|
||||
if (!t) return "";
|
||||
if (t.remark) return t.remark;
|
||||
@ -157,40 +250,61 @@ export default {
|
||||
return { left: line.x1 + 'px', top: line.y1 + 'px', width: len + 'px', transform: `rotate(${angle}deg)`, transformOrigin: '0 0' };
|
||||
},
|
||||
statusLabel(s) { const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" }; return 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"; case "CANCELED": return "s-canceled"; default: return "s-gray"; } }
|
||||
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 "CANCELED": return "s-canceled"; case "ARCHIVED": return "s-archived"; default: return "s-gray"; } }
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tree-canvas-container { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: #ebedf0; border-radius: 12px; margin-top: 10px; box-shadow: inset 0 0 10px rgba(0,0,0,0.02); }
|
||||
.zero-task { display: flex; flex-direction: column; align-items: center; padding: 40px 0; }
|
||||
.tree-canvas-fullscreen { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 100; display: flex; flex-direction: column; background: #e8ecf0; }
|
||||
|
||||
/* 工具栏 */
|
||||
.tc-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; padding-top: calc(10px + env(safe-area-inset-top)); background: rgba(255,255,255,0.95); backdrop-filter: blur(10px); border-bottom: 1px solid #e5e7eb; flex-shrink: 0; z-index: 10; }
|
||||
.tc-toolbar-left { display: flex; align-items: center; gap: 12px; }
|
||||
.tc-back-btn { font-size: 14px; font-weight: 700; color: #2563eb; padding: 6px 12px; background: #eff6ff; border-radius: 8px; }
|
||||
.tc-title { font-size: 16px; font-weight: 800; color: #1f2937; }
|
||||
.tc-hint { font-size: 10px; color: #9ca3af; }
|
||||
.tc-toolbar-right { display: flex; align-items: center; gap: 8px; }
|
||||
.tc-zoom-btn { width: 36px; height: 36px; border-radius: 10px; background: #f3f4f6; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 700; color: #374151; }
|
||||
.tc-zoom-btn:active { background: #e5e7eb; }
|
||||
.tc-zoom-label { font-size: 12px; font-weight: 600; color: #6b7280; min-width: 42px; text-align: center; }
|
||||
.tc-mode-btn { width: auto; padding: 0 10px; font-size: 12px; background: #ede9fe; color: #7c3aed; }
|
||||
.tc-fit-btn { width: auto; padding: 0 12px; font-size: 13px; background: #dbeafe; color: #2563eb; }
|
||||
|
||||
.zero-task { display: flex; flex-direction: column; align-items: center; padding: 60px 0; }
|
||||
.zero-icon { font-size: 40px; margin-bottom: 10px; }
|
||||
.zero-text { font-size: 14px; color: #9ca3af; }
|
||||
.canvas-hint { font-size: 11px; color: #9ca3af; text-align: center; padding: 8px 0; background: #fff; border-bottom: 1px solid #e5e7eb; flex-shrink: 0; }
|
||||
.tree-movable-area { flex: 1; width: 100%; background-color: #f8fafc; background-image: linear-gradient(#e5e7eb 1px, transparent 1px), linear-gradient(90deg, #e5e7eb 1px, transparent 1px); background-size: 20px 20px; }
|
||||
|
||||
.tree-movable-area { flex: 1; width: 100%; background-color: #f0f2f5; background-image: linear-gradient(#dde1e6 1px, transparent 1px), linear-gradient(90deg, #dde1e6 1px, transparent 1px); background-size: 24px 24px; }
|
||||
.canvas-inner { position: absolute; left: 0; top: 0; }
|
||||
.canvas-node { position: absolute; width: 320px; min-height: 460px; background: #ffffff; border-radius: 20px; padding: 24px; box-shadow: 0 10px 30px rgba(0,0,0,0.10), 0 4px 8px rgba(0,0,0,0.06); border-left: 12px solid #3b82f6; z-index: 10; display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
/* 节点卡片 */
|
||||
.canvas-node { position: absolute; width: 320px; min-height: 460px; background: #ffffff; border-radius: 20px; padding: 24px; box-shadow: 0 10px 30px rgba(0,0,0,0.10), 0 4px 8px rgba(0,0,0,0.06); border-left: 12px solid #3b82f6; z-index: 10; display: flex; flex-direction: column; gap: 10px; transition: opacity 0.2s; }
|
||||
.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; }
|
||||
.canvas-node.s-canceled { border-left-color: #9ca3af; opacity: 0.5; filter: grayscale(0.6); }
|
||||
|
||||
/* 🚀 ARCHIVED 入库卡片专属样式 */
|
||||
.canvas-node.s-archived { border-left-color: #8b5cf6; opacity: 0.72; }
|
||||
.canvas-node.s-archived::after { content: "📦"; position: absolute; right: 20px; bottom: 20px; font-size: 60px; opacity: 0.08; pointer-events: none; }
|
||||
|
||||
.cn-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.badge-main { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: #2563eb; color: #fff; font-weight: 700; }
|
||||
.badge-sub { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: #ede9fe; color: #7c3aed; font-weight: 700; }
|
||||
.cn-name { font-size: 24px; font-weight: 800; color: #1f2937; }
|
||||
.cn-badge { font-size: 14px; padding: 4px 12px; border-radius: 8px; font-weight: 700; }
|
||||
.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-badge.s-red { background: #fce4ec; color: #be123c; }
|
||||
.cn-badge-archived { background: #ede9fe; color: #7c3aed; }
|
||||
.cn-assignee { font-size: 16px; color: #6b7280; font-weight: 500; margin-top: 10px; }
|
||||
.tag-rework { font-size: 13px; color: #fff; background: #ef4444; padding: 4px 10px; border-radius: 6px; display: inline-block; width: max-content; }
|
||||
.tag-archived { font-size: 14px; color: #7c3aed; background: #ede9fe; padding: 6px 12px; border-radius: 8px; display: inline-block; width: max-content; font-weight: 700; border: 2px dashed #a78bfa; }
|
||||
.cn-remark { font-size: 16px; color: #a16207; background: #fefce8; padding: 16px; border-radius: 8px; border: 1px solid #fef08a; line-height: 1.5; word-break: break-all; min-height: 100px; white-space: normal; margin-top: 16px; }
|
||||
.cn-footer { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 14px; border-top: 1px dashed #e5e7eb; }
|
||||
.cn-end { font-size: 13px; font-weight: 700; color: #16a34a; }
|
||||
.cn-end-wh { color: #7c3aed; }
|
||||
.cn-records { font-size: 14px; color: #2563eb; font-weight: 700; background: #eff6ff; padding: 4px 12px; border-radius: 12px; }
|
||||
.canvas-line { position: absolute; height: 3px; background: #94a3b8; z-index: 1; transform-origin: 0 0; }
|
||||
</style>
|
||||
|
||||
@ -49,7 +49,7 @@
|
||||
<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"
|
||||
<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>
|
||||
@ -110,11 +110,29 @@ export default {
|
||||
product: { type: Object, default: null },
|
||||
currentUserId: { type: String, default: "" },
|
||||
currentUsername: { 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 = {};
|
||||
@ -133,8 +151,8 @@ export default {
|
||||
};
|
||||
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';
|
||||
const aIsMain = !a.parent_task_id || a.task_type === 'TRANSFER' || a.task_type === 'RECOVERY';
|
||||
const bIsMain = !b.parent_task_id || b.task_type === 'TRANSFER' || b.task_type === 'RECOVERY';
|
||||
if (aIsMain && !bIsMain) return -1;
|
||||
if (!aIsMain && bIsMain) return 1;
|
||||
return new Date(a.created_at) - new Date(b.created_at);
|
||||
@ -154,11 +172,31 @@ export default {
|
||||
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 traverse = (tasks, prefix) => {
|
||||
if (!tasks) return;
|
||||
let spawnIndex = 0;
|
||||
tasks.forEach((t) => {
|
||||
if (t.task_type !== 'SPAWN') {
|
||||
const parent = t.parent_task_id ? flatMap[t.parent_task_id] : null;
|
||||
|
||||
// 🚀 终极防御判定:
|
||||
// 1. 没有父节点(打底任务) -> 主分支
|
||||
// 2. 明确的基因 (TRANSFER/RECOVERY) -> 主分支
|
||||
// 3. 兜底:如果基因丢失,但它的父节点已经完工(COMPLETED),那它必然是接力的转交任务 -> 主分支
|
||||
const isMain = !t.parent_task_id
|
||||
|| t.task_type === 'TRANSFER'
|
||||
|| t.task_type === 'RECOVERY'
|
||||
|| (!t.task_type && parent && (parent.status === 'COMPLETED' || parent.status === 'CANCELED'));
|
||||
|
||||
if (isMain) {
|
||||
map[t.id] = '主分支';
|
||||
traverse(t.child_tasks, prefix);
|
||||
} else {
|
||||
|
||||
@ -4,45 +4,55 @@
|
||||
<view v-if="error" class="error-box">{{ error }}</view>
|
||||
|
||||
<template v-if="product && !loading">
|
||||
<view class="overall-bar" @tap="handleOverallBarClick">
|
||||
<text class="overall-label">宏观状态</text>
|
||||
<text :class="['overall-val', product.overall_status ? '' : 'overall-empty']">{{ product.overall_status || '未激活 — 点击发起首道工序' }}</text>
|
||||
<text v-if="product.task_tree && product.task_tree.length" class="overall-arrow">▾</text>
|
||||
</view>
|
||||
<!-- 工作区模式:显示产品信息栏 -->
|
||||
<template v-if="currentMode === 'workspace'">
|
||||
<view class="overall-bar" @tap="handleOverallBarClick">
|
||||
<text class="overall-label">宏观状态</text>
|
||||
<text :class="['overall-val', product.overall_status ? '' : 'overall-empty']">{{ product.overall_status || '未激活 — 点击发起首道工序' }}</text>
|
||||
<text v-if="product.task_tree && product.task_tree.length" class="overall-arrow">▾</text>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">📦 产品信息</text>
|
||||
<view class="card-header-right">
|
||||
<text :class="['badge', statusColor(product.status)]">{{ statusLabel(product.status) }}</text>
|
||||
<text class="mode-toggle" @tap="toggleMode">{{ currentMode === 'workspace' ? '🌳 流转树' : '🛠️ 工作区' }}</text>
|
||||
<text class="edit-btn" @tap="openEditProduct">✏️</text>
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">📦 产品信息</text>
|
||||
<view class="card-header-right">
|
||||
<text class="mode-toggle" @tap="toggleMode">{{ modeToggleLabel }}</text>
|
||||
<text class="edit-btn" @tap="openEditProduct">✏️</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-grid">
|
||||
<view class="info-item"><text class="label">身份证</text><text class="value sn">{{ product.serial_number }}</text></view>
|
||||
<view class="info-item" v-if="product.external_serial"><text class="label">产品序列号</text><text class="value sn">{{ product.external_serial }}</text></view>
|
||||
<view class="info-item"><text class="label">物料名称</text><text class="value">{{ product.material_name || product.material_id || '—' }}</text></view>
|
||||
<view class="info-item"><text class="label">规格型号</text><text class="value">{{ product.spec_model || '—' }}</text></view>
|
||||
<view class="info-item"><text class="label">订单编号</text><text class="value">{{ product.order_no || '—' }}</text></view>
|
||||
<view class="info-item" v-if="product.current_location_id">
|
||||
<text class="label">当前位置</text>
|
||||
<text :class="['value', product.current_location_id === 'virtual_warehouse' ? 'warehouse' : '']">{{ product.current_location_id === 'virtual_warehouse' ? '🏭 仓库' : product.current_location_id }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-grid">
|
||||
<view class="info-item"><text class="label">身份证</text><text class="value sn">{{ product.serial_number }}</text></view>
|
||||
<view class="info-item"><text class="label">物料名称</text><text class="value">{{ product.material_name || product.material_id || '—' }}</text></view>
|
||||
<view class="info-item"><text class="label">规格型号</text><text class="value">{{ product.spec_model || '—' }}</text></view>
|
||||
<view class="info-item"><text class="label">订单编号</text><text class="value">{{ product.order_no || '—' }}</text></view>
|
||||
<view class="info-item" v-if="product.current_location_id">
|
||||
<text class="label">当前位置</text>
|
||||
<text :class="['value', product.current_location_id === 'virtual_warehouse' ? 'warehouse' : '']">{{ product.current_location_id === 'virtual_warehouse' ? '🏭 仓库' : product.current_location_id }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="product && product.current_location_id === 'virtual_warehouse' && !hasMyActiveTask"
|
||||
class="warehouse-transfer-banner" @tap="openWarehouseTransfer">
|
||||
<text class="wt-icon">📤</text>
|
||||
<text class="wt-text">该产品在仓库中 — 点击此处转出并派发给指定人员</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 📤 仓库转出横幅(当前用户有活跃任务时隐藏) -->
|
||||
<view v-if="product && product.current_location_id === 'virtual_warehouse' && !hasMyActiveTask"
|
||||
class="warehouse-transfer-banner" @tap="openWarehouseTransfer">
|
||||
<text class="wt-icon">📤</text>
|
||||
<text class="wt-text">该产品在仓库中 — 点击此处转出并派发给指定人员</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 工作区视图 -->
|
||||
<WorkspaceArea v-if="currentMode === 'workspace'" :product="product"
|
||||
:currentUserId="currentUserId" :currentUsername="currentUsername"
|
||||
:initialLockTaskId="autoLockTaskId"
|
||||
@action="handleTaskAction" @viewRecords="handleViewRecords" />
|
||||
|
||||
<TreeCanvas v-if="currentMode === 'tree'" :product="product" @viewRecords="handleViewRecords" />
|
||||
<!-- 📇 流转卡片:探探式单张滑动 -->
|
||||
<TaskSwipeCards v-if="currentMode === 'swipe'" :product="product"
|
||||
@back="currentMode = 'workspace'" @overview="currentMode = 'tree'"
|
||||
@viewRecords="handleViewRecords" />
|
||||
|
||||
<!-- 🌳 流转树:全屏独立视图 -->
|
||||
<TreeCanvas v-if="currentMode === 'tree'" :product="product" @viewRecords="handleViewRecords" @back="currentMode = 'workspace'" @swipe="currentMode = 'swipe'" />
|
||||
</template>
|
||||
|
||||
<!-- 状态定调 -->
|
||||
@ -155,13 +165,14 @@
|
||||
import request, { get, post, patch, put } from "../../utils/request";
|
||||
import WorkspaceArea from "./components/WorkspaceArea.vue";
|
||||
import TreeCanvas from "./components/TreeCanvas.vue";
|
||||
import TaskSwipeCards from "./components/TaskSwipeCards.vue";
|
||||
|
||||
const OVERALL_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
|
||||
const TASK_NAME_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
|
||||
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" };
|
||||
|
||||
export default {
|
||||
components: { WorkspaceArea, TreeCanvas },
|
||||
components: { WorkspaceArea, TreeCanvas, TaskSwipeCards },
|
||||
data() {
|
||||
return {
|
||||
OVERALL_OPTIONS, loading: true, error: "", product: null,
|
||||
@ -169,7 +180,7 @@ export default {
|
||||
users: [], TASK_NAME_OPTIONS,
|
||||
createFirstVisible: false, isWarehouseTransfer: false, firstForm: { task_name: "", taskNameIdx: 0, assignee_id: "", assigneeLabel: "", assigneeIdx: 0, note: "" }, firstSaving: false,
|
||||
recordPopup: { visible: false, task: null }, recordForm: { recordId: null, remark: "", images: [], pendingCount: 0 }, recordSaving: false, isUploading: false,
|
||||
currentUser: null, currentUserId: "", currentUsername: "", currentMode: "tree",
|
||||
currentUser: null, currentUserId: "", currentUsername: "", currentMode: "workspace", autoLockTaskId: "",
|
||||
processOptions: [], userOptions: [],
|
||||
actionPopup: { visible: false, type: "", task: null }, actionLoading: false, rejectReason: "", receiveRemark: "", receiveTaskName: "",
|
||||
transferForm: { selectedUserId: "", isWarehouse: false, note: "" },
|
||||
@ -182,6 +193,7 @@ export default {
|
||||
transferUserName() { const u = this.userOptions.find(u => u.id === this.transferForm.selectedUserId); return u ? u.name : ""; },
|
||||
spawnUserName() { const u = this.userOptions.find(u => u.id === this.spawnForm.assignee_id); return u ? u.name : ""; },
|
||||
userGridOptions() { return (this.userOptions || []).map(u => ({ id: u.id, name: u.name })); },
|
||||
modeToggleLabel() { if (this.currentMode === 'workspace') return '📇 流转卡片'; if (this.currentMode === 'swipe') return '🌳 流转树'; return '🛠️ 工作区'; },
|
||||
hasMyActiveTask() {
|
||||
const find = (tasks) => { if (!tasks) return false; for (const t of tasks) { if ((t.status === 'WIP' || t.status === 'PENDING') && (t.assignee_id == this.currentUserId || t.assignee_id == this.currentUsername)) return true; if (find(t.child_tasks)) return true; } return false; };
|
||||
return this.product ? find(this.product.task_tree) : false;
|
||||
@ -193,8 +205,24 @@ export default {
|
||||
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"; } },
|
||||
|
||||
async doQuery(sn) { this.loading = true; this.error = ""; try { this.product = await get(`/products/scan/${sn}`); this.$nextTick(() => { this.currentMode = this.hasMyActiveTask ? 'workspace' : 'tree'; if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } }); } catch (e) { this.error = e?.data?.detail || "查询失败"; } finally { this.loading = false; } },
|
||||
toggleMode() { this.currentMode = this.currentMode === 'workspace' ? 'tree' : 'workspace'; },
|
||||
async doQuery(sn) { this.loading = true; this.error = ""; try { this.product = await get(`/products/scan/${sn}`); this.$nextTick(() => { this.currentMode = 'workspace'; this.autoLockTaskId = this.findMyImmersiveTask() || ''; if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } }); } catch (e) { this.error = e?.data?.detail || "查询失败"; } finally { this.loading = false; } },
|
||||
findMyImmersiveTask() {
|
||||
// 🚀 扫描用户的 WIP/PENDING 任务,自动沉浸锁定
|
||||
if (!this.product || !this.product.task_tree) return null;
|
||||
let wipTask = null, pendingTask = null;
|
||||
const walk = (tasks) => {
|
||||
if (!tasks) return;
|
||||
for (const t of tasks) {
|
||||
const isMine = t.assignee_id == this.currentUserId || t.assignee_id == this.currentUsername;
|
||||
if (isMine && t.status === 'WIP') wipTask = t;
|
||||
if (isMine && t.status === 'PENDING' && !pendingTask) pendingTask = t;
|
||||
walk(t.child_tasks);
|
||||
}
|
||||
};
|
||||
walk(this.product.task_tree);
|
||||
return (wipTask || pendingTask) ? (wipTask || pendingTask).id : null;
|
||||
},
|
||||
toggleMode() { if (this.currentMode === 'workspace') this.currentMode = 'swipe'; else if (this.currentMode === 'swipe') this.currentMode = 'tree'; else this.currentMode = 'workspace'; },
|
||||
|
||||
async handleSetOverallStatus(status) { try { this.product = await patch(`/products/scan/${this.product.serial_number}/status`, { status }); uni.showToast({ title: `状态已更新: ${status}`, icon: "success" }); this.showStatusPicker = false; } catch {} },
|
||||
openEditProduct() { this.editForm = { order_no: this.product.order_no || "", external_serial: this.product.external_serial || "" }; this.editProductVisible = true; },
|
||||
|
||||
Reference in New Issue
Block a user