Files
track/track-uniapp/src/pages/scan/components/TaskSwipeCards.vue
duxingchen 30a48d90fc fix(frontend): 流转蓝图 + 聊天室 + 卡片布局 + 打印功能修复
1. TreeCanvas.vue - Y轴坐标算法重构:
   - calcSubtreeHeight 改为兄弟累加 (reduce) 替代 Math.max,彻底杜绝垂直重叠
   - childNode.y = parentNode.y + CARD_H + VERTICAL_GAP 严格向下延伸
   - 同级子节点按子树高度累加 startY,各占独立 Y 区域
   - 加宽画布默认尺寸 (400→800, padding +300/+400)

2. detail.vue - onShow 生命周期修复:
   - 新增 onShow() 钩子调用 fetchMessages(),解决聊天室退回不刷新问题

3. TaskSwipeCards.vue + detail.vue - CSS 防跳动:
   - .ss-swiper-h / .ss-card-wrapper 添加 min-height: 220px
   - .card 添加 min-height: 120px

4. detail.vue - 移动端打印标签:
   - 新增「🖨️ 打印标签」按钮
   - 双方案 ActionSheet: 网络打印机(后端API) / 蓝牙打印机(ESC/POS)
   - 对接现有 /print/execute 端点
2026-08-11 15:05:39 +08:00

282 lines
14 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">
步骤 {{ lanes[currentLane]._cardIdx + 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="lane._cardIdx" @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">
<text class="tc-meta-label">👤 负责人</text>
<text 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 === lanes[currentLane]._cardIdx ? '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,
swiperHeight: 600,
};
},
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';
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: [], _cardIdx: 0 };
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'
);
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: [], _cardIdx: 0 };
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,
onLaneSwipe(e) { this.currentLane = e.detail.current; },
onCardSwipe(e, laneIdx) {
if (this.lanes[laneIdx]) {
this.$set(this.lanes[laneIdx], '_cardIdx', 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>