Files
track/track-uniapp/src/pages/scan/components/TaskSwipeCards.vue
duxingchen 6c93b98190 fix: 移动端全览树/卡片模式兼容 WAREHOUSE 仓储节点
- WorkspaceArea/TaskSwipeCards 的 isMainFn 与主线子任务过滤加 WAREHOUSE
- 扫码入库/出库节点回到中央主干道,不再渲染在左侧分支
- TaskSwipeCards 操作人防御性渲染:仓储任务显示 📦 MOM 仓储系统,不崩不丢
2026-09-01 17:42:57 +08:00

301 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="swipe-fullscreen">
<!-- 顶部栏 -->
<view class="ss-topbar">
<view class="ss-back" @tap="$emit('back')">← 返回</view>
<view class="ss-title-group">
<text class="ss-title">{{ lanes[currentLane].label }}</text>
<text class="ss-step-hint">
步骤 {{ getCardIdx(lanes[currentLane]._key) + 1 }}/{{ lanes[currentLane].cards.length }}
<text v-if="lanes.length > 1"> · ← 左右滑切换分支 →</text>
</text>
</view>
<view class="ss-topbar-right">
<view class="ss-overview-btn" @tap="$emit('overview')">⊡ 全览</view>
</view>
</view>
<!-- 🚀 水平滑动:切换分支(主分支 / 分支1 / 分支2 ...) -->
<swiper class="ss-swiper-h" :current="currentLane" @change="onLaneSwipe"
:style="{ height: swiperHeight + 'px' }" duration="250">
<swiper-item v-for="(lane, li) in lanes" :key="lane._key">
<!-- 🚀 垂直滑动:当前分支的时间线 -->
<swiper class="ss-swiper-v" :current="getCardIdx(lane._key)" @change="onCardSwipe($event, li)"
duration="200" vertical :style="{ height: swiperHeight + 'px' }">
<swiper-item v-for="(card, ci) in lane.cards" :key="card._key">
<view class="ss-card-wrapper">
<view class="ss-card task-card"
:class="[card._isMain ? 'task-card-main' : 'task-card-branch', taskCardClass(card)]">
<view v-if="card._isMain" class="tc-ribbon tc-ribbon-main">主分支</view>
<view v-else class="tc-ribbon tc-ribbon-sub">{{ lane.label }}</view>
<view class="tc-head">
<view class="tc-head-left">
<text :class="card._isMain ? 'badge-main' : 'badge-sub'">{{ card._isMain ? '主分支' : lane.label }}</text>
<text v-if="card.is_rework" class="tag-rework-sm">⚠ 返工</text>
<text v-if="card.status === 'ARCHIVED'" class="tag-archived-sm">📦 入库</text>
</view>
<text :class="['tc-status', statusColor(card.status)]">{{ statusLabel(card.status) }}</text>
</view>
<text class="tc-name">{{ card.task_name }}</text>
<view class="tc-meta">
<view class="tc-meta-row">
<!-- 仓储任务:assignee 为 null,直接显示系统名,不取用户头像 -->
<text v-if="isWarehouseTask(card)" class="tc-meta-label">📦 仓储</text>
<text v-else class="tc-meta-label">👤 负责人</text>
<text v-if="isWarehouseTask(card)" class="tc-meta-val">MOM 仓储系统</text>
<text v-else class="tc-meta-val">{{ formatUserName(card.assignee_id) || '未分配' }}</text>
</view>
</view>
<view v-if="card.remark" class="tc-remark-box">
<text class="tc-remark-label">📌 备注</text>
<text class="tc-remark-text">{{ card.remark }}</text>
</view>
<!-- 提交记录:显示总数 + 可点击查看全部 -->
<view v-if="card.records && card.records.length" class="tc-records-link"
@tap.stop="$emit('viewRecords', card)">
<text class="tc-records-icon">📋</text>
<text class="tc-records-count">共 {{ card.records.length }} 条提交记录</text>
<text class="tc-records-arrow">查看全部 ›</text>
</view>
<view class="tc-stats">
<text v-if="card.received_at" class="tc-stat">✅ 已接收</text>
<text v-if="card.completed_at" class="tc-stat">🏁 已完工</text>
</view>
<view class="tc-time"><text>创建: {{ fmtTime(card.created_at) }}</text></view>
</view>
</view>
</swiper-item>
</swiper>
</swiper-item>
</swiper>
<!-- 底部:分支标签条 + 步骤进度 -->
<view class="ss-footer">
<view class="ss-lane-tabs">
<view v-for="(lane, i) in lanes" :key="'lt'+i"
:class="['ss-lane-tab', i === currentLane ? 'ss-lane-active' : '']"
@tap="currentLane = i">
<text :class="i === 0 ? 'ss-lane-tab-main' : 'ss-lane-tab-sub'">{{ lane.label }}</text>
<text class="ss-lane-count">{{ lane.cards.length }}</text>
</view>
</view>
<view class="ss-dots">
<view v-for="(c, i) in lanes[currentLane].cards" :key="'d'+i"
:class="['ss-dot', i === getCardIdx(lanes[currentLane]._key) ? 'ss-dot-active' : '', c._isMain ? 'ss-dot-main' : '']">
<text class="ss-dot-label">{{ i + 1 }}</text>
</view>
</view>
</view>
</view>
</template>
<script>
import { formatUserName } from "../../../utils/format";
export default {
name: "TaskSwipeCards",
props: {
product: { type: Object, required: true },
},
emits: ["back", "overview", "viewRecords"],
data() {
return {
currentLane: 0,
cardIndices: {}, // { laneKey: currentCardIndex } — 独立管理避免computed重置
swiperHeight: 600,
};
},
watch: {
// product 变化时重置所有滑动位置
product: {
immediate: true,
handler() { this.cardIndices = {}; },
},
},
computed: {
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 isMainFn = (t) => !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY' || t.task_type === 'WAREHOUSE';
const traverse = (tasks, prefix) => {
if (!tasks) return;
let spawnIndex = 0;
tasks.forEach((t) => {
if (isMainFn(t)) { map[t.id] = '主分支'; traverse(t.child_tasks, prefix); }
else { spawnIndex++; const num = prefix ? `${prefix}.${spawnIndex}` : `${spawnIndex}`; map[t.id] = `分支 ${num}`; traverse(t.child_tasks, num); }
});
};
this.product.task_tree.forEach((t) => { map[t.id] = '主分支'; traverse(t.child_tasks, ''); });
return map;
},
lanes() {
const result = [];
if (!this.product || !this.product.task_tree) return result;
const mainLane = { _key: 'main', label: '主分支', cards: [] };
const sortTasks = (tasks) => {
if (!tasks) return [];
return [...tasks].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
};
// 🚀 终极递归收集算法
const followMain = (taskList, lane) => {
if (!taskList || !taskList.length) return;
const sorted = sortTasks(taskList);
for (const t of sorted) {
// 1. 无条件入列:既然进到了这个 lane,就属于这个 lane 的卡片
lane.cards.push({ ...t, _key: t.id + '_' + lane._key, _isMain: lane._key === 'main' });
if (t.child_tasks && t.child_tasks.length) {
// 1. 同一分支线性延续(含仓储任务)
const nextOnThisLane = t.child_tasks.filter(c =>
c.task_type === 'TRANSFER' || c.task_type === 'RECOVERY' || c.task_type === 'WAREHOUSE'
);
followMain(nextOnThisLane, lane);
// 2. 凡是 SPAWN,必定开辟新分支 (不限层级)
const spawns = t.child_tasks.filter(c => c.task_type === 'SPAWN');
for (const sc of spawns) {
const blabel = (this.branchLabelMap && this.branchLabelMap[sc.id]) || '协助分支';
const branchLane = { _key: 'branch_' + sc.id, label: blabel, cards: [] };
result.push(branchLane); // 先占坑:父分支排在前面
followMain([sc], branchLane); // 再递归:孙子分支自然排在后面
}
}
}
};
result.push(mainLane);
followMain(this.product.task_tree, mainLane);
return result;
},
},
mounted() {
try {
const info = uni.getSystemInfoSync();
this.swiperHeight = (info.windowHeight || 600) - 130;
} catch (e) { /* ignore */ }
},
methods: {
formatUserName,
// ★ 仓储系统任务(扫码入库/扫码出库):assignee 为 null,防御性渲染
isWarehouseTask(t) {
const name = String((t && t.task_name) || "");
return name.includes("扫码") || (t && t.task_type === "WAREHOUSE");
},
getCardIdx(laneKey) { return this.cardIndices[laneKey] || 0; },
setCardIdx(laneKey, idx) { this.$set(this.cardIndices, laneKey, idx); },
onLaneSwipe(e) { this.currentLane = e.detail.current; },
onCardSwipe(e, laneIdx) {
const lane = this.lanes[laneIdx];
if (lane) {
this.setCardIdx(lane._key, e.detail.current);
}
},
taskCardClass(t) {
if (t.status === 'ARCHIVED') return 'card-archived';
if (t.status === 'CANCELED') return 'card-canceled';
return '';
},
statusLabel(s) { const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" }; return map[s] || s; },
statusColor(s) { switch (s) { case "PENDING": return "sc-yellow"; case "WIP": return "sc-blue"; case "COMPLETED": return "sc-green"; case "REJECTED": return "sc-red"; case "CANCELED": return "sc-canceled"; case "ARCHIVED": return "sc-purple"; default: return "sc-gray"; } },
fmtTime(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>
.ss-fullscreen { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 100; display: flex; flex-direction: column; background: #e8ecf0; }
/* 顶部栏 */
.ss-topbar { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; padding-top: calc(8px + 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; }
.ss-back { font-size: 13px; font-weight: 700; color: #2563eb; padding: 5px 10px; background: #eff6ff; border-radius: 8px; }
.ss-title-group { display: flex; flex-direction: column; align-items: center; gap: 0; flex: 1; }
.ss-title { font-size: 15px; font-weight: 800; color: #1f2937; }
.ss-step-hint { font-size: 10px; color: #9ca3af; }
.ss-topbar-right { display: flex; gap: 6px; }
.ss-overview-btn { font-size: 12px; font-weight: 700; color: #2563eb; padding: 5px 10px; background: #dbeafe; border-radius: 8px; }
/* 滑动区域 */
.ss-swiper-h { width: 100%; flex: 1; min-height: 220px; }
.ss-swiper-v { width: 100%; }
.ss-card-wrapper { display: flex; align-items: flex-start; justify-content: center; padding: 10px 16px; height: 100%; min-height: 220px; box-sizing: border-box; }
/* 任务卡片 */
.ss-card { position: relative; width: 100%; max-width: 420px; background: #fff; border-radius: 20px; padding: 18px 22px; box-shadow: 0 8px 24px rgba(0,0,0,0.1); display: flex; flex-direction: column; gap: 10px; max-height: 100%; overflow-y: auto; }
.task-card { border-left: 10px solid #3b82f6; }
.task-card-main { border-left-color: #2563eb; border-left-width: 12px; }
.task-card-branch { border-left-color: #7c3aed; }
.task-card.card-archived { border-left-color: #8b5cf6; opacity: 0.75; }
.task-card.card-canceled { border-left-color: #9ca3af; opacity: 0.5; }
.tc-ribbon { position: absolute; top: 14px; right: 14px; font-size: 10px; padding: 3px 10px; border-radius: 6px; color: #fff; font-weight: 700; z-index: 2; }
.tc-ribbon-main { background: #2563eb; }
.tc-ribbon-sub { background: #7c3aed; }
.tc-head { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; }
.tc-head-left { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.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; }
.tag-rework-sm { font-size: 10px; padding: 2px 6px; border-radius: 6px; background: #ef4444; color: #fff; font-weight: 700; }
.tag-archived-sm { font-size: 10px; padding: 2px 6px; border-radius: 6px; background: #ede9fe; color: #7c3aed; font-weight: 700; border: 1px dashed #a78bfa; }
.tc-status { font-size: 12px; padding: 3px 10px; border-radius: 10px; font-weight: 700; }
.tc-status.sc-yellow { background: #fef3c7; color: #b45309; }
.tc-status.sc-blue { background: #dbeafe; color: #1d4ed8; }
.tc-status.sc-green { background: #dcfce7; color: #15803d; }
.tc-status.sc-red { background: #fce4ec; color: #be123c; }
.tc-status.sc-purple { background: #ede9fe; color: #7c3aed; }
.tc-status.sc-canceled { background: #f3f4f6; color: #9ca3af; text-decoration: line-through; }
.tc-name { font-size: 20px; font-weight: 800; color: #1f2937; line-height: 1.3; }
.tc-meta { display: flex; flex-direction: column; gap: 4px; }
.tc-meta-row { display: flex; align-items: center; gap: 8px; }
.tc-meta-label { font-size: 13px; color: #9ca3af; }
.tc-meta-val { font-size: 14px; color: #374151; font-weight: 600; }
.tc-remark-box { padding: 10px 12px; border-radius: 10px; display: flex; flex-direction: column; gap: 3px; background: #fefce8; border: 1px solid #fef08a; }
.tc-remark-label { font-size: 12px; font-weight: 700; color: #a16207; }
.tc-remark-text { font-size: 14px; color: #374151; line-height: 1.5; }
/* 提交记录链接 */
.tc-records-link { display: flex; align-items: center; gap: 6px; padding: 10px 14px; background: linear-gradient(135deg, #eff6ff, #dbeafe); border-radius: 10px; border: 1px solid #bfdbfe; }
.tc-records-icon { font-size: 16px; }
.tc-records-count { flex: 1; font-size: 13px; font-weight: 700; color: #2563eb; }
.tc-records-arrow { font-size: 12px; color: #2563eb; font-weight: 600; }
.tc-stats { display: flex; flex-wrap: wrap; gap: 6px; }
.tc-stat { font-size: 11px; padding: 3px 8px; border-radius: 8px; background: #f3f4f6; color: #6b7280; font-weight: 600; }
.tc-time { font-size: 11px; color: #9ca3af; padding-top: 6px; border-top: 1px solid #f3f4f6; }
/* 底部 */
.ss-footer { flex-shrink: 0; background: rgba(255,255,255,0.95); padding-bottom: calc(6px + env(safe-area-inset-bottom)); }
.ss-lane-tabs { display: flex; gap: 4px; padding: 6px 12px; overflow-x: auto; }
.ss-lane-tab { display: flex; align-items: center; gap: 4px; padding: 5px 12px; border-radius: 14px; background: #f3f4f6; font-size: 12px; font-weight: 600; white-space: nowrap; flex-shrink: 0; }
.ss-lane-tab-main { color: #2563eb; }
.ss-lane-tab-sub { color: #7c3aed; }
.ss-lane-active { background: #2563eb; }
.ss-lane-active .ss-lane-tab-main { color: #fff; }
.ss-lane-active .ss-lane-tab-sub { color: #ddd6fe; }
.ss-lane-count { font-size: 10px; color: #9ca3af; background: #fff; padding: 1px 6px; border-radius: 8px; }
.ss-lane-active .ss-lane-count { color: #2563eb; }
.ss-dots { display: flex; justify-content: center; gap: 5px; padding: 4px 0; overflow-x: auto; }
.ss-dot { width: 20px; height: 20px; border-radius: 10px; background: #e5e7eb; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: all 0.2s; }
.ss-dot-active { width: 26px; height: 26px; border-radius: 13px; background: #2563eb; }
.ss-dot-main { background: #bfdbfe; }
.ss-dot-label { font-size: 9px; font-weight: 700; color: #9ca3af; }
.ss-dot-active .ss-dot-label { color: #fff; }
</style>