- 点击✏️直接弹出编辑弹窗:修改备注文本 + 图片预览/删除/新增上传
- 保存走 PUT /records/{id} 更新,本地列表同步刷新
- 删除图片同样走5秒倒计时双重确认
364 lines
16 KiB
Vue
364 lines
16 KiB
Vue
<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>
|