refactor: 移动端流转树全面复刻 Web 端 childMap 左中右布局
- 废弃递归纵向排版(FlowTree),改用拍平 + childMap 重构 - 横向 scroll-x + 每条主线一个 Row:[左翼]-[中央]-[右翼] - 中央绝对定位垂直线贯穿;分支按奇偶拆左右翼,横向展开 - 新建 FlowCard(节点卡片)与 FlowBranch(递归分支)
This commit is contained in:
92
track-uniapp/src/pages/scan/components/FlowBranch.vue
Normal file
92
track-uniapp/src/pages/scan/components/FlowBranch.vue
Normal file
@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<view class="fb-cell">
|
||||
<!-- 左翼:子分支在卡片左侧(继续向左展开) -->
|
||||
<view v-if="side === 'left' && kids.length" class="fb-kids-col">
|
||||
<FlowBranch
|
||||
v-for="k in kids"
|
||||
:key="k.id"
|
||||
:node="k"
|
||||
:side="side"
|
||||
:child-map="childMap"
|
||||
:current-user="currentUser"
|
||||
:current-user-id="currentUserId"
|
||||
:current-username="currentUsername"
|
||||
@view-records="$emit('viewRecords', $event)"
|
||||
/>
|
||||
</view>
|
||||
<!-- 左翼:卡片在右侧贴近主干道 -->
|
||||
<FlowCard
|
||||
v-if="side === 'left'"
|
||||
:node="node"
|
||||
@view-records="$emit('viewRecords', $event)"
|
||||
/>
|
||||
<!-- 横向连线箭头:左翼→指向中央,右翼←从中央伸出 -->
|
||||
<view class="fb-arrow" :class="'arrow-' + side">{{ side === 'left' ? '→' : '←' }}</view>
|
||||
<!-- 右翼:卡片在左侧贴近主干道 -->
|
||||
<FlowCard
|
||||
v-if="side === 'right'"
|
||||
:node="node"
|
||||
@view-records="$emit('viewRecords', $event)"
|
||||
/>
|
||||
<!-- 右翼:子分支在卡片右侧(继续向右展开) -->
|
||||
<view v-if="side === 'right' && kids.length" class="fb-kids-col">
|
||||
<FlowBranch
|
||||
v-for="k in kids"
|
||||
:key="k.id"
|
||||
:node="k"
|
||||
:side="side"
|
||||
:child-map="childMap"
|
||||
:current-user="currentUser"
|
||||
:current-user-id="currentUserId"
|
||||
:current-username="currentUsername"
|
||||
@view-records="$emit('viewRecords', $event)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FlowCard from "./FlowCard.vue";
|
||||
|
||||
export default {
|
||||
name: "FlowBranch",
|
||||
components: { FlowCard },
|
||||
props: {
|
||||
node: { type: Object, required: true },
|
||||
side: { type: String, default: "left" }, // 'left' | 'right'
|
||||
childMap: { type: Object, default: () => ({}) },
|
||||
currentUser: { type: Object, default: null },
|
||||
currentUserId: { type: [String, Number], default: "" },
|
||||
currentUsername: { type: String, default: "" },
|
||||
},
|
||||
emits: ["viewRecords"],
|
||||
computed: {
|
||||
// 该分支节点的直接子节点(进入分支后子子孙孙全部继续作为分支展开,不回主干道)
|
||||
kids() {
|
||||
return this.childMap[this.node.id] || [];
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fb-cell {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.fb-kids-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
.fb-arrow {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #9ca3af;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
112
track-uniapp/src/pages/scan/components/FlowCard.vue
Normal file
112
track-uniapp/src/pages/scan/components/FlowCard.vue
Normal file
@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<view class="fc-card" :class="statusColorClass(node.status)">
|
||||
<view class="fc-head">
|
||||
<!-- 主线/分支 标签(仅中央主线显示) -->
|
||||
<text v-if="showBadge" class="fc-badge" :class="isMainNode ? 'fc-badge-main' : 'fc-badge-sub'">{{ isMainNode ? '主线' : '分支' }}</text>
|
||||
<text :class="['fc-status', statusColorClass(node.status)]">{{ statusLabel(node.status) }}</text>
|
||||
<text v-if="node.is_rework" class="fc-rework">⚠返工</text>
|
||||
</view>
|
||||
<text class="fc-name">{{ node.task_name }}</text>
|
||||
<view class="fc-meta">
|
||||
<!-- 在库任务显示"转入在库",其余显示负责人 -->
|
||||
<text v-if="isWarehouseTask" class="fc-warehouse-in">📥 转入在库: {{ formatUserName(node.assignee_id) }}</text>
|
||||
<text v-else class="fc-assignee">👤 {{ formatUserName(node.assignee_id) || '未分配' }}</text>
|
||||
</view>
|
||||
<view class="fc-bottom">
|
||||
<text class="fc-time">⏰ {{ fmtDate(node.created_at) }}{{ node.completed_at ? '→' + fmtDate(node.completed_at) : '→至今' }}</text>
|
||||
<text v-if="node.records && node.records.length" class="fc-records" @tap.stop="$emit('viewRecords', node)">📋{{ node.records.length }}条 ›</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { formatUserName as fmtUserName } from "../../../utils/format";
|
||||
|
||||
export default {
|
||||
name: "FlowCard",
|
||||
props: {
|
||||
node: { type: Object, required: true },
|
||||
showBadge: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ["viewRecords"],
|
||||
computed: {
|
||||
isMainNode() {
|
||||
return !this.node.parent_task_id
|
||||
|| this.node.task_type === "TRANSFER"
|
||||
|| this.node.task_type === "RECOVERY";
|
||||
},
|
||||
isWarehouseTask() {
|
||||
const name = String(this.node.task_name || "");
|
||||
return (name.includes("在库") || name.includes("入库")) || this.node.assignee_id === "virtual_warehouse";
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
formatUserName(userId) {
|
||||
return fmtUserName(userId);
|
||||
},
|
||||
statusLabel(s) {
|
||||
const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", OUTBOUND: "已出库" };
|
||||
return map[s] || s;
|
||||
},
|
||||
statusColorClass(s) {
|
||||
switch (s) {
|
||||
case "PENDING": return "s-yellow";
|
||||
case "WIP": return "s-blue";
|
||||
case "COMPLETED": return "s-green";
|
||||
case "REJECTED": return "s-red";
|
||||
case "ARCHIVED": return "s-archived";
|
||||
case "OUTBOUND": return "s-outbound";
|
||||
default: return "s-gray";
|
||||
}
|
||||
},
|
||||
fmtDate(t) {
|
||||
if (!t) return "";
|
||||
const d = new Date(t);
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-left-width: 4px;
|
||||
padding: 10px 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
width: 168px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fc-card.s-yellow { border-left-color: #f59e0b; }
|
||||
.fc-card.s-blue { border-left-color: #3b82f6; }
|
||||
.fc-card.s-green { border-left-color: #22c55e; }
|
||||
.fc-card.s-red { border-left-color: #ef4444; }
|
||||
.fc-card.s-archived { border-left-color: #8b5cf6; }
|
||||
.fc-card.s-outbound { border-left-color: #4f46e5; }
|
||||
.fc-card.s-gray { border-left-color: #9ca3af; }
|
||||
|
||||
.fc-head { display: flex; align-items: center; gap: 4px; margin-bottom: 4px; flex-wrap: wrap; }
|
||||
.fc-badge { font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 4px; color: #fff; }
|
||||
.fc-badge-main { background: #2563eb; }
|
||||
.fc-badge-sub { background: #7c3aed; }
|
||||
.fc-status { font-size: 11px; font-weight: 700; padding: 1px 8px; border-radius: 10px; }
|
||||
.s-yellow .fc-status { background: #fef3c7; color: #b45309; }
|
||||
.s-blue .fc-status { background: #dbeafe; color: #1d4ed8; }
|
||||
.s-green .fc-status { background: #dcfce7; color: #15803d; }
|
||||
.s-red .fc-status { background: #fce4ec; color: #be123c; }
|
||||
.s-archived .fc-status { background: #ede9fe; color: #7c3aed; }
|
||||
.s-outbound .fc-status { background: #e0e7ff; color: #4338ca; }
|
||||
.s-gray .fc-status { background: #f3f4f6; color: #6b7280; }
|
||||
.fc-rework { font-size: 10px; background: #ef4444; color: #fff; padding: 1px 5px; border-radius: 4px; font-weight: 700; }
|
||||
|
||||
.fc-name { font-size: 13px; font-weight: 700; color: #1f2937; }
|
||||
.fc-meta { margin-top: 4px; font-size: 11px; }
|
||||
.fc-assignee { color: #4b5563; }
|
||||
.fc-warehouse-in { color: #059669; font-weight: 700; }
|
||||
.fc-bottom { display: flex; align-items: center; justify-content: space-between; gap: 4px; margin-top: 6px; }
|
||||
.fc-time { font-size: 10px; color: #9ca3af; }
|
||||
.fc-records { font-size: 10px; color: #2563eb; font-weight: 600; }
|
||||
</style>
|
||||
@ -14,38 +14,65 @@
|
||||
<text class="zero-icon">📋</text><text class="zero-text">该产品暂无流转记录</text>
|
||||
</view>
|
||||
|
||||
<scroll-view v-else scroll-y class="tree-scroll" :style="{ height: scrollHeight + 'px' }">
|
||||
<view class="tree-root">
|
||||
<!-- 递归纵向流转树:根级主线之间用 ↓ 连线,分支由 FlowTree 内部缩进 -->
|
||||
<template v-for="(t, i) in product.task_tree" :key="'rt-' + t.id">
|
||||
<view v-if="i > 0" class="ft-flow-arrow">↓</view>
|
||||
<FlowTree
|
||||
:node="t"
|
||||
:current-user="currentUser"
|
||||
:current-user-id="currentUserId"
|
||||
:current-username="currentUsername"
|
||||
@view-records="$emit('viewRecords', $event)"
|
||||
/>
|
||||
</template>
|
||||
</view>
|
||||
<!-- 横向滚动:左右滑动查看分支 -->
|
||||
<scroll-view v-else scroll-x class="tree-scroll" :style="{ height: scrollHeight + 'px' }">
|
||||
<view class="tree-canvas" :style="{ width: canvasWidth + 'px' }">
|
||||
<!-- 每条主线一个 Row:[左翼] - [中央主线卡片] - [右翼] -->
|
||||
<view v-for="mainTask in allMains" :key="mainTask.id" class="flow-row">
|
||||
<!-- 中央主干道垂直线(绝对定位,穿透整行) -->
|
||||
<view class="flow-vline" />
|
||||
|
||||
<!-- 图例 -->
|
||||
<view class="tree-legend">
|
||||
<text class="legend-item legend-main">■ 主线</text>
|
||||
<text class="legend-item legend-sub">□ 分支</text>
|
||||
<text class="legend-item">📥 在库</text>
|
||||
<text class="legend-item">📦 已入库</text>
|
||||
<!-- 左翼:奇数分支(向下/左展开) -->
|
||||
<view class="flow-side flow-left">
|
||||
<FlowBranch
|
||||
v-for="b in leftDirect(mainTask)"
|
||||
:key="b.id"
|
||||
:node="b"
|
||||
side="left"
|
||||
:child-map="childMap"
|
||||
:current-user="currentUser"
|
||||
:current-user-id="currentUserId"
|
||||
:current-username="currentUsername"
|
||||
@view-records="$emit('viewRecords', $event)"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 中央主线卡片 -->
|
||||
<view class="flow-center">
|
||||
<FlowCard
|
||||
:node="mainTask"
|
||||
:show-badge="true"
|
||||
@view-records="$emit('viewRecords', $event)"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 右翼:偶数分支(向右展开) -->
|
||||
<view class="flow-side flow-right">
|
||||
<FlowBranch
|
||||
v-for="b in rightDirect(mainTask)"
|
||||
:key="b.id"
|
||||
:node="b"
|
||||
side="right"
|
||||
:child-map="childMap"
|
||||
:current-user="currentUser"
|
||||
:current-user-id="currentUserId"
|
||||
:current-username="currentUsername"
|
||||
@view-records="$emit('viewRecords', $event)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FlowTree from "./FlowTree.vue";
|
||||
import FlowCard from "./FlowCard.vue";
|
||||
import FlowBranch from "./FlowBranch.vue";
|
||||
|
||||
export default {
|
||||
name: "TreeCanvas",
|
||||
components: { FlowTree },
|
||||
components: { FlowCard, FlowBranch },
|
||||
props: {
|
||||
product: { type: Object, required: true },
|
||||
currentUser: { type: Object, default: null },
|
||||
@ -56,10 +83,55 @@ export default {
|
||||
data() {
|
||||
return { scrollHeight: 500 };
|
||||
},
|
||||
computed: {
|
||||
// 复刻 Web 端:递归拍平 + childMap(parent_task_id → 子数组)
|
||||
childMap() {
|
||||
const flat = [];
|
||||
const walk = (tasks) => { (tasks || []).forEach((t) => { flat.push(t); walk(t.child_tasks); }); };
|
||||
walk(this.product.task_tree);
|
||||
const map = {};
|
||||
flat.forEach((t) => {
|
||||
const pid = t.parent_task_id || "";
|
||||
if (!map[pid]) map[pid] = [];
|
||||
map[pid].push(t);
|
||||
});
|
||||
Object.values(map).forEach((arr) => arr.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)));
|
||||
return map;
|
||||
},
|
||||
// 所有主线任务,按创建时间排序
|
||||
allMains() {
|
||||
const flat = [];
|
||||
const walk = (tasks) => { (tasks || []).forEach((t) => { flat.push(t); walk(t.child_tasks); }); };
|
||||
walk(this.product.task_tree);
|
||||
const mains = flat.filter((t) => this.isMain(t));
|
||||
mains.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||
return mains;
|
||||
},
|
||||
// 横向画布宽度:中央 + 左右翼展开空间(scroll-x 用)
|
||||
canvasWidth() {
|
||||
return 900;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isMain(t) {
|
||||
return !t.parent_task_id || t.task_type === "TRANSFER" || t.task_type === "RECOVERY";
|
||||
},
|
||||
// 当前主线的直接分支子节点(非主线)
|
||||
branchChildren(mainTask) {
|
||||
return (this.childMap[mainTask.id] || []).filter((c) => !this.isMain(c));
|
||||
},
|
||||
// 左翼(奇偶拆分)
|
||||
leftDirect(mainTask) {
|
||||
return this.branchChildren(mainTask).filter((_, i) => i % 2 === 0);
|
||||
},
|
||||
// 右翼
|
||||
rightDirect(mainTask) {
|
||||
return this.branchChildren(mainTask).filter((_, i) => i % 2 === 1);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
try {
|
||||
const info = uni.getSystemInfoSync();
|
||||
// 工具条高度 + 底部安全区预留约 70px,剩余给滚动区
|
||||
this.scrollHeight = Math.max((info.windowHeight || 600) - 70, 300);
|
||||
} catch (e) {}
|
||||
},
|
||||
@ -96,25 +168,39 @@ export default {
|
||||
.zero-text { font-size: 14px; color: #9ca3af; }
|
||||
|
||||
.tree-scroll { flex-shrink: 0; }
|
||||
.tree-root { padding: 16px 12px 20px; }
|
||||
/* 根级主线之间的向下连线箭头 */
|
||||
.ft-flow-arrow {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin: 2px 0;
|
||||
}
|
||||
.tree-legend {
|
||||
.tree-canvas { display: flex; flex-direction: column; padding: 16px 12px; }
|
||||
|
||||
/* 每条主线一行 */
|
||||
.flow-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 10px 16px 20px;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
padding: 20px 0;
|
||||
}
|
||||
.legend-item { padding: 2px 8px; background: #fff; border-radius: 6px; border: 1px solid #e5e7eb; }
|
||||
.legend-main { color: #2563eb; font-weight: 700; }
|
||||
.legend-sub { color: #7c3aed; font-weight: 700; }
|
||||
/* 中央主干道垂直线(绝对定位,穿透整行) */
|
||||
.flow-vline {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #d1d5db;
|
||||
transform: translateX(-50%);
|
||||
z-index: 0;
|
||||
}
|
||||
/* 左右翼容器:各占一半,分支从中央向两侧展开 */
|
||||
.flow-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.flow-left { align-items: flex-end; padding-right: 10px; }
|
||||
.flow-right { align-items: flex-start; padding-left: 10px; }
|
||||
/* 中央卡片 */
|
||||
.flow-center { z-index: 2; flex-shrink: 0; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user