feat: Track 移动端状态展示与流转树全览改造
- 宏观状态栏补已入库/已出库语义色;OVERALL_OPTIONS 补已入库/已出库 - 在库任务'转入在库'显示实际操作人(assignee);补 OUTBOUND 状态色 - 流转树全览(TreeCanvas)由可移动 canvas 改为纵向递归树,复用 TaskTreeNode
This commit is contained in:
@ -12,11 +12,12 @@
|
|||||||
<text class="tnode-name">{{ task.task_name }}</text>
|
<text class="tnode-name">{{ task.task_name }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="tnode-meta">
|
<view class="tnode-meta">
|
||||||
<text v-if="task.assignee_id">负责人: {{ task.assignee_id }}</text>
|
<!-- 在库任务不单独显示"负责人",由下方"转入在库"统一展示实际操作人 -->
|
||||||
|
<text v-if="task.assignee_id && !isWarehouseTask">负责人: {{ formatUserName(task.assignee_id) }}</text>
|
||||||
<text v-if="task.reject_reason" class="reject-reason">驳回: {{ task.reject_reason }}</text>
|
<text v-if="task.reject_reason" class="reject-reason">驳回: {{ task.reject_reason }}</text>
|
||||||
</view>
|
</view>
|
||||||
<!-- 📥 在库任务:显示谁转入在库 -->
|
<!-- 📥 在库任务:显示实际执行"转入在库"的操作人(任务负责人) -->
|
||||||
<text v-if="isWarehouseTask && task.created_by" class="warehouse-in">📥 转入在库: {{ formatUserName(task.created_by) }}</text>
|
<text v-if="isWarehouseTask && task.assignee_id" class="warehouse-in">📥 转入在库: {{ formatUserName(task.assignee_id) }}</text>
|
||||||
|
|
||||||
<!-- 任务初始描述 / 交接备注(常驻显示,区别于动态日志 records) -->
|
<!-- 任务初始描述 / 交接备注(常驻显示,区别于动态日志 records) -->
|
||||||
<view v-if="task.remark || task.description" class="task-initial-remark">
|
<view v-if="task.remark || task.description" class="task-initial-remark">
|
||||||
@ -42,6 +43,7 @@
|
|||||||
:currentUserId="currentUserId"
|
:currentUserId="currentUserId"
|
||||||
:currentUsername="currentUsername"
|
:currentUsername="currentUsername"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
|
:has-children="!!(child.child_tasks && child.child_tasks.length)"
|
||||||
:isLast="idx === task.child_tasks.length - 1"
|
:isLast="idx === task.child_tasks.length - 1"
|
||||||
@action="(e) => $emit('action', e)"
|
@action="(e) => $emit('action', e)"
|
||||||
@editRecord="(e) => $emit('editRecord', e)"
|
@editRecord="(e) => $emit('editRecord', e)"
|
||||||
@ -151,6 +153,7 @@ export default {
|
|||||||
case "WIP": return "s-blue";
|
case "WIP": return "s-blue";
|
||||||
case "COMPLETED": return "s-green";
|
case "COMPLETED": return "s-green";
|
||||||
case "REJECTED": return "s-red";
|
case "REJECTED": return "s-red";
|
||||||
|
case "OUTBOUND": return "s-outbound";
|
||||||
default: return "s-gray";
|
default: return "s-gray";
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -179,6 +182,7 @@ export default {
|
|||||||
.s-blue { border-left-color: #3b82f6; }
|
.s-blue { border-left-color: #3b82f6; }
|
||||||
.s-green { border-left-color: #22c55e; }
|
.s-green { border-left-color: #22c55e; }
|
||||||
.s-red { border-left-color: #ef4444; }
|
.s-red { border-left-color: #ef4444; }
|
||||||
|
.s-outbound { border-left-color: #4f46e5; }
|
||||||
|
|
||||||
.tnode-body { flex: 1; min-width: 0; }
|
.tnode-body { flex: 1; min-width: 0; }
|
||||||
.tnode-name-row { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
|
.tnode-name-row { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
|
||||||
@ -232,6 +236,7 @@ export default {
|
|||||||
.s-blue .badge { background: #dbeafe; color: #1d4ed8; }
|
.s-blue .badge { background: #dbeafe; color: #1d4ed8; }
|
||||||
.s-green .badge { background: #dcfce7; color: #15803d; }
|
.s-green .badge { background: #dcfce7; color: #15803d; }
|
||||||
.s-red .badge { background: #fce4ec; color: #be123c; }
|
.s-red .badge { background: #fce4ec; color: #be123c; }
|
||||||
|
.s-outbound .badge { background: #e0e7ff; color: #4338ca; }
|
||||||
|
|
||||||
.tnode-children { position: relative; }
|
.tnode-children { position: relative; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="tree-canvas-fullscreen">
|
<view class="tree-fullscreen">
|
||||||
<view class="tc-toolbar">
|
<view class="tc-toolbar">
|
||||||
<view class="tc-toolbar-left">
|
<view class="tc-toolbar-left">
|
||||||
<view class="tc-back-btn" @tap="$emit('back')">← 返回</view>
|
<view class="tc-back-btn" @tap="$emit('back')">← 返回</view>
|
||||||
<text class="tc-title">🌳 流转蓝图</text>
|
<text class="tc-title">🌳 流转树</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="tc-toolbar-right">
|
<view class="tc-toolbar-right">
|
||||||
<view class="tc-zoom-btn tc-mode-btn" @tap="$emit('swipe')">📇 卡片</view>
|
<view class="tc-zoom-btn tc-mode-btn" @tap="$emit('swipe')">📇 卡片</view>
|
||||||
<view class="tc-zoom-btn tc-fit-btn" @tap="fitAll">⊡ 全览</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@ -15,315 +14,90 @@
|
|||||||
<text class="zero-icon">📋</text><text class="zero-text">该产品暂无流转记录</text>
|
<text class="zero-icon">📋</text><text class="zero-text">该产品暂无流转记录</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<template v-else>
|
<scroll-view v-else scroll-y class="tree-scroll">
|
||||||
<movable-area class="tree-movable-area" :style="{ width: viewportW + 'px', height: areaHeight + 'px' }">
|
<view class="tree-root">
|
||||||
<movable-view class="tree-movable-view" direction="all"
|
<!-- 递归纵向树:主线 + 分支缩进,复用 TaskTreeNode 渲染 -->
|
||||||
:x="mvX" :y="mvY" :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
|
<TaskTreeNode
|
||||||
:inertia="true" :friction="2" :damping="40"
|
v-for="(t, idx) in product.task_tree"
|
||||||
@change="onChange">
|
:key="t.id"
|
||||||
|
:task="t"
|
||||||
|
:current-user="currentUser"
|
||||||
|
:current-user-id="currentUserId"
|
||||||
|
:current-username="currentUsername"
|
||||||
|
:has-children="!!(t.child_tasks && t.child_tasks.length)"
|
||||||
|
:readonly="true"
|
||||||
|
:is-last="idx === product.task_tree.length - 1"
|
||||||
|
@view-records="$emit('viewRecords', $event)"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="canvas-inner">
|
<!-- 图例 -->
|
||||||
<!-- 🔧 连线层 -->
|
<view class="tree-legend">
|
||||||
<view v-for="(line, i) in lines" :key="'l'+i"
|
<text class="legend-item legend-main">■ 主线</text>
|
||||||
class="cn-line" :style="lineStyle(line)"
|
<text class="legend-item legend-sub">□ 分支</text>
|
||||||
:class="line.dashed ? 'cn-line-dashed' : 'cn-line-solid'" />
|
<text class="legend-item">📥 在库</text>
|
||||||
<!-- 🔧 连线末端箭头 -->
|
<text class="legend-item">📦 已入库</text>
|
||||||
<view v-for="(arrow, i) in arrows" :key="'a'+i"
|
</view>
|
||||||
class="cn-arrow" :style="{ left: arrow.x + 'px', top: arrow.y + 'px', transform: 'rotate(' + arrow.rot + 'deg)' }"
|
</scroll-view>
|
||||||
:class="arrow.dashed ? 'cn-arrow-dashed' : 'cn-arrow-solid'" />
|
|
||||||
|
|
||||||
<!-- 🚀 十字星节点 -->
|
|
||||||
<view v-for="node in treeNodes" :key="node.id"
|
|
||||||
class="canvas-node" :class="[statusColor(node.status), node._isMain ? 'node-main' : 'node-sub']"
|
|
||||||
:style="{ left: node.x + 'px', top: node.y + 'px' }"
|
|
||||||
@tap="handleNodeTap(node)">
|
|
||||||
<view class="cn-header">
|
|
||||||
<text :class="node._isMain ? 'badge-main' : 'badge-sub'">{{ node._isMain ? '主分支' : node._blabel }}</text>
|
|
||||||
<text :class="['cn-badge-mini', statusColor(node.status)]">{{ statusLabel(node.status) }}</text>
|
|
||||||
</view>
|
|
||||||
<text class="cn-assignee">{{ formatUserName(node.assignee_id) || '—' }}</text>
|
|
||||||
<text v-if="!node._isMain && node.parent && !node.parent._isMain" class="cn-nested-hint">协助: {{ formatUserName(node.parent.assignee_id) || '—' }}</text>
|
|
||||||
<!-- 📥 在库任务:显示谁转入在库 -->
|
|
||||||
<text v-if="(String(node.task_name || '').includes('在库') || String(node.task_name || '').includes('入库') || node.assignee_id === 'virtual_warehouse') && node.created_by" class="cn-warehouse-in">📥 转入在库: {{ formatUserName(node.created_by) }}</text>
|
|
||||||
<text class="cn-time">⏰ {{ fmtDate(node.created_at) }}{{ node.completed_at ? '→' + fmtDate(node.completed_at) : '→至今' }}</text>
|
|
||||||
<text v-if="node.records && node.records.length" class="cn-records-mini">{{ node.records.length }}条</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
</movable-view>
|
|
||||||
</movable-area>
|
|
||||||
</template>
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { formatUserName } from "../../../utils/format";
|
import TaskTreeNode from "./TaskTreeNode.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "TreeCanvas",
|
name: "TreeCanvas",
|
||||||
props: { product: { type: Object, required: true } },
|
components: { TaskTreeNode },
|
||||||
|
props: {
|
||||||
|
product: { type: Object, required: true },
|
||||||
|
currentUser: { type: Object, default: null },
|
||||||
|
currentUserId: { type: [String, Number], default: "" },
|
||||||
|
currentUsername: { type: String, default: "" },
|
||||||
|
},
|
||||||
emits: ["viewRecords", "back", "swipe"],
|
emits: ["viewRecords", "back", "swipe"],
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
mvX: 0, mvY: 0,
|
|
||||||
viewportW: 375, viewportH: 600,
|
|
||||||
areaHeight: 500,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
try { const info = uni.getSystemInfoSync(); this.viewportW = info.windowWidth || 375; this.viewportH = info.windowHeight || 600; } catch {}
|
|
||||||
this.areaHeight = Math.max(this.viewportH - 100, 400);
|
|
||||||
this.$nextTick(() => this.fitAll());
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
// ============================================================
|
|
||||||
// 🚀 父子相对坐标延伸算法(v3 — 无重叠 Y 轴栈式布局)
|
|
||||||
// ============================================================
|
|
||||||
treeNodes() {
|
|
||||||
if (!this.product || !this.product.task_tree) return [];
|
|
||||||
const CARD_W = 160, CARD_H = 80, GAP_X = 24, VERTICAL_GAP = 28, MAIN_GAP = 40;
|
|
||||||
const CENTER_X = 0;
|
|
||||||
|
|
||||||
// 拍平 + 浅拷贝
|
|
||||||
const flatMap = {};
|
|
||||||
const allTasks = [];
|
|
||||||
const walk = (tasks) => { if (!tasks) return; tasks.forEach(t => { const copy = { ...t }; allTasks.push(copy); flatMap[copy.id] = copy; walk(t.child_tasks); }); };
|
|
||||||
walk(this.product.task_tree);
|
|
||||||
allTasks.forEach(t => {
|
|
||||||
if (t.child_tasks && t.child_tasks.length) t.child_tasks = t.child_tasks.map(c => flatMap[c.id]).filter(Boolean);
|
|
||||||
if (t.parent_task_id) t.parent = flatMap[t.parent_task_id] || null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 🚀 主干判定
|
|
||||||
const isMainFn = (t) => !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY';
|
|
||||||
|
|
||||||
// 分支编号
|
|
||||||
const branchLabelMap = {};
|
|
||||||
const traverse = (tasks, prefix) => {
|
|
||||||
if (!tasks) return;
|
|
||||||
let si = 0;
|
|
||||||
tasks.forEach(t => {
|
|
||||||
if (isMainFn(t)) { branchLabelMap[t.id] = '主分支'; traverse(t.child_tasks, prefix); }
|
|
||||||
else { si++; const n = prefix ? prefix + '.' + si : '' + si; branchLabelMap[t.id] = '分支 ' + n; traverse(t.child_tasks, n); }
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const rootCopies = this.product.task_tree.map(t => flatMap[t.id]).filter(Boolean);
|
|
||||||
rootCopies.forEach(t => { branchLabelMap[t.id] = '主分支'; traverse(t.child_tasks, ''); });
|
|
||||||
|
|
||||||
// 🚀 构建真实的全局 childMap(按 parent_task_id)
|
|
||||||
const childMap = {};
|
|
||||||
allTasks.forEach(t => {
|
|
||||||
const pid = t.parent_task_id || '';
|
|
||||||
if (!childMap[pid]) childMap[pid] = [];
|
|
||||||
childMap[pid].push(t);
|
|
||||||
});
|
|
||||||
Object.values(childMap).forEach(arr => arr.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)));
|
|
||||||
|
|
||||||
// 主管分类
|
|
||||||
const mains = [];
|
|
||||||
allTasks.forEach(t => { if (isMainFn(t)) mains.push(t); });
|
|
||||||
mains.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
|
||||||
|
|
||||||
// 🚀 预计算每个节点的子树高度(兄弟累加取 max,首子与父平齐)
|
|
||||||
const calcSubtreeHeight = (nodeId) => {
|
|
||||||
const children = childMap[nodeId] || [];
|
|
||||||
if (children.length === 0) return CARD_H + VERTICAL_GAP;
|
|
||||||
const totalChildrenHeight = children.reduce((sum, c) => sum + calcSubtreeHeight(c.id), 0);
|
|
||||||
return Math.max(CARD_H + VERTICAL_GAP, totalChildrenHeight);
|
|
||||||
};
|
|
||||||
|
|
||||||
const nodes = [];
|
|
||||||
|
|
||||||
// 🚀 递归放置算法:首个子节点与父节点水平平齐
|
|
||||||
// 同级子节点按 index 错开 Y 轴,各占其子树高度防止重叠
|
|
||||||
const placeChildren = (parentNode, currentSide) => {
|
|
||||||
const children = childMap[parentNode.id] || [];
|
|
||||||
// 🚀 首个子节点 Y 起始:与父节点水平平齐
|
|
||||||
let startY = parentNode.y;
|
|
||||||
|
|
||||||
children.forEach((child, index) => {
|
|
||||||
const subtreeH = calcSubtreeHeight(child.id);
|
|
||||||
|
|
||||||
// 主干的第一层协助分支:均衡分发左右
|
|
||||||
let side = currentSide;
|
|
||||||
if (parentNode._isMain) {
|
|
||||||
side = index % 2 === 0 ? 'right' : 'left';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🚀 X 轴:永远基于真实父亲向外延伸
|
|
||||||
const childX = side === 'right'
|
|
||||||
? parentNode.x + CARD_W + GAP_X
|
|
||||||
: parentNode.x - CARD_W - GAP_X;
|
|
||||||
|
|
||||||
// 🚀 Y 轴:首子与父平齐,后续兄弟向下累加
|
|
||||||
const childY = startY;
|
|
||||||
|
|
||||||
const childNode = {
|
|
||||||
...child,
|
|
||||||
x: childX, y: childY,
|
|
||||||
_isMain: false, _side: side,
|
|
||||||
_blabel: branchLabelMap[child.id] || '分支',
|
|
||||||
_rootMainId: parentNode._isMain ? parentNode.id : parentNode._rootMainId,
|
|
||||||
};
|
|
||||||
nodes.push(childNode);
|
|
||||||
flatMap[childNode.id] = childNode;
|
|
||||||
|
|
||||||
// 递归放置孙子节点(沿相同方向)
|
|
||||||
placeChildren(childNode, side);
|
|
||||||
|
|
||||||
// 🚀 下一个兄弟节点跳到当前子树高度之后(calcSubtreeHeight 已含 GAP)
|
|
||||||
startY += subtreeH;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 🚀 渲染入口:主干节点也按子树深度排布
|
|
||||||
let mainStartY = 0;
|
|
||||||
mains.forEach((m, mi) => {
|
|
||||||
const subtreeH = calcSubtreeHeight(m.id);
|
|
||||||
const mainNode = { ...m, x: CENTER_X, y: mainStartY, _isMain: true, _blabel: '主分支', _rootMainId: m.id };
|
|
||||||
nodes.push(mainNode);
|
|
||||||
flatMap[mainNode.id] = mainNode;
|
|
||||||
placeChildren(mainNode, null);
|
|
||||||
mainStartY += subtreeH + MAIN_GAP;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 全局偏移(给左侧分支预留空间)
|
|
||||||
const minX = Math.min(...nodes.map(n => n.x));
|
|
||||||
const minY = Math.min(...nodes.map(n => n.y));
|
|
||||||
const padding = 40;
|
|
||||||
nodes.forEach(n => { n.x += Math.abs(minX) + CARD_W * 2 + padding; n.y += padding - Math.min(0, minY); });
|
|
||||||
return nodes;
|
|
||||||
},
|
|
||||||
// ============================================================
|
|
||||||
// 🚀 连线计算:中央 → 分支
|
|
||||||
// ============================================================
|
|
||||||
// 🔧 连线:卡片边缘到边缘,动态追踪 Y 坐标,杜绝箭头悬空
|
|
||||||
lines() {
|
|
||||||
const result = [];
|
|
||||||
const flatMap = {};
|
|
||||||
this.treeNodes.forEach(n => flatMap[n.id] = n);
|
|
||||||
this.treeNodes.forEach(n => {
|
|
||||||
if (n._isMain) return;
|
|
||||||
// 🚀 连线到真实直接父节点(树枝延伸)
|
|
||||||
const parent = n.parent_task_id ? flatMap[n.parent_task_id] : null;
|
|
||||||
if (!parent) return;
|
|
||||||
const isLeft = n.x < parent.x;
|
|
||||||
const startX = isLeft ? n.x + 160 : parent.x + 160;
|
|
||||||
const endX = isLeft ? parent.x : n.x;
|
|
||||||
// 🚀 动态计算真实的 Y 坐标对接点
|
|
||||||
const startY = isLeft ? n.y + 40 : parent.y + 40;
|
|
||||||
const endY = isLeft ? parent.y + 40 : n.y + 40;
|
|
||||||
result.push({
|
|
||||||
x1: startX, y1: startY,
|
|
||||||
x2: endX, y2: endY,
|
|
||||||
dashed: parent.status === 'COMPLETED' || parent.status === 'ARCHIVED',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
arrows() {
|
|
||||||
const result = [];
|
|
||||||
const flatMap = {};
|
|
||||||
this.treeNodes.forEach(n => flatMap[n.id] = n);
|
|
||||||
this.treeNodes.forEach(n => {
|
|
||||||
if (n._isMain) return;
|
|
||||||
const parent = n.parent_task_id ? flatMap[n.parent_task_id] : null;
|
|
||||||
if (!parent) return;
|
|
||||||
const isLegacy = parent && (parent.status === 'COMPLETED' || parent.status === 'ARCHIVED');
|
|
||||||
const isLeft = n.x < parent.x;
|
|
||||||
result.push({
|
|
||||||
x: isLeft ? n.x + 160 : n.x - 6,
|
|
||||||
y: n.y + 40 - 2,
|
|
||||||
rot: isLeft ? 180 : 0,
|
|
||||||
dashed: isLegacy,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
// ============================================================
|
|
||||||
canvasWidth() { const ns = this.treeNodes; if (!ns.length) return 800; return Math.max(...ns.map(n => n.x)) + 400; },
|
|
||||||
canvasHeight() { const ns = this.treeNodes; if (!ns.length) return 800; return Math.max(...ns.map(n => n.y)) + 300; },
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
formatUserName,
|
|
||||||
// 🚀 手势结束后同步位置
|
|
||||||
onChange(e) {
|
|
||||||
if (e?.detail?.x !== undefined) this.mvX = e.detail.x;
|
|
||||||
if (e?.detail?.y !== undefined) this.mvY = e.detail.y;
|
|
||||||
},
|
|
||||||
handleNodeTap(node) {
|
|
||||||
if (node.records && node.records.length > 0) {
|
|
||||||
this.$emit('viewRecords', node);
|
|
||||||
} else {
|
|
||||||
uni.showToast({ title: '当前节点暂无流转记录', icon: 'none' });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// 🚀 全览居中:固定 100% 比例,内容整体在视口中居中
|
|
||||||
fitAll() {
|
|
||||||
const canvasW = this.canvasWidth;
|
|
||||||
const canvasH = this.canvasHeight;
|
|
||||||
const viewW = this.viewportW;
|
|
||||||
const viewH = this.areaHeight;
|
|
||||||
this.mvX = (viewW - canvasW) / 2;
|
|
||||||
this.mvY = (viewH - canvasH) / 2;
|
|
||||||
},
|
|
||||||
fmtDate(d) { if (!d) return ""; const dt = new Date(d); return (dt.getMonth() + 1) + '/' + dt.getDate(); },
|
|
||||||
statusLabel(s) { const m = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" }; return m[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"; case "ARCHIVED": return "s-archived"; default: return "s-gray"; } },
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.tree-canvas-fullscreen { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 100; display: flex; flex-direction: column; background: #e8ecf0; }
|
.tree-fullscreen {
|
||||||
.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: 999; }
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
.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: 999;
|
||||||
|
}
|
||||||
.tc-toolbar-left { display: flex; align-items: center; gap: 12px; }
|
.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-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-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-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 { display: flex; align-items: center; justify-content: center; }
|
||||||
.tc-zoom-btn:active { background: #e5e7eb; }
|
.tc-mode-btn { width: auto; padding: 0 10px; font-size: 12px; background: #ede9fe; color: #7c3aed; border-radius: 8px; height: 30px; }
|
||||||
.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-task { display: flex; flex-direction: column; align-items: center; padding: 60px 0; }
|
||||||
.zero-icon { font-size: 40px; margin-bottom: 10px; }
|
.zero-icon { font-size: 40px; margin-bottom: 10px; }
|
||||||
.zero-text { font-size: 14px; color: #9ca3af; }
|
.zero-text { font-size: 14px; color: #9ca3af; }
|
||||||
.tree-movable-area { width: 100%; background-color: #f0f2f5; background-image: linear-gradient(#dde1e6 1px, transparent 1px), linear-gradient(90deg, #dde1e6 1px, transparent 1px); background-size: 24px 24px; overflow: hidden; }
|
|
||||||
.canvas-inner { position: absolute; left: 0; top: 0; width: 100%; height: 100%; }
|
|
||||||
/* 🚀 movable-view 填充整个父容器,确保空白区域也能响应拖拽/缩放手势 */
|
|
||||||
.tree-movable-view { width: 100%; height: 100%; }
|
|
||||||
|
|
||||||
/* 🔧 连线(禁止吞事件) */
|
.tree-scroll { flex: 1; height: 0; overflow: hidden; }
|
||||||
.cn-line { position: absolute; height: 2px; pointer-events: none; }
|
.tree-root { padding: 16px 12px 20px; }
|
||||||
.cn-line-solid { background: #9ca3af; }
|
.tree-legend {
|
||||||
.cn-line-dashed { background: repeating-linear-gradient(90deg, #fdba74 0, #fdba74 6px, transparent 6px, transparent 10px); }
|
display: flex;
|
||||||
/* 🔧 连线箭头(禁止吞事件) */
|
flex-wrap: wrap;
|
||||||
.cn-arrow { position: absolute; width: 0; height: 0; border-left: 6px solid #9ca3af; border-top: 4px solid transparent; border-bottom: 4px solid transparent; pointer-events: none; }
|
gap: 10px;
|
||||||
.cn-arrow-dashed { border-left-color: #fdba74; }
|
padding: 10px 16px 20px;
|
||||||
|
font-size: 11px;
|
||||||
/* 🔧 极简卡片 160x80 */
|
color: #6b7280;
|
||||||
.canvas-node { position: absolute; width: 160px; min-height: auto; padding: 10px 12px; background: #fff; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); border-left: 6px solid #3b82f6; z-index: 10; display: flex; flex-direction: column; gap: 4px; font-size: 11px; }
|
}
|
||||||
.canvas-node.node-sub { border-left-color: #7c3aed; }
|
.legend-item { padding: 2px 8px; background: #fff; border-radius: 6px; border: 1px solid #e5e7eb; }
|
||||||
.canvas-node.s-yellow { border-left-color: #f59e0b; }
|
.legend-main { color: #2563eb; font-weight: 700; }
|
||||||
.canvas-node.s-blue { border-left-color: #3b82f6; }
|
.legend-sub { color: #7c3aed; font-weight: 700; }
|
||||||
.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.4; }
|
|
||||||
.canvas-node.s-archived { border-left-color: #8b5cf6; opacity: 0.65; }
|
|
||||||
.cn-header { display: flex; align-items: center; justify-content: space-between; }
|
|
||||||
.badge-main { font-size: 9px; padding: 1px 6px; border-radius: 4px; background: #2563eb; color: #fff; font-weight: 700; }
|
|
||||||
.badge-sub { font-size: 9px; padding: 1px 6px; border-radius: 4px; background: #ede9fe; color: #7c3aed; font-weight: 700; }
|
|
||||||
.cn-badge-mini { font-size: 9px; padding: 1px 6px; border-radius: 4px; font-weight: 700; }
|
|
||||||
.cn-badge-mini.s-yellow { background: #fef3c7; color: #b45309; }
|
|
||||||
.cn-badge-mini.s-blue { background: #dbeafe; color: #1d4ed8; }
|
|
||||||
.cn-badge-mini.s-green { background: #dcfce7; color: #15803d; }
|
|
||||||
.cn-badge-mini.s-red { background: #fce4ec; color: #be123c; }
|
|
||||||
.cn-badge-mini.s-archived { background: #ede9fe; color: #7c3aed; }
|
|
||||||
.cn-assignee { font-size: 10px; color: #6b7280; }
|
|
||||||
.cn-nested-hint { font-size: 8px; color: #a78bfa; font-weight: 600; }
|
|
||||||
.cn-warehouse-in { font-size: 9px; color: #059669; font-weight: 700; }
|
|
||||||
.cn-time { font-size: 9px; color: #9ca3af; }
|
|
||||||
.cn-records-mini { font-size: 9px; color: #2563eb; background: #eff6ff; padding: 1px 6px; border-radius: 6px; display: inline-block; width: max-content; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
<view :key="'prod-card-' + dictVersion">
|
<view :key="'prod-card-' + dictVersion">
|
||||||
<view class="overall-bar" @tap="handleOverallBarClick">
|
<view class="overall-bar" @tap="handleOverallBarClick">
|
||||||
<text class="overall-label">宏观状态</text>
|
<text class="overall-label">宏观状态</text>
|
||||||
<text :class="['overall-val', product.overall_status ? '' : 'overall-empty']">{{ product.overall_status || '未激活 — 点击发起首道工序' }}</text>
|
<text :class="['overall-val', overallStatusClass(product.overall_status)]">{{ product.overall_status || '未激活 — 点击发起首道工序' }}</text>
|
||||||
<text v-if="product.task_tree && product.task_tree.length && canEditOverallStatus" class="overall-arrow">▾</text>
|
<text v-if="product.task_tree && product.task_tree.length && canEditOverallStatus" class="overall-arrow">▾</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@ -58,7 +58,9 @@
|
|||||||
@viewRecords="handleViewRecords" />
|
@viewRecords="handleViewRecords" />
|
||||||
|
|
||||||
<!-- 🌳 流转树:全屏独立视图 -->
|
<!-- 🌳 流转树:全屏独立视图 -->
|
||||||
<TreeCanvas v-if="currentMode === 'tree'" :product="product" :key="'tc-' + dictVersion" @viewRecords="handleViewRecords" @back="currentMode = 'workspace'" @swipe="currentMode = 'swipe'" />
|
<TreeCanvas v-if="currentMode === 'tree'" :product="product" :key="'tc-' + dictVersion"
|
||||||
|
:currentUser="currentUser" :currentUserId="currentUserId" :currentUsername="currentUsername"
|
||||||
|
@viewRecords="handleViewRecords" @back="currentMode = 'workspace'" @swipe="currentMode = 'swipe'" />
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -219,7 +221,7 @@ import WorkspaceArea from "./components/WorkspaceArea.vue";
|
|||||||
import TreeCanvas from "./components/TreeCanvas.vue";
|
import TreeCanvas from "./components/TreeCanvas.vue";
|
||||||
import TaskSwipeCards from "./components/TaskSwipeCards.vue";
|
import TaskSwipeCards from "./components/TaskSwipeCards.vue";
|
||||||
|
|
||||||
const OVERALL_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
|
const OVERALL_OPTIONS = ["备货", "生产", "测试", "维修", "在库", "已入库", "已出库"];
|
||||||
const TASK_NAME_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
|
const TASK_NAME_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
|
||||||
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" };
|
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" };
|
||||||
|
|
||||||
@ -319,6 +321,14 @@ export default {
|
|||||||
toggleMode() { if (this.currentMode === 'workspace') this.currentMode = 'swipe'; else if (this.currentMode === 'swipe') this.currentMode = 'tree'; else this.currentMode = 'workspace'; },
|
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 {} },
|
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 {} },
|
||||||
|
// 宏观状态语义色:已入库=灰,已出库=靛蓝,待仓库收货=橙,其余默认蓝
|
||||||
|
overallStatusClass(status) {
|
||||||
|
if (!status) return 'overall-empty';
|
||||||
|
if (status === '已入库') return 'overall-archived';
|
||||||
|
if (status === '已出库') return 'overall-outbound';
|
||||||
|
if (status === '待仓库收货') return 'overall-warehouse';
|
||||||
|
return '';
|
||||||
|
},
|
||||||
openEditProduct() { this.editForm = { order_no: this.product.order_no || "", external_serial: this.product.external_serial || "" }; this.editProductVisible = true; },
|
openEditProduct() { this.editForm = { order_no: this.product.order_no || "", external_serial: this.product.external_serial || "" }; this.editProductVisible = true; },
|
||||||
async doEditProduct() { this.editSaving = true; try { this.product = await patch(`/products/${this.product.id}`, { order_no: this.editForm.order_no.trim(), external_serial: this.editForm.external_serial.trim() }); uni.showToast({ title: "已保存", icon: "success" }); this.editProductVisible = false; } catch {} finally { this.editSaving = false; } },
|
async doEditProduct() { this.editSaving = true; try { this.product = await patch(`/products/${this.product.id}`, { order_no: this.editForm.order_no.trim(), external_serial: this.editForm.external_serial.trim() }); uni.showToast({ title: "已保存", icon: "success" }); this.editProductVisible = false; } catch {} finally { this.editSaving = false; } },
|
||||||
|
|
||||||
@ -476,6 +486,9 @@ export default {
|
|||||||
.overall-label { font-size: 13px; color: #6b7280; }
|
.overall-label { font-size: 13px; color: #6b7280; }
|
||||||
.overall-val { font-size: 15px; font-weight: 700; color: #2563eb; flex: 1; }
|
.overall-val { font-size: 15px; font-weight: 700; color: #2563eb; flex: 1; }
|
||||||
.overall-empty { color: #ef4444; }
|
.overall-empty { color: #ef4444; }
|
||||||
|
.overall-archived { color: #6b7280; } /* 已入库:灰 */
|
||||||
|
.overall-outbound { color: #4f46e5; } /* 已出库:靛蓝 */
|
||||||
|
.overall-warehouse { color: #ea580c; } /* 待仓库收货:橙 */
|
||||||
.overall-arrow { font-size: 12px; color: #9ca3af; }
|
.overall-arrow { font-size: 12px; color: #9ca3af; }
|
||||||
.card { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); flex-shrink: 0; min-height: 120px; }
|
.card { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); flex-shrink: 0; min-height: 120px; }
|
||||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; gap: 6px; }
|
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; gap: 6px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user