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 端点
282 lines
14 KiB
Vue
282 lines
14 KiB
Vue
<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>
|