纳入 track-uniapp 前端项目 (扫码/任务树/记录/转交Picker)
This commit is contained in:
767
track-uniapp/src/pages/scan/detail.vue
Normal file
767
track-uniapp/src/pages/scan/detail.vue
Normal file
@ -0,0 +1,767 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- ======== 加载/错误 ======== -->
|
||||
<view v-if="loading" class="loading">加载中...</view>
|
||||
<view v-if="error" class="error-box">{{ error }}</view>
|
||||
|
||||
<template v-if="product && !loading">
|
||||
<!-- ================================================================ -->
|
||||
<!-- 宏观状态栏 -->
|
||||
<!-- ================================================================ -->
|
||||
<view class="overall-bar" @tap="showStatusPicker = true">
|
||||
<text class="overall-label">宏观状态</text>
|
||||
<text :class="['overall-val', product.overall_status ? '' : 'overall-empty']">
|
||||
{{ product.overall_status || '点击设定' }}
|
||||
</text>
|
||||
<text class="overall-arrow">▾</text>
|
||||
</view>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 产品卡片 -->
|
||||
<!-- ================================================================ -->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">📦 产品信息</text>
|
||||
<view class="card-header-right">
|
||||
<text :class="['badge', statusColor(product.status)]">{{ statusLabel(product.status) }}</text>
|
||||
<text class="edit-btn" @tap="openEditProduct">✏️</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-grid">
|
||||
<view class="info-item">
|
||||
<text class="label">序列号</text>
|
||||
<text class="value sn">{{ product.serial_number }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="label">物料名称</text>
|
||||
<text class="value">{{ product.material_name || product.material_id || '—' }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="label">规格型号</text>
|
||||
<text class="value">{{ product.spec_model || '—' }}</text>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<text class="label">订单编号</text>
|
||||
<text class="value">{{ product.order_no || '—' }}</text>
|
||||
</view>
|
||||
<view class="info-item" v-if="product.current_location_id">
|
||||
<text class="label">当前位置</text>
|
||||
<text :class="['value', product.current_location_id === 'virtual_warehouse' ? 'warehouse' : '']">
|
||||
{{ product.current_location_id === 'virtual_warehouse' ? '🏭 仓库' : product.current_location_id }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 任务树 -->
|
||||
<!-- ================================================================ -->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">🌿 任务流转树</text>
|
||||
<text class="task-count">{{ countTasks(product.task_tree) }} 个任务</text>
|
||||
</view>
|
||||
|
||||
<!-- 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"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 弹窗层 -->
|
||||
<!-- ================================================================ -->
|
||||
|
||||
<!-- 1. 状态定调 -->
|
||||
<view v-if="showStatusPicker" class="overlay" @tap="() => {}">
|
||||
<view class="sheet">
|
||||
<text class="sheet-title">{{ product && product.overall_status ? '修改宏观状态' : '🔔 请设定产品宏观状态' }}</text>
|
||||
<text class="sheet-hint">首次扫码,请选择一个状态以开启流转</text>
|
||||
<view class="sheet-options">
|
||||
<view v-for="opt in OVERALL_OPTIONS" :key="opt"
|
||||
:class="['sheet-opt', product && product.overall_status === opt ? 'sheet-opt-active' : '']"
|
||||
@tap="handleSetOverallStatus(opt)"><text>{{ opt }}</text></view>
|
||||
</view>
|
||||
<button v-if="product && product.overall_status" class="sheet-close" @tap="showStatusPicker = false">关闭</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 2. 编辑产品 -->
|
||||
<view v-if="editProductVisible" class="overlay" @tap="editProductVisible = false">
|
||||
<view class="popup" @tap.stop>
|
||||
<text class="popup-title">编辑产品</text>
|
||||
<input v-model="editForm.order_no" class="popup-input" placeholder="订单编号" />
|
||||
<input v-model="editForm.external_serial" class="popup-input" placeholder="外部序列号" />
|
||||
<view class="popup-btns">
|
||||
<button class="btn-cancel" @tap="editProductVisible = false">取消</button>
|
||||
<button class="btn-primary" :disabled="editSaving" @tap="doEditProduct">
|
||||
{{ editSaving ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 3. 发起首道工序 -->
|
||||
<view v-if="createFirstVisible" class="overlay" @tap="createFirstVisible = false">
|
||||
<view class="popup" @tap.stop>
|
||||
<text class="popup-title">🚀 发起首道工序</text>
|
||||
|
||||
<!-- 工序名称 Picker -->
|
||||
<view class="field-label">工序名称 <text class="required">*</text></view>
|
||||
<picker :range="TASK_NAME_OPTIONS" :value="firstForm.taskNameIdx" @change="onTaskNameChange">
|
||||
<view class="picker-box">{{ firstForm.task_name || '请选择工序名称' }}</view>
|
||||
</picker>
|
||||
|
||||
<!-- 接收人 Picker -->
|
||||
<view class="field-label">接收人 <text class="required">*</text></view>
|
||||
<picker :range="userLabels" :value="firstForm.assigneeIdx" @change="onAssigneeChange">
|
||||
<view class="picker-box">{{ firstForm.assigneeLabel || '请选择接收人' }}</view>
|
||||
</picker>
|
||||
|
||||
<!-- 备注(必填) -->
|
||||
<view class="field-label">备注 <text class="required">*</text></view>
|
||||
<textarea v-model="firstForm.note" class="popup-textarea" placeholder="请填写备注说明(必填)" :maxlength="500" />
|
||||
|
||||
<!-- 立即接收开关 -->
|
||||
<label class="switch-row" @tap="firstForm.autoReceive = !firstForm.autoReceive">
|
||||
<text class="switch-label">⚡ 立即接收并开始计时</text>
|
||||
<switch :checked="firstForm.autoReceive" color="#2563EB" style="transform:scale(0.8)" />
|
||||
</label>
|
||||
<text class="switch-hint">{{ firstForm.autoReceive ? '创建后自动调用接收接口,任务直接变为 WIP 并记录开工时间' : '仅创建任务(PENDING),由工人自行接收' }}</text>
|
||||
|
||||
<view class="popup-btns">
|
||||
<button class="btn-cancel" @tap="createFirstVisible = false">取消</button>
|
||||
<button class="btn-primary" :disabled="firstSaving || !firstForm.task_name || !firstForm.assignee_id || !firstForm.note.trim()" @tap="doCreateFirstTask">
|
||||
{{ firstSaving ? '创建中...' : (firstForm.autoReceive ? '创建并接收' : '确认创建') }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 4. 记录/拍照 -->
|
||||
<view v-if="recordPopup.visible" class="overlay" @tap="closeRecordPopup">
|
||||
<view class="popup" @tap.stop>
|
||||
<text class="popup-title">{{ recordForm.recordId ? '✏️ 编辑记录' : '📝 记录/拍照' }}</text>
|
||||
<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="n in recordForm.pendingCount" :key="'p'+n" class="img-cell img-cell-loading">
|
||||
<text class="img-loading-text">⏳</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 上传按钮 (未满9张时显示) -->
|
||||
<button
|
||||
v-if="recordForm.images.length + recordForm.pendingCount < 9"
|
||||
class="btn-upload" @tap="handleChooseImage" :disabled="isUploading"
|
||||
>
|
||||
{{ isUploading ? '上传中...' : `📷 拍照/选图 (${recordForm.images.length + recordForm.pendingCount}/9)` }}
|
||||
</button>
|
||||
|
||||
<view class="popup-btns">
|
||||
<button class="btn-cancel" @tap="closeRecordPopup">取消</button>
|
||||
<button class="btn-primary" :disabled="recordSaving || isUploading" @tap="doSaveRecord">
|
||||
{{ isUploading ? `上传中 (${recordForm.images.length}/${recordForm.images.length + recordForm.pendingCount})` : (recordSaving ? '保存中...' : (recordForm.recordId ? '更新记录' : '保存记录')) }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 5. 任务操作弹出 -->
|
||||
<view v-if="actionPopup.visible" class="overlay" @tap="closeActionPopup">
|
||||
<view class="popup" @tap.stop>
|
||||
<!-- 接收 -->
|
||||
<template v-if="actionPopup.type === 'receive'">
|
||||
<text class="popup-title">确认接收任务</text>
|
||||
<view class="popup-task">{{ actionPopup.task && actionPopup.task.task_name }}</view>
|
||||
<text class="popup-hint">状态: {{ statusLabel(actionPopup.task && actionPopup.task.status) }} → 进行中</text>
|
||||
<view class="popup-btns">
|
||||
<button class="btn-cancel" @tap="closeActionPopup">取消</button>
|
||||
<button class="btn-primary" :disabled="actionLoading" @tap="doReceive">确认接收</button>
|
||||
</view>
|
||||
</template>
|
||||
<!-- 驳回 -->
|
||||
<template v-if="actionPopup.type === 'reject'">
|
||||
<text class="popup-title">品质驳回</text>
|
||||
<textarea v-model="rejectReason" class="popup-textarea" placeholder="请填写驳回原因(必填)" :maxlength="500" />
|
||||
<text class="popup-hint">⚠ 驳回后将自动创建返工任务</text>
|
||||
<view class="popup-btns">
|
||||
<button class="btn-cancel" @tap="closeActionPopup">取消</button>
|
||||
<button class="btn-danger" :disabled="actionLoading || !rejectReason.trim()" @tap="doReject">确认驳回</button>
|
||||
</view>
|
||||
</template>
|
||||
<!-- 转交 -->
|
||||
<template v-if="actionPopup.type === 'transfer'">
|
||||
<text class="popup-title">完工转交</text>
|
||||
|
||||
<!-- 工序选择 (Picker) -->
|
||||
<view class="form-item">
|
||||
<text class="form-label">下一道工序 <text class="required">*</text></text>
|
||||
<picker mode="selector" :range="processOptions" @change="onProcessChange">
|
||||
<view class="picker-value">
|
||||
<text :class="transferForm.next_task_name ? '' : 'picker-placeholder'">
|
||||
{{ transferForm.next_task_name || '请选择下一道工序' }}
|
||||
</text>
|
||||
<text class="picker-arrow">▾</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<!-- 接收人选择 (Picker,单选) -->
|
||||
<view class="form-item">
|
||||
<text class="form-label">接收人 <text class="required">*</text></text>
|
||||
<picker mode="selector" :range="userOptions" range-key="name" @change="onUserChange">
|
||||
<view class="picker-value">
|
||||
<text :class="selectedUserName ? '' : 'picker-placeholder'">
|
||||
{{ selectedUserName || '请选择接收人' }}
|
||||
</text>
|
||||
<text class="picker-arrow">▾</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<!-- 同时入库 -->
|
||||
<label class="wh-label" @tap="transferForm.warehouse = !transferForm.warehouse">
|
||||
<checkbox :checked="transferForm.warehouse" style="transform:scale(0.8)" /> 同时入库 (virtual_warehouse)
|
||||
</label>
|
||||
|
||||
<input v-model="transferForm.note" class="popup-input" placeholder="交接备注(选填)" />
|
||||
|
||||
<view v-if="transferForm.assignees.length && transferForm.next_task_name" class="preview-hint">
|
||||
将创建 {{ transferForm.assignees.length }}{{ transferForm.warehouse ? ' (+仓库)' : '' }} 个「{{ transferForm.next_task_name }}」任务
|
||||
</view>
|
||||
<view class="popup-btns">
|
||||
<button class="btn-cancel" @tap="closeActionPopup">取消</button>
|
||||
<button class="btn-primary" :disabled="actionLoading || !transferForm.next_task_name || (!transferForm.assignees.length && !transferForm.warehouse)" @tap="doTransfer">确认转交</button>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import request, { get, post, patch, put } from "../../utils/request";
|
||||
import TaskTreeNode from "./components/TaskTreeNode.vue";
|
||||
|
||||
const OVERALL_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
|
||||
const TASK_NAME_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
|
||||
const STATUS_MAP = {
|
||||
PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成",
|
||||
REJECTED: "已驳回", ARCHIVED: "已入库",
|
||||
};
|
||||
|
||||
export default {
|
||||
components: { TaskTreeNode },
|
||||
data() {
|
||||
return {
|
||||
OVERALL_OPTIONS,
|
||||
loading: true,
|
||||
error: "",
|
||||
product: null,
|
||||
// 状态定调
|
||||
showStatusPicker: false,
|
||||
// 编辑产品
|
||||
editProductVisible: false,
|
||||
editForm: { order_no: "", external_serial: "" },
|
||||
editSaving: false,
|
||||
// 用户列表
|
||||
users: [],
|
||||
TASK_NAME_OPTIONS,
|
||||
// 发起首道工序
|
||||
createFirstVisible: false,
|
||||
firstForm: { task_name: "", taskNameIdx: 0, assignee_id: "", assigneeLabel: "", assigneeIdx: 0, note: "", autoReceive: true },
|
||||
firstSaving: false,
|
||||
// 记录/拍照
|
||||
recordPopup: { visible: false, task: null },
|
||||
recordForm: { recordId: null, remark: "", images: [], pendingCount: 0 },
|
||||
recordSaving: false,
|
||||
isUploading: false,
|
||||
currentUser: null,
|
||||
currentUserId: "",
|
||||
currentUsername: "",
|
||||
// Picker 数据源
|
||||
processOptions: ["备货", "生产", "测试", "维修", "质检", "打包"],
|
||||
userOptions: [
|
||||
{ id: "duxingchen", name: "杜邢宸" },
|
||||
{ id: "zhangsan", name: "张三" },
|
||||
{ id: "lisi", name: "李四" },
|
||||
{ id: "wangwu", name: "王五" },
|
||||
],
|
||||
// 任务操作
|
||||
actionPopup: { visible: false, type: "", task: null },
|
||||
actionLoading: false,
|
||||
rejectReason: "",
|
||||
transferForm: { next_task_name: "", assignees: [], selectedUserId: "", warehouse: false, note: "" },
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
userLabels() {
|
||||
return this.users.map(u => `${u.full_name} (${u.username})`);
|
||||
},
|
||||
canDeleteImage() {
|
||||
if (!this.recordPopup.task) return true;
|
||||
if (!this.currentUser) return true;
|
||||
return this.currentUser.username === this.recordPopup.task.assignee_id;
|
||||
},
|
||||
selectedUserName() {
|
||||
const u = this.userOptions.find(u => u.id === this.transferForm.selectedUserId);
|
||||
return u ? u.name : "";
|
||||
},
|
||||
},
|
||||
onLoad(options) {
|
||||
this.loadUsers();
|
||||
this.loadCurrentUser();
|
||||
const sn = options.serial || "";
|
||||
if (sn) this.doQuery(sn);
|
||||
},
|
||||
methods: {
|
||||
statusLabel(s) { return STATUS_MAP[s] || s; },
|
||||
statusColor(s) {
|
||||
switch (s) {
|
||||
case "PENDING": return "s-yellow";
|
||||
case "WIP": return "s-blue";
|
||||
case "COMPLETED": return "s-green";
|
||||
case "REJECTED": return "s-red";
|
||||
default: return "s-gray";
|
||||
}
|
||||
},
|
||||
countTasks(tree) { return tree ? tree.reduce((s, t) => s + 1 + this.countTasks(t.child_tasks), 0) : 0; },
|
||||
|
||||
// ---- 数据 ----
|
||||
async doQuery(sn) {
|
||||
this.loading = true; this.error = "";
|
||||
try {
|
||||
this.product = await get(`/products/scan/${sn}`);
|
||||
if (!this.product.overall_status) this.showStatusPicker = true;
|
||||
} catch (e) { this.error = e?.data?.detail || "查询失败"; }
|
||||
finally { this.loading = false; }
|
||||
},
|
||||
|
||||
// ---- 状态定调 ----
|
||||
async handleSetOverallStatus(status) {
|
||||
try {
|
||||
this.product = await patch(`/products/scan/${this.product.serial_number}/status`, { status });
|
||||
uni.showToast({ title: `状态已更新: ${status}`, icon: "success" });
|
||||
this.showStatusPicker = false;
|
||||
} catch {}
|
||||
},
|
||||
|
||||
// ---- 编辑产品 ----
|
||||
openEditProduct() {
|
||||
this.editForm = {
|
||||
order_no: this.product.order_no || "",
|
||||
external_serial: this.product.external_serial || "",
|
||||
};
|
||||
this.editProductVisible = true;
|
||||
},
|
||||
async doEditProduct() {
|
||||
this.editSaving = true;
|
||||
try {
|
||||
this.product = await patch(`/products/${this.product.id}`, { order_no: this.editForm.order_no.trim(), external_serial: this.editForm.external_serial.trim() });
|
||||
uni.showToast({ title: "已保存", icon: "success" });
|
||||
this.editProductVisible = false;
|
||||
} catch {}
|
||||
finally { this.editSaving = false; }
|
||||
},
|
||||
|
||||
// ---- 用户列表 ----
|
||||
async loadUsers() {
|
||||
try {
|
||||
this.users = await get("/users/", { limit: 200 });
|
||||
} catch { /* 静默 */ }
|
||||
},
|
||||
loadCurrentUser() {
|
||||
try {
|
||||
let user = uni.getStorageSync("user");
|
||||
// ⚠️ Storage 可能返回 JSON 字符串,必须强制解析
|
||||
if (typeof user === "string" && user) {
|
||||
try { user = JSON.parse(user); } catch (e) { user = null; }
|
||||
}
|
||||
if (user && typeof user === "object") {
|
||||
this.currentUser = user;
|
||||
this.currentUserId = String(user.id || "");
|
||||
this.currentUsername = user.username || "";
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
|
||||
// ---- 发起首道工序 ----
|
||||
onTaskNameChange(e) {
|
||||
const idx = e.detail.value;
|
||||
this.firstForm.taskNameIdx = idx;
|
||||
this.firstForm.task_name = TASK_NAME_OPTIONS[idx];
|
||||
},
|
||||
onAssigneeChange(e) {
|
||||
const idx = e.detail.value;
|
||||
const u = this.users[idx];
|
||||
if (u) {
|
||||
this.firstForm.assigneeIdx = idx;
|
||||
this.firstForm.assignee_id = u.username;
|
||||
this.firstForm.assigneeLabel = `${u.full_name} (${u.username})`;
|
||||
}
|
||||
},
|
||||
openCreateFirstTask() {
|
||||
this.firstForm = {
|
||||
task_name: TASK_NAME_OPTIONS[0],
|
||||
taskNameIdx: 0,
|
||||
assignee_id: this.users.length > 0 ? this.users[0].username : "",
|
||||
assigneeLabel: this.users.length > 0 ? `${this.users[0].full_name} (${this.users[0].username})` : "",
|
||||
assigneeIdx: 0,
|
||||
note: "",
|
||||
autoReceive: true,
|
||||
};
|
||||
this.createFirstVisible = true;
|
||||
},
|
||||
async doCreateFirstTask() {
|
||||
this.firstSaving = true;
|
||||
try {
|
||||
const task = await post("/tasks/", {
|
||||
product_id: this.product.id,
|
||||
task_name: this.firstForm.task_name,
|
||||
assignee_id: this.firstForm.assignee_id,
|
||||
notify_parent_on_complete: false,
|
||||
});
|
||||
// 场景B: 立即接收
|
||||
if (this.firstForm.autoReceive) {
|
||||
try {
|
||||
await post(`/tasks/${task.id}/receive`);
|
||||
} catch { /* receive 失败不影响流程 */ }
|
||||
}
|
||||
uni.showToast({
|
||||
title: this.firstForm.autoReceive ? "已创建并接收 (WIP)" : "已创建 (PENDING)",
|
||||
icon: "success",
|
||||
});
|
||||
this.createFirstVisible = false;
|
||||
this.doQuery(this.product.serial_number);
|
||||
} catch {}
|
||||
finally { this.firstSaving = false; }
|
||||
},
|
||||
|
||||
// ---- 删除记录 ----
|
||||
async doDeleteRecord(record) {
|
||||
try {
|
||||
await request({ url: `/records/${record.id}`, method: "DELETE" });
|
||||
uni.showToast({ title: "记录已删除", icon: "success" });
|
||||
this.doQuery(this.product.serial_number);
|
||||
} catch {}
|
||||
},
|
||||
|
||||
// ---- 记录/拍照 ----
|
||||
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) { uni.showToast({ title: "最多上传 9 张图片", icon: "none" }); 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;
|
||||
|
||||
// 第二步:强制二次压缩 (quality=60,车间场景足够)
|
||||
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 (compressSkipCount > 0) {
|
||||
uni.showToast({ title: `${compressSkipCount} 张压缩失败已丢弃`, icon: "none", duration: 1500 });
|
||||
}
|
||||
if (!compressedPaths.length) {
|
||||
uni.showToast({ title: "所有图片压缩失败,请重试", icon: "none" });
|
||||
return;
|
||||
}
|
||||
|
||||
// 第三步:逐张上传(只传压缩后的文件)
|
||||
this.isUploading = true;
|
||||
this.recordForm.pendingCount += compressedPaths.length;
|
||||
let failCount = 0;
|
||||
for (const path of compressedPaths) {
|
||||
const url = await this.uploadFile(path);
|
||||
if (url) {
|
||||
this.recordForm.images.push(url);
|
||||
} else {
|
||||
failCount++;
|
||||
uni.showToast({ title: "单张图片上传失败", icon: "none", duration: 1500 });
|
||||
}
|
||||
this.recordForm.pendingCount--;
|
||||
}
|
||||
|
||||
this.isUploading = false;
|
||||
if (failCount > 0 && this.recordForm.images.length === 0) {
|
||||
uni.showToast({ title: `${failCount} 张全部上传失败,请重试`, icon: "none" });
|
||||
}
|
||||
},
|
||||
|
||||
uploadFile(filePath) {
|
||||
return new Promise((resolve) => {
|
||||
uni.uploadFile({
|
||||
url: "http://192.168.9.80:8011/api/v1/upload/",
|
||||
filePath,
|
||||
name: "file",
|
||||
timeout: 30000,
|
||||
success(res) {
|
||||
if (res.statusCode !== 200) { resolve(null); return; }
|
||||
try {
|
||||
const data = JSON.parse(res.data);
|
||||
if (!data || !data.url) { resolve(null); return; }
|
||||
resolve(data.url);
|
||||
} catch { resolve(null); }
|
||||
},
|
||||
fail: () => resolve(null),
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
removeRecordImage(i) { this.recordForm.images.splice(i, 1); },
|
||||
previewRecordImage(i) { uni.previewImage({ urls: this.recordForm.images, current: i }); },
|
||||
|
||||
// ---- 保存记录 ----
|
||||
async doSaveRecord() {
|
||||
if (this.isUploading) { uni.showToast({ title: "图片上传中,请稍候", icon: "none" }); return; }
|
||||
if (this.recordForm.pendingCount > 0) { uni.showToast({ title: "有图片未上传成功,请删除或重试", icon: "none" }); 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: this.recordForm.recordId ? "记录已更新" : "记录已保存", icon: "success" });
|
||||
this.closeRecordPopup();
|
||||
this.doQuery(this.product.serial_number);
|
||||
} catch {}
|
||||
finally { this.recordSaving = false; }
|
||||
},
|
||||
|
||||
// ---- 任务操作 ----
|
||||
handleTaskAction({ task, type, record }) {
|
||||
if (type === "record") { this.openRecordPopup(task); return; }
|
||||
if (type === "deleteRecord") { this.doDeleteRecord(record); return; }
|
||||
this.actionPopup = { visible: true, type, task };
|
||||
this.rejectReason = "";
|
||||
this.transferForm = { next_task_name: "", assignees: [], selectedUserId: "", warehouse: false, note: "" };
|
||||
},
|
||||
closeActionPopup() { this.actionPopup = { visible: false, type: "", task: null }; },
|
||||
|
||||
async doReceive() {
|
||||
this.actionLoading = true;
|
||||
try {
|
||||
await post(`/tasks/${this.actionPopup.task.id}/receive`);
|
||||
uni.showToast({ title: "已接收", icon: "success" });
|
||||
this.closeActionPopup();
|
||||
this.doQuery(this.product.serial_number);
|
||||
} catch {} finally { this.actionLoading = false; }
|
||||
},
|
||||
async doReject() {
|
||||
this.actionLoading = true;
|
||||
try {
|
||||
await post(`/tasks/${this.actionPopup.task.id}/reject`, { reason: this.rejectReason.trim() });
|
||||
uni.showToast({ title: "已驳回,返工任务已创建", icon: "success" });
|
||||
this.closeActionPopup();
|
||||
this.doQuery(this.product.serial_number);
|
||||
} catch {} finally { this.actionLoading = false; }
|
||||
},
|
||||
onProcessChange(e) {
|
||||
this.transferForm.next_task_name = this.processOptions[e.detail.value];
|
||||
},
|
||||
onUserChange(e) {
|
||||
const user = this.userOptions[e.detail.value];
|
||||
if (user) {
|
||||
this.transferForm.selectedUserId = user.id;
|
||||
this.transferForm.assignees = [user.id];
|
||||
}
|
||||
},
|
||||
async doTransfer() {
|
||||
this.actionLoading = true;
|
||||
const finalAssignees = [...this.transferForm.assignees];
|
||||
if (this.transferForm.warehouse) finalAssignees.push("virtual_warehouse");
|
||||
try {
|
||||
await post(`/tasks/${this.actionPopup.task.id}/transfer`, {
|
||||
next_assignees: finalAssignees,
|
||||
next_task_name: this.transferForm.next_task_name.trim(),
|
||||
note: this.transferForm.note.trim() || undefined,
|
||||
});
|
||||
uni.showToast({ title: "转交成功", icon: "success" });
|
||||
this.closeActionPopup();
|
||||
this.doQuery(this.product.serial_number);
|
||||
} catch {} finally { this.actionLoading = false; }
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; padding-bottom: 100px; }
|
||||
.loading { text-align: center; padding: 48px 0; color: #6b7280; }
|
||||
.error-box { padding: 12px; border-radius: 10px; background: #fef2f2; color: #dc2626; font-size: 13px; border: 1px solid #fecaca; }
|
||||
|
||||
/* 宏观状态 */
|
||||
.overall-bar { display: flex; align-items: center; gap: 8px; padding: 10px 14px;
|
||||
background: #fff; border-radius: 12px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
.overall-label { font-size: 13px; color: #6b7280; }
|
||||
.overall-val { font-size: 15px; font-weight: 700; color: #2563eb; flex: 1; }
|
||||
.overall-empty { color: #ef4444; }
|
||||
.overall-arrow { font-size: 12px; color: #9ca3af; }
|
||||
|
||||
/* 卡片 */
|
||||
.card { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
.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; }
|
||||
.edit-btn { font-size: 18px; padding: 2px 6px; }
|
||||
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.label { font-size: 12px; color: #9ca3af; }
|
||||
.value { font-size: 14px; color: #1f2937; font-weight: 600; word-break: break-all; }
|
||||
.sn { font-family: monospace; }
|
||||
.warehouse { color: #7c3aed; }
|
||||
.badge { font-size: 11px; padding: 2px 10px; border-radius: 20px; font-weight: 600; }
|
||||
.s-yellow .badge, .s-yellow { color: #b45309; }
|
||||
.s-blue .badge, .s-blue { color: #1d4ed8; }
|
||||
.s-green .badge, .s-green { color: #15803d; }
|
||||
.s-red .badge, .s-red { color: #be123c; }
|
||||
.s-gray .badge, .s-gray { color: #6b7280; }
|
||||
.task-count { font-size: 12px; color: #9ca3af; }
|
||||
|
||||
/* 0任务 */
|
||||
.zero-task { display: flex; flex-direction: column; align-items: center; padding: 24px 0; }
|
||||
.zero-icon { font-size: 40px; margin-bottom: 8px; }
|
||||
.zero-text { font-size: 14px; color: #9ca3af; margin-bottom: 16px; }
|
||||
.btn-start { width: 220px; height: 44px; background: linear-gradient(135deg, #2563EB, #3B82F6);
|
||||
border: none; border-radius: 12px; color: #fff; font-size: 15px; font-weight: 700; line-height: 44px; box-shadow: 0 4px 12px rgba(37,99,235,0.3); }
|
||||
|
||||
/* 遮罩 + 底部面板 */
|
||||
.overlay { position: fixed; inset: 0; z-index: 999; background: rgba(0,0,0,0.45);
|
||||
display: flex; align-items: flex-end; justify-content: center; }
|
||||
.sheet { width: 100%; max-width: 480px; background: #fff; border-radius: 20px 20px 0 0; padding: 20px 16px 32px; }
|
||||
.sheet-title { font-size: 17px; font-weight: 700; display: block; text-align: center; }
|
||||
.sheet-hint { font-size: 13px; color: #9ca3af; display: block; text-align: center; margin: 6px 0 16px; }
|
||||
.sheet-options { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
||||
.sheet-opt { padding: 14px 8px; border-radius: 12px; text-align: center; font-size: 15px; font-weight: 600;
|
||||
background: #f3f4f6; color: #374151; border: 2px solid transparent; }
|
||||
.sheet-opt-active { background: #dbeafe; color: #2563eb; border-color: #2563eb; }
|
||||
.sheet-close { margin-top: 14px; height: 40px; background: #f3f4f6; border: none; border-radius: 10px; font-size: 14px; color: #6b7280; line-height: 40px; }
|
||||
|
||||
/* 通用弹窗 */
|
||||
.popup { width: 100%; max-width: 480px; background: #fff; border-radius: 16px 16px 0 0;
|
||||
padding: 20px 16px 32px; max-height: 80vh; overflow-y: auto; }
|
||||
.popup-title { font-size: 16px; font-weight: 700; display: block; text-align: center; margin-bottom: 12px; }
|
||||
.popup-task { font-size: 14px; font-weight: 600; color: #2563eb; text-align: center; margin-bottom: 4px; }
|
||||
.popup-hint { font-size: 12px; color: #9ca3af; display: block; text-align: center; }
|
||||
.popup-textarea { width: 100%; height: 80px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 10px; font-size: 14px; margin: 10px 0; box-sizing: border-box; }
|
||||
.popup-input { width: 100%; height: 42px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 0 10px; font-size: 14px; margin: 8px 0; box-sizing: border-box; }
|
||||
.flex-1 { flex: 1; margin: 0; }
|
||||
.popup-btns { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.btn-cancel { flex: 1; height: 42px; border: 1px solid #e5e7eb; border-radius: 10px; background: #fff; color: #6b7280; font-size: 14px; line-height: 42px; }
|
||||
.btn-primary { flex: 1; height: 42px; border: none; border-radius: 10px; background: #2563eb; color: #fff; font-size: 14px; font-weight: 600; line-height: 42px; }
|
||||
.btn-primary[disabled] { opacity: 0.5; }
|
||||
.btn-danger { flex: 1; height: 42px; border: none; border-radius: 10px; background: #dc2626; color: #fff; font-size: 14px; font-weight: 600; line-height: 42px; }
|
||||
.btn-danger[disabled] { opacity: 0.5; }
|
||||
.btn-sm { height: 42px; width: 42px; border: 1px solid #e5e7eb; border-radius: 10px; background: #f3f4f6; font-size: 18px; line-height: 42px; text-align: center; padding: 0; }
|
||||
.popup-assignee-area { margin: 8px 0; }
|
||||
.popup-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
|
||||
.popup-tag { padding: 4px 10px; border-radius: 20px; background: #dbeafe; color: #2563eb; font-size: 12px; font-weight: 600; }
|
||||
.popup-tag-wh { background: #ede9fe; color: #7c3aed; }
|
||||
.popup-add-row { display: flex; gap: 6px; }
|
||||
.wh-label { font-size: 13px; display: flex; align-items: center; gap: 4px; margin-top: 6px; color: #6b7280; }
|
||||
.preview-hint { font-size: 12px; background: #f0fdf4; color: #16a34a; padding: 8px 10px; border-radius: 8px; margin: 6px 0; }
|
||||
|
||||
/* Picker & 表单 */
|
||||
.field-label { font-size: 14px; font-weight: 600; color: #374151; margin-top: 10px; margin-bottom: 4px; }
|
||||
.required { color: #ef4444; }
|
||||
.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; }
|
||||
.switch-row { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; padding: 8px 0; }
|
||||
.switch-label { font-size: 15px; font-weight: 600; color: #2563eb; }
|
||||
.switch-hint { font-size: 11px; color: #9ca3af; display: block; margin-top: 2px; }
|
||||
|
||||
/* 图片上传 — 九宫格 */
|
||||
.img-grid { display: flex; flex-wrap: wrap; margin: 8px -5px; }
|
||||
.img-cell { position: relative; width: 160rpx; height: 160rpx; margin: 10rpx; }
|
||||
.img-thumb { width: 160rpx; height: 160rpx; border-radius: 12rpx; border: 1px solid #e5e7eb; }
|
||||
.img-cell-loading { display: flex; align-items: center; justify-content: center;
|
||||
background: #f3f4f6; border-radius: 12rpx; border: 1px dashed #d1d5db; }
|
||||
.img-loading-text { font-size: 36rpx; }
|
||||
.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; }
|
||||
.btn-upload { width: 100%; height: 42px; border: 1px dashed #d1d5db; border-radius: 10px;
|
||||
background: #f9fafb; color: #6b7280; font-size: 14px; line-height: 42px; margin: 8px 0; }
|
||||
.btn-upload[disabled] { opacity: 0.5; }
|
||||
|
||||
/* Picker 选择器 */
|
||||
.form-item { margin: 10px 0; }
|
||||
.form-label { font-size: 14px; font-weight: 600; color: #374151; display: block; margin-bottom: 4px; }
|
||||
.required { color: #ef4444; }
|
||||
.picker-value {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
width: 100%; height: 42px; padding: 0 12px;
|
||||
border: 1px solid #e5e7eb; border-radius: 10px;
|
||||
background: #f9fafb; font-size: 14px; box-sizing: border-box;
|
||||
}
|
||||
.picker-placeholder { color: #9ca3af; }
|
||||
.picker-arrow { font-size: 12px; color: #9ca3af; margin-left: 8px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user