Files
track/track-uniapp/src/pages/scan/records.vue
duxingchen dd14185ab4 feat(APP): 记录页实现编辑记录弹窗,替代占位的「返回详情页」提示
- 点击✏️直接弹出编辑弹窗:修改备注文本 + 图片预览/删除/新增上传
- 保存走 PUT /records/{id} 更新,本地列表同步刷新
- 删除图片同样走5秒倒计时双重确认
2026-08-28 13:02:29 +08:00

364 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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="page">
<view v-if="loading" class="loading">加载中...</view>
<view v-if="error" class="error-box">{{ error }}</view>
<template v-if="!loading && task">
<!-- 任务信息头 -->
<view class="task-header">
<text class="task-name">{{ task.task_name }}</text>
<text class="task-meta">负责人: {{ task.assignee_id || '—' }} · {{ statusLabel(task.status) }}</text>
</view>
<!-- 记录时间轴 -->
<view v-if="records.length" class="timeline">
<view v-for="(rec, i) in records" :key="rec.id" class="tl-item">
<!-- 时间轴竖线 + 圆点 -->
<view class="tl-line">
<view class="tl-dot" :class="i === 0 ? 'tl-dot-latest' : ''" />
<view v-if="i < records.length - 1" class="tl-connector" />
</view>
<!-- 内容卡片 -->
<view class="tl-card">
<view class="tl-top">
<text class="tl-time">{{ formatTime(rec.created_at) }}</text>
<view v-if="canEdit" class="tl-actions">
<text class="tl-act" @tap="openEditRecord(rec)"></text>
<text class="tl-act" @tap="confirmDelete(rec)">🗑</text>
</view>
</view>
<text v-if="rec.remark" class="tl-remark">{{ rec.remark }}</text>
<view v-if="rec.images && rec.images.length" class="tl-images">
<image
v-for="(img, j) in rec.images"
:key="j"
:src="imageUrl(img)"
mode="aspectFill"
class="tl-thumb"
@tap="previewImage(rec.images, j)"
/>
</view>
</view>
</view>
</view>
<view v-else class="empty-timeline">
<text class="empty-icon">📭</text>
<text class="empty-text">暂无历史记录</text>
</view>
</template>
<!-- 🛡 双重确认删除5 秒倒计时防误触 -->
<view v-if="confirmDlg.visible" class="dlg-overlay" @tap="confirmDlgCancel">
<view class="dlg-box" @tap.stop>
<text class="dlg-title">{{ confirmDlg.title }}</text>
<text class="dlg-content">{{ confirmDlg.content }}</text>
<text class="dlg-tip"> 5 秒确认等待中请核对信息倒计时结束后确认按钮才可点击</text>
<view class="dlg-btns">
<button class="dlg-btn dlg-cancel" @tap="confirmDlgCancel">取消</button>
<button class="dlg-btn dlg-danger" :disabled="confirming === 'dlg' && confirmCount > 0" @tap="confirmDlgConfirm">{{ confirmLabel('删除') }}</button>
</view>
</view>
</view>
<!-- 编辑记录弹窗 -->
<view v-if="editVisible" class="edit-overlay" @tap="closeEditPopup">
<view class="edit-popup" @tap.stop>
<text class="edit-title"> 编辑记录</text>
<textarea v-model="editForm.remark" class="edit-textarea" placeholder="填写备注说明" :maxlength="2000" />
<view class="edit-imgs">
<view v-for="(img, i) in editForm.images" :key="i" class="edit-img-cell">
<image :src="imageUrl(img)" mode="aspectFill" class="edit-img" @tap="previewEditImage(i)" />
<text class="edit-img-del" @tap.stop="removeEditImage(i)"></text>
</view>
<view v-for="n in editForm.pendingCount" :key="'p'+n" class="edit-img-cell edit-img-loading"><text class="edit-img-loading-text"></text></view>
</view>
<button v-if="editForm.images.length + editForm.pendingCount < 9" class="edit-upload" @tap="handleChooseImage" :disabled="isUploading">{{ isUploading ? '上传中...' : '📷 拍照/选图' }}</button>
<view class="edit-btns">
<button class="edit-btn edit-cancel" @tap="closeEditPopup">取消</button>
<button class="edit-btn edit-save" :disabled="editSaving || isUploading" @tap="doSaveEdit">{{ editSaving ? '保存中...' : '保存' }}</button>
</view>
</view>
</view>
</view>
</template>
<script>
import request, { get, put, getBaseUrl } from "../../utils/request";
export default {
data() {
return {
loading: true,
error: "",
task: null,
records: [],
currentUser: null,
// 🛡️ 双重确认5 秒倒计时防误触)
confirmDlg: { visible: false, title: "", content: "", action: null },
confirming: "",
confirmCount: 5,
confirmTimer: null,
// ✏️ 编辑记录弹窗
editVisible: false,
editForm: { recordId: null, remark: "", images: [], pendingCount: 0 },
editSaving: false,
isUploading: false,
};
},
computed: {
canEdit() {
if (!this.currentUser || !this.task) return false;
// 已完成/已驳回/已入库/已撤回 的任务禁止编辑
const frozen = ["COMPLETED", "REJECTED", "ARCHIVED", "CANCELED"];
if (frozen.includes(this.task.status)) return false;
return (
this.currentUser.id == this.task.assignee_id ||
this.currentUser.username == this.task.assignee_id
);
},
},
onLoad(options) {
this.loadCurrentUser();
const taskId = options.taskId || "";
if (taskId) {
this.loadTask(taskId);
} else {
this.error = "缺少任务ID";
this.loading = false;
}
},
// 🚀 页面卸载:清理确认倒计时定时器
onUnload() { this.clearConfirm(); },
methods: {
loadCurrentUser() {
try {
let user = uni.getStorageSync("user");
if (typeof user === "string" && user) {
try { user = JSON.parse(user); } catch (e) { user = null; }
}
if (user && typeof user === "object") {
this.currentUser = user;
}
} catch {}
},
async loadTask(taskId) {
this.loading = true;
try {
// 通过产品扫码接口反向获取任务(后端可能没有单独的任务查询接口)
// 这里假设后端提供 GET /tasks/{taskId}
this.task = await get(`/tasks/${taskId}`);
this.records = this.task.records || [];
} catch {
this.error = "加载任务失败";
} finally {
this.loading = false;
}
},
imageUrl(url) {
if (!url) return "";
if (url.startsWith("http")) return url;
const domain = getBaseUrl().replace(/\/api.*$/, '');
return domain + (url.startsWith("/") ? url : "/" + url);
},
previewImage(urls, index) {
const fullUrls = (urls || []).map((u) => this.imageUrl(u));
uni.previewImage({ urls: fullUrls, current: index });
},
formatTime(t) {
if (!t) return "";
const d = new Date(t);
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
},
statusLabel(s) {
const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库" };
return map[s] || s;
},
// ✏️ 编辑记录:直接在当前页弹窗编辑备注与图片
openEditRecord(rec) {
this.editForm = { recordId: rec.id, remark: rec.remark || "", images: rec.images || [], pendingCount: 0 };
this.editVisible = true;
this.isUploading = false;
},
closeEditPopup() {
this.editVisible = false;
this.editForm = { recordId: null, remark: "", images: [], pendingCount: 0 };
},
previewEditImage(i) {
uni.previewImage({ urls: this.editForm.images.map((u) => this.imageUrl(u)), current: i });
},
removeEditImage(i) {
this.openConfirmDlg({ title: "删除图片", content: "确定删除这张图片吗?", action: () => { this.editForm.images.splice(i, 1); } });
},
async handleChooseImage() {
const maxSlots = 9 - (this.editForm.images.length + this.editForm.pendingCount);
if (maxSlots <= 0) return;
const chooseRes = await new Promise((resolve, reject) => {
uni.chooseImage({ count: maxSlots, sizeType: ["compressed"], sourceType: ["camera", "album"], success: resolve, fail: reject });
}).catch(() => null);
if (!chooseRes || !chooseRes.tempFilePaths || !chooseRes.tempFilePaths.length) return;
const compressedPaths = [];
for (const p of chooseRes.tempFilePaths) {
try {
const compressed = await new Promise((resolve, reject) => { uni.compressImage({ src: p, quality: 60, success: resolve, fail: reject }); });
compressedPaths.push(compressed.tempFilePath);
} catch {}
}
if (!compressedPaths.length) return;
this.isUploading = true;
this.editForm.pendingCount += compressedPaths.length;
for (const path of compressedPaths) {
const url = await this.uploadFile(path);
if (url) this.editForm.images.push(url);
this.editForm.pendingCount--;
}
this.isUploading = false;
},
uploadFile(filePath) {
return new Promise((resolve) => {
uni.uploadFile({
url: getBaseUrl() + "/upload/", filePath, name: "file",
success(res) { try { const data = JSON.parse(res.data); resolve(data.url || null); } catch { resolve(null); } },
fail: () => resolve(null),
});
});
},
async doSaveEdit() {
if (this.isUploading) return;
this.editSaving = true;
try {
const payload = { remark: this.editForm.remark.trim(), images: this.editForm.images };
await put(`/records/${this.editForm.recordId}`, payload);
uni.showToast({ title: "已保存", icon: "success" });
const idx = this.records.findIndex((r) => r.id === this.editForm.recordId);
if (idx >= 0) this.records[idx] = { ...this.records[idx], remark: payload.remark, images: payload.images };
this.closeEditPopup();
} catch (e) {
uni.showToast({ title: (e && e.data && e.data.detail) || "保存失败", icon: "none" });
} finally {
this.editSaving = false;
}
},
confirmDelete(rec) {
this.openConfirmDlg({ title: "删除记录", content: "确定删除这条记录吗?删除后不可恢复。", action: () => this.deleteRecord(rec) });
},
// ═══ 双重确认倒计时(防误触) ═══
openConfirmDlg({ title, content, action }) {
this.clearConfirm();
this.confirmDlg = { visible: true, title, content, action };
this.confirming = "dlg";
this.confirmCount = 5;
this.confirmTimer = setInterval(() => {
this.confirmCount -= 1;
if (this.confirmCount <= 0) clearInterval(this.confirmTimer);
}, 1000);
},
clearConfirm() {
if (this.confirmTimer) clearInterval(this.confirmTimer);
this.confirmTimer = null;
this.confirming = "";
this.confirmCount = 5;
},
confirmLabel(baseText) {
if (this.confirming === "dlg" && this.confirmCount > 0) return `${baseText} (${this.confirmCount}s)`;
return baseText;
},
confirmDlgConfirm() {
if (this.confirming === "dlg" && this.confirmCount > 0) return; // 倒计时中:忽略
if (this.confirming === "dlg") this.clearConfirm();
const action = this.confirmDlg.action;
this.confirmDlg.visible = false;
this.confirmDlg.action = null;
if (action) action();
},
confirmDlgCancel() {
this.clearConfirm();
this.confirmDlg.visible = false;
this.confirmDlg.action = null;
},
async deleteRecord(rec) {
try {
await request({ url: `/records/${rec.id}`, method: "DELETE" });
uni.showToast({ title: "已删除", icon: "success" });
this.records = this.records.filter((r) => r.id !== rec.id);
} catch {
uni.showToast({ title: "删除失败", icon: "none" });
}
},
},
};
</script>
<style scoped>
.page { min-height: 100vh; padding: 16px; padding-bottom: 40px; background: #f3f4f6; }
.loading { text-align: center; padding: 48px 0; color: #6b7280; }
.error-box { padding: 12px; border-radius: 10px; background: #fef2f2; color: #dc2626; font-size: 13px; }
/* 任务头 */
.task-header { background: #fff; border-radius: 12px; padding: 16px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.task-name { font-size: 18px; font-weight: 700; color: #1f2937; display: block; }
.task-meta { font-size: 13px; color: #9ca3af; margin-top: 4px; display: block; }
/* 时间轴 */
.timeline { padding-left: 8px; }
.tl-item { display: flex; gap: 12px; }
.tl-line { display: flex; flex-direction: column; align-items: center; width: 24px; flex-shrink: 0; }
.tl-dot { width: 12px; height: 12px; border-radius: 50%; background: #d1d5db; margin-top: 6px; }
.tl-dot-latest { background: #2563eb; box-shadow: 0 0 0 4px rgba(37,99,235,0.15); }
.tl-connector { flex: 1; width: 2px; background: #e5e7eb; min-height: 12px; }
.tl-card { flex: 1; background: #fff; border-radius: 10px; padding: 12px; margin-bottom: 12px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
.tl-top { display: flex; align-items: center; justify-content: space-between; }
.tl-time { font-size: 12px; color: #9ca3af; }
.tl-actions { display: flex; gap: 12px; }
.tl-act { font-size: 16px; padding: 2px; }
.tl-remark { display: block; font-size: 14px; color: #374151; margin-top: 6px; line-height: 1.5; word-break: break-all; }
.tl-images { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
.tl-thumb { width: 80px; height: 80px; border-radius: 8px; background: #e5e7eb; }
.empty-timeline { display: flex; flex-direction: column; align-items: center; padding-top: 60px; }
.empty-icon { font-size: 48px; margin-bottom: 8px; }
.empty-text { font-size: 14px; color: #9ca3af; }
/* 双重确认弹窗 */
.dlg-overlay { position: fixed; inset: 0; z-index: 999; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; }
.dlg-box { width: 80%; max-width: 360px; background: #fff; border-radius: 16px; padding: 28px 20px 20px; box-sizing: border-box; }
.dlg-title { display: block; text-align: center; font-size: 17px; font-weight: 700; color: #1f2937; }
.dlg-content { display: block; text-align: center; font-size: 14px; color: #6b7280; margin-top: 10px; line-height: 1.5; }
.dlg-tip { display: block; text-align: center; font-size: 12px; color: #b45309; background: #fffbeb; border: 1px solid #fcd34d; border-radius: 8px; padding: 6px 10px; margin: 12px 0 0; line-height: 1.4; }
.dlg-btns { display: flex; gap: 12px; margin-top: 20px; }
.dlg-btn { flex: 1; height: 42px; line-height: 42px; border-radius: 10px; font-size: 15px; font-weight: 600; text-align: center; box-sizing: border-box; padding: 0; margin: 0; }
.dlg-btn::after { border: none; }
.dlg-cancel { background: #f3f4f6; color: #6b7280; }
.dlg-danger { background: #dc2626; color: #fff; }
.dlg-danger[disabled] { opacity: 0.5; }
/* 编辑记录弹窗 */
.edit-overlay { position: fixed; inset: 0; z-index: 999; background: rgba(0,0,0,0.45); display: flex; align-items: flex-end; justify-content: center; }
.edit-popup { width: 100%; max-width: 480px; background: #fff; border-radius: 16px 16px 0 0; padding: 20px 16px 32px; box-sizing: border-box; max-height: 80vh; overflow-y: auto; }
.edit-title { display: block; text-align: center; font-size: 16px; font-weight: 700; color: #1f2937; margin-bottom: 12px; }
.edit-textarea { width: 100%; height: 80px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 10px; font-size: 14px; box-sizing: border-box; }
.edit-imgs { display: flex; flex-wrap: wrap; margin-top: 10px; }
.edit-img-cell { position: relative; width: 160rpx; height: 160rpx; margin: 10rpx; }
.edit-img { width: 160rpx; height: 160rpx; border-radius: 12rpx; border: 1px solid #e5e7eb; }
.edit-img-loading { display: flex; align-items: center; justify-content: center; background: #f3f4f6; border-radius: 12rpx; border: 1px dashed #d1d5db; }
.edit-img-loading-text { font-size: 36rpx; }
.edit-img-del { position: absolute; top: -12rpx; right: -12rpx; width: 40rpx; height: 40rpx; background: #ef4444; color: #fff; border-radius: 20rpx; font-size: 24rpx; text-align: center; line-height: 40rpx; z-index: 2; }
.edit-upload { width: 100%; height: 42px; border: 1px dashed #d1d5db; border-radius: 10px; background: #f9fafb; color: #6b7280; font-size: 14px; line-height: 42px; margin-top: 10px; }
.edit-upload[disabled] { opacity: 0.5; }
.edit-btns { display: flex; gap: 10px; margin-top: 16px; }
.edit-btn { flex: 1; height: 42px; line-height: 42px; border-radius: 10px; font-size: 14px; font-weight: 600; text-align: center; box-sizing: border-box; padding: 0; margin: 0; }
.edit-btn::after { border: none; }
.edit-cancel { background: #f3f4f6; color: #6b7280; }
.edit-save { background: #2563eb; color: #fff; }
.edit-save[disabled] { opacity: 0.5; }
</style>