fix: 移动端驳回支持传图、422 报错可读化、修复详情页图片破图
车间的驳回操作不再强制拍照(编号错误等场景无需照片),移动端同步放开。
- utils/request.js: 新增 extractErrorDetail 统一解析后端 detail 的三种形态
(字符串 / FastAPI 校验错误数组 / 对象),新增 422 拦截分支,并把
400/403/409 及兜底分支的 `res.data?.detail || x` 全部换掉 —— 原写法在
detail 是数组时会把整个数组丢给 showToast,弹出 [object Object]
- pages/scan/detail.vue: 驳回弹窗新增与「追加记录」一致的选图/传图 UI,
doReject 携带 { reason, images }(允许空数组);选图上传流程抽成通用的
pickAndUploadImages,与追加记录共用
- pages/scan/detail.vue: 新增 imageUrl() 补全后端返回的相对路径
(/api/v1/upload/files/xxx.jpg 直接给 <image> 会破图),缩略图渲染与
previewImage 预览均已接入;逻辑与 records.vue / TaskTreeNode.vue 对齐
This commit is contained in:
@ -110,7 +110,7 @@
|
||||
<text class="popup-task">{{ recordPopup.task && recordPopup.task.task_name }}</text>
|
||||
<textarea v-model="recordForm.remark" class="popup-textarea" placeholder="填写备注说明" :maxlength="2000" />
|
||||
<view class="img-grid">
|
||||
<view v-for="(img, i) in recordForm.images" :key="i" class="img-cell"><image :src="img" mode="aspectFill" class="img-thumb" @tap="previewRecordImage(i)" /><text v-if="canDeleteImage" class="img-del" @tap.stop="removeRecordImage(i)">✕</text></view>
|
||||
<view v-for="(img, i) in recordForm.images" :key="i" class="img-cell"><image :src="imageUrl(img)" mode="aspectFill" class="img-thumb" @tap="previewRecordImage(i)" /><text v-if="canDeleteImage" class="img-del" @tap.stop="removeRecordImage(i)">✕</text></view>
|
||||
<view v-for="n in recordForm.pendingCount" :key="'p'+n" class="img-cell img-cell-loading"><text class="img-loading-text">⏳</text></view>
|
||||
</view>
|
||||
<button v-if="recordForm.images.length + recordForm.pendingCount < 9" class="btn-upload" @tap="handleChooseImage" :disabled="isUploading">{{ isUploading ? '上传中...' : `📷 拍照/选图 (${recordForm.images.length + recordForm.pendingCount}/9)` }}</button>
|
||||
@ -136,8 +136,15 @@
|
||||
<template v-if="actionPopup.type === 'reject'">
|
||||
<text class="popup-title">品质驳回</text>
|
||||
<textarea v-model="rejectReason" class="popup-textarea" placeholder="请填写驳回原因(必填)" :maxlength="500" />
|
||||
<!-- 📷 异常图片为选填:编号错误、选错工序等场景可不拍照 -->
|
||||
<view class="field-label">异常图片 <text class="optional">(选填,最多 9 张)</text></view>
|
||||
<view class="img-grid">
|
||||
<view v-for="(img, i) in rejectForm.images" :key="i" class="img-cell"><image :src="imageUrl(img)" mode="aspectFill" class="img-thumb" @tap="previewRejectImage(i)" /><text class="img-del" @tap.stop="removeRejectImage(i)">✕</text></view>
|
||||
<view v-for="n in rejectForm.pendingCount" :key="'rp'+n" class="img-cell img-cell-loading"><text class="img-loading-text">⏳</text></view>
|
||||
</view>
|
||||
<button v-if="rejectForm.images.length + rejectForm.pendingCount < 9" class="btn-upload" @tap="handleChooseRejectImage" :disabled="isUploading">{{ isUploading ? '上传中...' : `📷 拍照/选图 (${rejectForm.images.length + rejectForm.pendingCount}/9)` }}</button>
|
||||
<text class="popup-hint">⚠ 驳回后将自动创建返工任务</text>
|
||||
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-danger" :class="{ 'btn-counting': confirming === 'reject' && confirmCount > 0 }" :disabled="actionLoading || !rejectReason.trim() || (confirming === 'reject' && confirmCount > 0)" @tap="confirmBtn('reject', doReject)">{{ confirmLabel('reject', '确认驳回') }}</button></view>
|
||||
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-danger" :class="{ 'btn-counting': confirming === 'reject' && confirmCount > 0 }" :disabled="actionLoading || isUploading || !rejectReason.trim() || (confirming === 'reject' && confirmCount > 0)" @tap="confirmBtn('reject', doReject)">{{ isUploading ? '上传中...' : confirmLabel('reject', '确认驳回') }}</button></view>
|
||||
</template>
|
||||
<template v-if="actionPopup.type === 'transfer'">
|
||||
<text class="popup-title">完工转交</text>
|
||||
@ -240,6 +247,8 @@ export default {
|
||||
currentUser: null, currentUserId: "", currentUsername: "", currentUserRole: "", currentMode: "workspace", autoLockTaskId: "",
|
||||
processOptions: [], userOptions: [],
|
||||
actionPopup: { visible: false, type: "", task: null }, actionLoading: false, rejectReason: "", receiveRemark: "", receiveTaskName: "",
|
||||
// 📷 驳回异常图片(选填):与追加记录共用同一套选图/上传流程
|
||||
rejectForm: { images: [], pendingCount: 0 },
|
||||
transferForm: { selectedUserId: "", isWarehouse: false, note: "" },
|
||||
spawnForm: { assignee_id: "", remark: "" },
|
||||
// 🛡️ 双重确认倒计时:避免误触
|
||||
@ -384,10 +393,42 @@ export default {
|
||||
openRecordPopup(task) { this.recordPopup = { visible: true, task }; this.recordForm = { recordId: null, remark: "", images: [], pendingCount: 0 }; this.isUploading = false; },
|
||||
openEditRecord({ task, record }) { this.recordPopup = { visible: true, task }; this.recordForm = { recordId: record.id, remark: record.remark || "", images: record.images || [], pendingCount: 0 }; this.isUploading = false; },
|
||||
closeRecordPopup() { this.recordPopup = { visible: false, task: null }; },
|
||||
async handleChooseImage() { const maxSlots = 9 - (this.recordForm.images.length + this.recordForm.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; let compressSkipCount = 0; 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 { compressSkipCount++; } } if (!compressedPaths.length) return; this.isUploading = true; this.recordForm.pendingCount += compressedPaths.length; for (const path of compressedPaths) { const url = await this.uploadFile(path); if (url) this.recordForm.images.push(url); this.recordForm.pendingCount--; } this.isUploading = false; },
|
||||
// 🖼️ 后端返回的是相对路径(如 /api/v1/upload/files/xxx.jpg),直接丢给 <image> 会破图,
|
||||
// 这里补全域名 → 完整可访问 URL。逻辑与 records.vue / TaskTreeNode.vue 保持一致。
|
||||
imageUrl(url) {
|
||||
if (!url) return "";
|
||||
if (url.startsWith("http")) return url;
|
||||
const domain = getBaseUrl().replace(/\/api.*$/, '');
|
||||
return domain + (url.startsWith("/") ? url : "/" + url);
|
||||
},
|
||||
// 📷 通用选图上传:压缩后逐个上传,URL 累积进 target.images,
|
||||
// 过程中用 target.pendingCount 显示 ⏳ 占位(追加记录 / 驳回共用)
|
||||
async pickAndUploadImages(target) {
|
||||
const maxSlots = 9 - (target.images.length + target.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;
|
||||
target.pendingCount += compressedPaths.length;
|
||||
for (const path of compressedPaths) {
|
||||
const url = await this.uploadFile(path);
|
||||
if (url) target.images.push(url);
|
||||
target.pendingCount--;
|
||||
}
|
||||
this.isUploading = false;
|
||||
},
|
||||
handleChooseImage() { return this.pickAndUploadImages(this.recordForm); },
|
||||
handleChooseRejectImage() { return this.pickAndUploadImages(this.rejectForm); },
|
||||
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) }); }); },
|
||||
removeRecordImage(i) { this.openConfirmDlg({ title: "删除图片", content: "确定删除这张图片吗?", countdown: true, action: () => { this.recordForm.images.splice(i, 1); } }); },
|
||||
previewRecordImage(i) { uni.previewImage({ urls: this.recordForm.images, current: i }); },
|
||||
previewRecordImage(i) { uni.previewImage({ urls: this.recordForm.images.map((u) => this.imageUrl(u)), current: i }); },
|
||||
removeRejectImage(i) { this.rejectForm.images.splice(i, 1); },
|
||||
previewRejectImage(i) { uni.previewImage({ urls: this.rejectForm.images.map((u) => this.imageUrl(u)), current: i }); },
|
||||
async doSaveRecord() { if (this.isUploading) return; this.recordSaving = true; try { const payload = { remark: this.recordForm.remark.trim(), images: this.recordForm.images }; if (this.recordForm.recordId) await put(`/records/${this.recordForm.recordId}`, payload); else await patch(`/tasks/${this.recordPopup.task.id}/records`, payload); uni.showToast({ title: "已保存", icon: "success" }); this.closeRecordPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.recordSaving = false; } },
|
||||
|
||||
async handleTaskAction({ task, type, record }) {
|
||||
@ -397,6 +438,7 @@ export default {
|
||||
if (type === "recall") { this.confirmRecall(task); return; }
|
||||
if (type === "transfer" || type === "spawn") { uni.showLoading({ title: "加载数据..." }); try { if (!this.userOptions.length) await this.loadUsers(); this.processOptions = ["🏭 入库 (virtual_warehouse)", ...this.availableTaskOptions]; } finally { uni.hideLoading(); } }
|
||||
this.actionPopup = { visible: true, type, task }; this.rejectReason = ""; this.receiveRemark = ""; this.receiveTaskName = "";
|
||||
this.rejectForm = { images: [], pendingCount: 0 }; this.isUploading = false;
|
||||
this.transferForm = { selectedUserId: "", isWarehouse: false, note: "" };
|
||||
this.spawnForm = { assignee_id: "", remark: "" };
|
||||
},
|
||||
@ -468,7 +510,8 @@ export default {
|
||||
this.confirmDlg.action = null;
|
||||
},
|
||||
async doReceive() { this.actionLoading = true; try { const remark = this.receiveRemark.trim() || undefined; const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/receive?operator_id=${encodeURIComponent(opId)}`, { remark, task_name: this.receiveTaskName }); uni.showToast({ title: "已接收", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } },
|
||||
async doReject() { this.actionLoading = true; try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/reject?operator_id=${encodeURIComponent(opId)}`, { reason: this.rejectReason.trim() }); uni.showToast({ title: "已驳回", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } },
|
||||
// 驳回:reason 必填,images 选填(编号错误等场景允许空数组)
|
||||
async doReject() { if (this.isUploading) return; this.actionLoading = true; try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/reject?operator_id=${encodeURIComponent(opId)}`, { reason: this.rejectReason.trim(), images: this.rejectForm.images }); uni.showToast({ title: "已驳回", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } },
|
||||
// 转交 — 互斥选择
|
||||
selectTransferUser(userId) { this.transferForm.selectedUserId = userId; this.transferForm.isWarehouse = false; },
|
||||
toggleWarehouse() { this.transferForm.isWarehouse = !this.transferForm.isWarehouse; if (this.transferForm.isWarehouse) this.transferForm.selectedUserId = ""; },
|
||||
@ -529,9 +572,10 @@ export default {
|
||||
.overall-outbound { color: #4f46e5; } /* 已出库:靛蓝 */
|
||||
.overall-warehouse { color: #ea580c; } /* 待仓库收货:橙 */
|
||||
.overall-arrow { font-size: 12px; color: #9ca3af; }
|
||||
/* 🔧 售后回流标识:红底白字,回流设备一眼可辨(生产阶段不打标) */
|
||||
/* 🔧 售后回流标识:紫底白字(生产阶段不打标)
|
||||
不用红色——红色在本系统是「驳回/危险」语义,售后只是另一条流转支线。 */
|
||||
.life-badge { font-size: 11px; font-weight: 700; padding: 3px 10px; border-radius: 20px; flex-shrink: 0; }
|
||||
.life-badge-after { background: #dc2626; color: #ffffff; }
|
||||
.life-badge-after { background: #9333ea; color: #ffffff; }
|
||||
.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; min-height: 120px; }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; gap: 6px; }
|
||||
.card-header-right { display: flex; align-items: center; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
|
||||
@ -604,6 +648,7 @@ export default {
|
||||
.btn-disabled { background: #9ca3af; opacity: 0.5; }
|
||||
.mb-bottom-anchor { height: 1px; }
|
||||
.required { color: #ef4444; }
|
||||
.optional { color: #9ca3af; font-weight: 400; font-size: 12px; }
|
||||
.picker-box { width: 100%; height: 42px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 0 12px; font-size: 14px; color: #1f2937; line-height: 42px; box-sizing: border-box; background: #fff; }
|
||||
.img-grid { display: flex; flex-wrap: wrap; margin: 8px -5px; }
|
||||
.img-cell { position: relative; width: 160rpx; height: 160rpx; margin: 10rpx; }
|
||||
|
||||
@ -82,6 +82,39 @@ async function refreshAccessToken() {
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 错误文案提取 — 把后端 detail 统一转成能读的中文,杜绝 [object Object]
|
||||
// ============================================================
|
||||
//
|
||||
// FastAPI 的 detail 有三种形态,直接丢给 showToast 会变成 [object Object]:
|
||||
// 1. 字符串: {"detail": "任务状态为 COMPLETED,无法驳回"}
|
||||
// 2. 校验错误数组(422): {"detail": [{"loc": ["body","images"], "msg": "Value error, 驳回必须...", "type": "value_error"}]}
|
||||
// 3. 对象: {"detail": {"msg": "..."}}
|
||||
export function extractErrorDetail(data, fallback = "") {
|
||||
const detail = data && data.detail;
|
||||
if (!detail) return (data && data.msg) || fallback;
|
||||
if (typeof detail === "string") return detail || fallback;
|
||||
|
||||
if (Array.isArray(detail)) {
|
||||
const msgs = detail.map((item) => {
|
||||
if (typeof item === "string") return item;
|
||||
if (!item || typeof item !== "object") return "";
|
||||
// Pydantic v2 自定义校验器抛的错带 "Value error, " 前缀,读起来噪音大,去掉
|
||||
const msg = String(item.msg || item.message || "").replace(/^Value error,\s*/, "");
|
||||
const field = Array.isArray(item.loc)
|
||||
? item.loc.filter((p) => !["body", "query", "path"].includes(p)).join(".")
|
||||
: "";
|
||||
return field && msg ? `${field}: ${msg}` : msg;
|
||||
}).filter(Boolean);
|
||||
return msgs.length ? msgs.join(";") : fallback;
|
||||
}
|
||||
|
||||
if (typeof detail === "object") {
|
||||
return detail.msg || detail.message || JSON.stringify(detail);
|
||||
}
|
||||
return String(detail) || fallback;
|
||||
}
|
||||
|
||||
export default function request(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = options.url.startsWith("http") ? options.url : getBaseUrl() + options.url;
|
||||
@ -137,10 +170,14 @@ export default function request(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 403) uni.showToast({ title: res.data?.detail || "无权操作", icon: "none", duration: 3000 });
|
||||
else if (code === 400) uni.showToast({ title: res.data?.detail || "请求参数有误", icon: "none", duration: 2500 });
|
||||
else if (code === 409) uni.showToast({ title: res.data?.detail || "操作冲突", icon: "none", duration: 3000 });
|
||||
else { const d = res.data?.detail || ""; uni.showToast({ title: d || `请求失败 (${code})`, icon: "none", duration: 3000 }); }
|
||||
if (code === 403) uni.showToast({ title: extractErrorDetail(res.data, "无权操作"), icon: "none", duration: 3000 });
|
||||
else if (code === 400) uni.showToast({ title: extractErrorDetail(res.data, "请求参数有误"), icon: "none", duration: 2500 });
|
||||
else if (code === 409) uni.showToast({ title: extractErrorDetail(res.data, "操作冲突"), icon: "none", duration: 3000 });
|
||||
else if (code === 422) {
|
||||
// 表单/参数校验失败(Pydantic)—— detail 是校验项数组,必须解析出 msg 再提示
|
||||
uni.showToast({ title: extractErrorDetail(res.data, "提交内容不符合要求"), icon: "none", duration: 3000 });
|
||||
}
|
||||
else uni.showToast({ title: extractErrorDetail(res.data, `请求失败 (${code})`), icon: "none", duration: 3000 });
|
||||
reject(res);
|
||||
},
|
||||
fail() { uni.showToast({ title: "网络连接失败", icon: "none" }); reject(new Error("network")); },
|
||||
|
||||
Reference in New Issue
Block a user