feat: 移动端流转卡片组件 + 静态资源

This commit is contained in:
2026-08-07 11:53:40 +08:00
parent 41dfc53f83
commit 43baa1de8e
2 changed files with 290 additions and 0 deletions

View File

@ -0,0 +1,290 @@
<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">{{ 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>
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 traverse = (tasks, prefix) => {
if (!tasks) return;
let spawnIndex = 0;
tasks.forEach((t) => {
const parent = t.parent_task_id ? flatMap[t.parent_task_id] : null;
const isMain = !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY'
|| (!t.task_type && parent && (parent.status === 'COMPLETED' || parent.status === 'CANCELED'));
if (isMain) { 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;
// 泳道0主分支
const mainLane = { _key: 'main', label: '主分支', cards: [], _cardIdx: 0 };
const sortTasks = (tasks) => {
if (!tasks) return [];
return [...tasks].sort((a, b) => {
const aMain = (!a.parent_task_id || a.task_type === 'TRANSFER' || a.task_type === 'RECOVERY') ? -1 : 1;
const bMain = (!b.parent_task_id || b.task_type === 'TRANSFER' || b.task_type === 'RECOVERY') ? -1 : 1;
if (aMain !== bMain) return aMain - bMain;
return 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) {
const isMain = !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY'
|| (!t.task_type && t.parent_task_id);
if (isMain) {
lane.cards.push({ ...t, _key: t.id + '_' + lane._key, _isMain: lane._key === 'main' });
// 递归:本泳道下一步 = TRANSFER 子节点
if (t.child_tasks && t.child_tasks.length) {
const nextMain = t.child_tasks.filter(c =>
c.task_type === 'TRANSFER' || c.task_type === 'RECOVERY' || !c.task_type);
followMain(nextMain, lane);
// SPAWN 子节点 → 新泳道
const spawns = t.child_tasks.filter(c => c.task_type === 'SPAWN');
for (const sc of spawns) {
const blabel = this.branchLabelMap[sc.id] || '分支';
const branchLane = { _key: 'branch_' + sc.id, label: blabel, cards: [], _cardIdx: 0 };
branchLane.cards.push({ ...sc, _key: sc.id + '_' + branchLane._key, _isMain: false });
if (sc.child_tasks && sc.child_tasks.length) {
followMain(sc.child_tasks, branchLane);
}
result.push(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: {
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; }
.ss-swiper-v { width: 100%; }
.ss-card-wrapper { display: flex; align-items: flex-start; justify-content: center; padding: 10px 16px; height: 100%; 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB