feat: 移动端流转卡片组件 + 静态资源
This commit is contained in:
290
track-uniapp/src/pages/scan/components/TaskSwipeCards.vue
Normal file
290
track-uniapp/src/pages/scan/components/TaskSwipeCards.vue
Normal 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>
|
||||
BIN
track-uniapp/static/logo.png
Normal file
BIN
track-uniapp/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 MiB |
Reference in New Issue
Block a user