修复remark丢失(_to_response漏字段) + UI重构swiper专注卡片看板

This commit is contained in:
2026-08-05 16:35:14 +08:00
parent 4ee3cb7552
commit e2d9590c48
2 changed files with 161 additions and 66 deletions

View File

@ -104,6 +104,7 @@ def _to_response(task: Task) -> TaskResponse:
status=task.status,
notify_parent_on_complete=task.notify_parent_on_complete,
is_rework=task.is_rework,
remark=task.remark,
reject_reason=task.reject_reason,
received_at=task.received_at,
completed_at=task.completed_at,

View File

@ -54,57 +54,98 @@
</view>
<!-- ================================================================ -->
<!-- 任务撑满剩余空间可滚动 -->
<!-- 任务卡片看板 (专注模式 + 左右滑动) -->
<!-- ================================================================ -->
<view class="tree-scroll-area">
<view class="card card-tree">
<view class="card-header">
<text class="card-title">🌿 任务流转树</text>
<text class="task-count">{{ countTasks(product.task_tree) }} 个任务</text>
</view>
<!-- 0任务 发起首道工序 -->
<view class="swiper-area">
<!-- 0任务 -->
<view v-if="!product.task_tree || !product.task_tree.length" class="zero-task">
<text class="zero-icon">📋</text>
<text class="zero-text">该产品暂无流转任务</text>
<button class="btn-start" @tap="openCreateFirstTask">🚀 发起首道工序</button>
</view>
<!-- 递归树 -->
<TaskTreeNode
v-for="(task, idx) in (product.task_tree || [])"
:key="task.id"
:task="task"
:currentUser="currentUser"
:currentUserId="currentUserId"
:currentUsername="currentUsername"
:hasChildren="task.child_tasks && task.child_tasks.length > 0"
:isLast="idx === (product.task_tree || []).length - 1"
@action="handleTaskAction"
@editRecord="openEditRecord"
@viewRecords="handleViewRecords"
/>
<!-- 无本人任务但有其他任务 -->
<view v-else-if="!focusTasks.length" class="zero-task">
<text class="zero-icon">🔒</text>
<text class="zero-text">当前没有需要你处理的任务</text>
<text class="task-count"> {{ countTasks(product.task_tree) }} 个任务</text>
</view>
<!-- Swiper 卡片 -->
<template v-else>
<view class="swipe-nav">
<text class="swipe-info">{{ swiperCurrent + 1 }} / {{ focusTasks.length }}</text>
<text class="swipe-hint"> 左右滑动切换任务 </text>
</view>
<swiper
class="task-swiper"
:current="swiperCurrent"
@change="onSwiperChange"
:indicator-dots="false"
:circular="false"
>
<swiper-item v-for="(t, i) in focusTasks" :key="t.id">
<view class="focus-card" :class="statusColor(t.status)">
<!-- 卡片头 -->
<view class="fc-header">
<text class="fc-status">{{ statusLabel(t.status) }}</text>
<text v-if="t.is_rework" class="tag tag-rework-sm">返工</text>
</view>
<!-- 任务名 -->
<text class="fc-name">{{ t.task_name }}</text>
<!-- remark初始说明 -->
<view v-if="t.remark" class="fc-remark">
<text class="fc-remark-label">📌</text>
<text class="fc-remark-text">{{ t.remark }}</text>
</view>
<!-- 来源/去向 -->
<view class="fc-chain">
<view v-if="t.parent_task_id && parentTaskOf(t)" class="fc-link fc-up">
<text class="fc-link-label"> 上一步</text>
<text class="fc-link-name">{{ parentTaskOf(t).task_name }}</text>
<text class="fc-link-who"> {{ parentTaskOf(t).assignee_id || '—' }}</text>
</view>
<view v-if="t.child_tasks && t.child_tasks.length" class="fc-link fc-down">
<text class="fc-link-label"> 下一步 ({{ t.child_tasks.length }})</text>
<text v-for="c in t.child_tasks" :key="c.id" class="fc-link-name">· {{ c.task_name }} {{ c.assignee_id || '' }}</text>
</view>
</view>
<!-- 最新记录 -->
<view v-if="t.records && t.records.length" class="fc-records-bar" @tap="handleViewRecords(t)">
📋 {{ t.records.length }} 条记录
</view>
<!-- 时间 -->
<view class="fc-time">{{ formatTaskTime(t) }}</view>
</view>
</swiper-item>
</swiper>
</template>
</view>
<!-- ================================================================ -->
<!-- 底部操作栏钉死不随滚动 -->
<!-- ================================================================ -->
<view v-if="myActiveTask" class="footer-actions">
<button v-if="myActiveTask.status === 'WIP'" class="footer-btn footer-transfer"
@tap="handleTaskAction({ task: myActiveTask, type: 'transfer' })">
<view v-if="currentTask" class="footer-actions">
<button v-if="currentTask.status === 'WIP'" class="footer-btn footer-transfer"
@tap="handleTaskAction({ task: currentTask, type: 'transfer' })">
🔄 完工转交
</button>
<button v-if="myActiveTask.status === 'WIP'" class="footer-btn footer-record"
@tap="handleTaskAction({ task: myActiveTask, type: 'record' })">
<button v-if="currentTask.status === 'WIP'" class="footer-btn footer-record"
@tap="handleTaskAction({ task: currentTask, type: 'record' })">
📝 记录/拍照
</button>
<button v-if="myActiveTask.status === 'PENDING'" class="footer-btn footer-receive"
@tap="handleTaskAction({ task: myActiveTask, type: 'receive' })">
<button v-if="currentTask.status === 'PENDING'" class="footer-btn footer-receive"
@tap="handleTaskAction({ task: currentTask, type: 'receive' })">
接收任务
</button>
<button v-if="myActiveTask.status === 'PENDING'" class="footer-btn footer-reject"
@tap="handleTaskAction({ task: myActiveTask, type: 'reject' })">
<button v-if="currentTask.status === 'PENDING'" class="footer-btn footer-reject"
@tap="handleTaskAction({ task: currentTask, type: 'reject' })">
驳回任务
</button>
</view>
@ -351,6 +392,7 @@ export default {
currentUser: null,
currentUserId: "",
currentUsername: "",
swiperCurrent: 0,
// Picker 数据源
processOptions: [],
userOptions: [],
@ -378,38 +420,34 @@ export default {
return !!b.assignee_id;
});
},
activeTask() {
// 查找树中第一个 WIP 或 PENDING 任务(不限归属)
const findActive = (tasks) => {
if (!tasks) return null;
for (const t of tasks) {
if (t.status === 'WIP' || t.status === 'PENDING') return t;
if (t.child_tasks && t.child_tasks.length) {
const found = findActive(t.child_tasks);
if (found) return found;
}
}
return null;
// 构建 parent_task_id → task 的索引
taskMap() {
const m = {};
const walk = (tasks) => {
if (!tasks) return;
for (const t of tasks) { m[t.id] = t; walk(t.child_tasks); }
};
return this.product ? findActive(this.product.task_tree) : null;
if (this.product) walk(this.product.task_tree);
return m;
},
myActiveTask() {
// 只返回当前登录人自己的 WIP/PENDING 任务
const findMine = (tasks) => {
if (!tasks) return null;
// 只返回当前用户需要处理的活跃任务 (PENDING/WIP)
focusTasks() {
const result = [];
const walk = (tasks) => {
if (!tasks) return;
for (const t of tasks) {
const isMine = t.status === 'WIP' || t.status === 'PENDING';
const matchId = t.assignee_id == this.currentUserId;
const matchName = t.assignee_id == this.currentUsername;
if (isMine && (matchId || matchName)) return t;
if (t.child_tasks && t.child_tasks.length) {
const found = findMine(t.child_tasks);
if (found) return found;
if (isMine && (matchId || matchName)) result.push(t);
walk(t.child_tasks);
}
}
return null;
};
return this.product ? findMine(this.product.task_tree) : null;
if (this.product) walk(this.product.task_tree);
return result;
},
currentTask() {
return this.focusTasks[this.swiperCurrent] || null;
},
},
onLoad(options) {
@ -709,6 +747,26 @@ export default {
handleViewRecords(task) {
uni.navigateTo({ url: `/pages/scan/records?taskId=${task.id}` });
},
onSwiperChange(e) {
this.swiperCurrent = e.detail.current;
},
parentTaskOf(t) {
if (!t || !t.parent_task_id) return null;
return this.taskMap[t.parent_task_id] || null;
},
formatTaskTime(t) {
if (!t) return "";
const parts = [];
if (t.created_at) parts.push("创建: " + this.formatTime(t.created_at));
if (t.received_at) parts.push("接收: " + this.formatTime(t.received_at));
return parts.join(" | ");
},
formatTime(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())}`;
},
closeActionPopup() { this.actionPopup = { visible: false, type: "", task: null }; },
async doReceive() {
@ -795,10 +853,46 @@ export default {
/* 卡片 */
.card { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); flex-shrink: 0; }
/* 任务树滚动区域 → 撑满剩余空间 */
.tree-scroll-area { flex: 1; overflow-y: auto; min-height: 0; -webkit-overflow-scrolling: touch; }
.card-tree { margin-bottom: 0; }
.card-tree .card-header { flex-shrink: 0; }
/* Swiper 卡片看板 */
.swiper-area { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
.swipe-nav { display: flex; align-items: center; justify-content: space-between; padding: 8px 4px; flex-shrink: 0; }
.swipe-info { font-size: 12px; font-weight: 700; color: #2563eb; }
.swipe-hint { font-size: 11px; color: #9ca3af; }
.task-swiper { flex: 1; min-height: 0; }
.task-count { font-size: 12px; color: #9ca3af; display: block; margin-top: 4px; }
.focus-card {
height: 100%; margin: 0 4px; padding: 20px 16px; border-radius: 16px;
background: #fff; box-shadow: 0 2px 12px rgba(0,0,0,0.08); overflow-y: auto;
border-top: 5px solid #3b82f6;
}
.focus-card.s-yellow { border-top-color: #f59e0b; }
.focus-card.s-blue { border-top-color: #3b82f6; }
.focus-card.s-green { border-top-color: #22c55e; }
.focus-card.s-red { border-top-color: #ef4444; }
.fc-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.fc-status { font-size: 12px; padding: 2px 10px; border-radius: 12px; font-weight: 700; background: #f3f4f6; color: #6b7280; }
.s-yellow .fc-status { background: #fef3c7; color: #b45309; }
.s-blue .fc-status { background: #dbeafe; color: #1d4ed8; }
.tag-rework-sm { font-size: 10px; padding: 2px 6px; border-radius: 8px; background: #ef4444; color: #fff; }
.fc-name { font-size: 22px; font-weight: 800; color: #1f2937; display: block; margin-bottom: 10px; }
.fc-remark { display: flex; gap: 6px; padding: 10px 12px; background: #fefce8; border-radius: 10px; margin-bottom: 10px; border-left: 4px solid #eab308; }
.fc-remark-label { font-size: 14px; flex-shrink: 0; }
.fc-remark-text { font-size: 14px; color: #713f12; line-height: 1.5; word-break: break-all; }
.fc-chain { margin-bottom: 8px; }
.fc-link { padding: 6px 10px; border-radius: 8px; margin-bottom: 4px; font-size: 12px; }
.fc-up { background: #f0fdf4; color: #16a34a; }
.fc-down { background: #eff6ff; color: #2563eb; }
.fc-link-label { font-weight: 700; display: block; }
.fc-link-name { display: block; margin-top: 2px; }
.fc-link-who { color: #9ca3af; font-size: 11px; }
.fc-records-bar { padding: 8px 12px; background: linear-gradient(135deg,#eff6ff,#dbeafe); border-radius: 8px; font-size: 13px; font-weight: 700; color: #2563eb; margin-bottom: 8px; }
.fc-time { font-size: 11px; color: #9ca3af; margin-top: auto; padding-top: 8px; border-top: 1px solid #f3f4f6; }
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.card-header-right { display: flex; align-items: center; gap: 8px; }
.card-title { font-size: 15px; font-weight: 700; }