353 lines
34 KiB
Vue
353 lines
34 KiB
Vue
<template>
|
||
<view class="page-container">
|
||
<view v-if="loading" class="loading">加载中...</view>
|
||
<view v-if="error" class="error-box">{{ error }}</view>
|
||
|
||
<template v-if="product && !loading">
|
||
<!-- 工作区模式:显示产品信息栏 -->
|
||
<template v-if="currentMode === 'workspace'">
|
||
<view class="overall-bar" @tap="handleOverallBarClick">
|
||
<text class="overall-label">宏观状态</text>
|
||
<text :class="['overall-val', product.overall_status ? '' : 'overall-empty']">{{ product.overall_status || '未激活 — 点击发起首道工序' }}</text>
|
||
<text v-if="product.task_tree && product.task_tree.length" class="overall-arrow">▾</text>
|
||
</view>
|
||
|
||
<view class="card">
|
||
<view class="card-header">
|
||
<text class="card-title">📦 产品信息</text>
|
||
<view class="card-header-right">
|
||
<text class="mode-toggle" @tap="toggleMode">{{ modeToggleLabel }}</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" v-if="product.external_serial"><text class="label">产品序列号</text><text class="value sn">{{ product.external_serial }}</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 v-if="product && product.current_location_id === 'virtual_warehouse' && !hasMyActiveTask"
|
||
class="warehouse-transfer-banner" @tap="openWarehouseTransfer">
|
||
<text class="wt-icon">📤</text>
|
||
<text class="wt-text">该产品在仓库中 — 点击此处转出并派发给指定人员</text>
|
||
</view>
|
||
</template>
|
||
|
||
<!-- 工作区视图 -->
|
||
<WorkspaceArea v-if="currentMode === 'workspace'" :product="product"
|
||
:currentUserId="currentUserId" :currentUsername="currentUsername"
|
||
:initialLockTaskId="autoLockTaskId"
|
||
@action="handleTaskAction" @viewRecords="handleViewRecords" />
|
||
|
||
<!-- 📇 流转卡片:探探式单张滑动 -->
|
||
<TaskSwipeCards v-if="currentMode === 'swipe'" :product="product"
|
||
@back="currentMode = 'workspace'" @overview="currentMode = 'tree'"
|
||
@viewRecords="handleViewRecords" />
|
||
|
||
<!-- 🌳 流转树:全屏独立视图 -->
|
||
<TreeCanvas v-if="currentMode === 'tree'" :product="product" @viewRecords="handleViewRecords" @back="currentMode = 'workspace'" @swipe="currentMode = 'swipe'" />
|
||
</template>
|
||
|
||
<!-- 状态定调 -->
|
||
<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>
|
||
<!-- 编辑产品 -->
|
||
<view v-if="editProductVisible" class="overlay" @tap="editProductVisible = false">
|
||
<view class="popup" @tap.stop>
|
||
<text class="popup-title">编辑产品</text>
|
||
<view class="field-label">订单编号</view>
|
||
<input v-model="editForm.order_no" class="popup-input" placeholder="请输入订单编号" />
|
||
<view class="field-label" style="margin-top:10px;">产品序列号</view>
|
||
<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>
|
||
<!-- 发起首道工序 (只选人+填备注) -->
|
||
<view v-if="createFirstVisible" class="overlay" @tap="createFirstVisible = false">
|
||
<view class="popup" @tap.stop>
|
||
<text class="popup-title">{{ isWarehouseTransfer ? '📤 仓库转出派发' : '🚀 发起首道工序' }}</text>
|
||
<view class="field-label">接收人 <text class="required">*</text></view>
|
||
<view class="user-grid">
|
||
<view v-for="u in userGridOptions" :key="u.id"
|
||
:class="['user-grid-item', firstForm.assignee_id === u.id ? 'user-grid-active' : '']"
|
||
@tap="firstForm.assignee_id = u.id; firstForm.assigneeLabel = u.name">{{ formatName(u.name) }}</view>
|
||
</view>
|
||
<view class="field-label" style="margin-top:12px;">备注 <text class="required">*</text></view>
|
||
<textarea v-model="firstForm.note" class="popup-textarea" placeholder="请填写备注说明(必填)" :maxlength="500" />
|
||
<view class="popup-btns"><button class="btn-cancel" @tap="createFirstVisible = false">取消</button><button class="btn-primary" :disabled="firstSaving || !firstForm.assignee_id || !firstForm.note.trim()" @tap="doCreateFirstTask">{{ firstSaving ? '创建中...' : '确认创建' }}</button></view>
|
||
</view>
|
||
</view>
|
||
<!-- 记录/拍照 -->
|
||
<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>
|
||
<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 ? '上传中' : (recordSaving ? '保存中...' : (recordForm.recordId ? '更新记录' : '保存记录')) }}</button></view>
|
||
</view>
|
||
</view>
|
||
<!-- 任务操作 -->
|
||
<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="field-label">选择工序 <text class="required">*</text></view>
|
||
<view class="user-grid">
|
||
<view v-for="opt in TASK_NAME_OPTIONS" :key="opt"
|
||
:class="['user-grid-item', receiveTaskName === opt ? 'user-grid-active' : '']"
|
||
@tap="receiveTaskName = opt">{{ opt }}</view>
|
||
</view>
|
||
<textarea v-model="receiveRemark" class="popup-textarea" placeholder="接收备注(选填)" :maxlength="500" style="margin-top:12px;" />
|
||
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-primary" :disabled="actionLoading || !receiveTaskName" @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>
|
||
<view class="field-label">接收人 <text class="required">*</text></view>
|
||
<view class="user-grid">
|
||
<view v-for="u in userGridOptions" :key="u.id"
|
||
:class="['user-grid-item', transferForm.selectedUserId === u.id ? 'user-grid-active' : '']"
|
||
@tap="selectTransferUser(u.id)">{{ formatName(u.name) }}</view>
|
||
</view>
|
||
<view class="field-label" style="margin-top:12px;">或</view>
|
||
<view :class="['user-grid-item', transferForm.isWarehouse ? 'user-grid-active' : '']" style="width:100%;" @tap="toggleWarehouse">📦 入库 (virtual_warehouse)</view>
|
||
<view class="field-label" style="margin-top:12px;">交接备注 <text class="required">*</text></view>
|
||
<textarea v-model="transferForm.note" class="popup-textarea" placeholder="请填写交接备注(必填)" :maxlength="500" />
|
||
<view v-if="transferForm.isWarehouse || transferForm.selectedUserId" class="preview-hint">{{ transferForm.isWarehouse ? '产品将入库并从个人待办中移除' : '将创建新任务指派给 ' + (transferUserName || '—') }}</view>
|
||
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-primary" :disabled="actionLoading || (!transferForm.isWarehouse && !transferForm.selectedUserId) || !transferForm.note.trim()" @tap="doTransfer">{{ actionLoading ? '提交中...' : (transferForm.isWarehouse ? '📦 确认入库' : '确认转交') }}</button></view>
|
||
</template>
|
||
<template v-if="actionPopup.type === 'spawn'">
|
||
<text class="popup-title">➕ 派发协助分支</text>
|
||
<text class="popup-hint">为当前任务创建并行协助,当前任务保持进行中</text>
|
||
<view class="field-label">接收人 <text class="required">*</text></view>
|
||
<view class="user-grid">
|
||
<view v-for="u in userGridOptions" :key="u.id"
|
||
:class="['user-grid-item', spawnForm.assignee_id === u.id ? 'user-grid-active' : '']"
|
||
@tap="spawnForm.assignee_id = u.id">{{ formatName(u.name) }}</view>
|
||
</view>
|
||
<view class="field-label" style="margin-top:12px;">派发备注 <text class="required">*</text></view>
|
||
<textarea v-model="spawnForm.remark" class="popup-textarea" placeholder="请填写派发备注说明(必填)" :maxlength="500" />
|
||
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-primary btn-spawn" :disabled="actionLoading || !spawnForm.assignee_id || !spawnForm.remark.trim()" @tap="doSpawn">{{ actionLoading ? '提交中...' : '确认派发' }}</button></view>
|
||
</template>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import request, { get, post, patch, put } from "../../utils/request";
|
||
import WorkspaceArea from "./components/WorkspaceArea.vue";
|
||
import TreeCanvas from "./components/TreeCanvas.vue";
|
||
import TaskSwipeCards from "./components/TaskSwipeCards.vue";
|
||
|
||
const OVERALL_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
|
||
const TASK_NAME_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
|
||
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" };
|
||
|
||
export default {
|
||
components: { WorkspaceArea, TreeCanvas, TaskSwipeCards },
|
||
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, isWarehouseTransfer: false, firstForm: { task_name: "", taskNameIdx: 0, assignee_id: "", assigneeLabel: "", assigneeIdx: 0, note: "" }, firstSaving: false,
|
||
recordPopup: { visible: false, task: null }, recordForm: { recordId: null, remark: "", images: [], pendingCount: 0 }, recordSaving: false, isUploading: false,
|
||
currentUser: null, currentUserId: "", currentUsername: "", currentMode: "workspace", autoLockTaskId: "",
|
||
processOptions: [], userOptions: [],
|
||
actionPopup: { visible: false, type: "", task: null }, actionLoading: false, rejectReason: "", receiveRemark: "", receiveTaskName: "",
|
||
transferForm: { selectedUserId: "", isWarehouse: false, note: "" },
|
||
spawnForm: { assignee_id: "", remark: "" },
|
||
};
|
||
},
|
||
computed: {
|
||
userLabels() { return this.users.map(u => `${u.full_name} (${u.username})`); },
|
||
canDeleteImage() { if (!this.recordPopup.task) return true; if (!this.currentUser) return true; const frozen = ["COMPLETED","REJECTED","ARCHIVED","CANCELED"]; if (frozen.includes(this.recordPopup.task.status)) return false; return this.currentUser.username === this.recordPopup.task.assignee_id; },
|
||
transferUserName() { const u = this.userOptions.find(u => u.id === this.transferForm.selectedUserId); return u ? u.name : ""; },
|
||
spawnUserName() { const u = this.userOptions.find(u => u.id === this.spawnForm.assignee_id); return u ? u.name : ""; },
|
||
userGridOptions() { return (this.userOptions || []).map(u => ({ id: u.id, name: u.name })); },
|
||
modeToggleLabel() { if (this.currentMode === 'workspace') return '📇 流转卡片'; if (this.currentMode === 'swipe') return '🌳 流转树'; return '🛠️ 工作区'; },
|
||
hasMyActiveTask() {
|
||
const find = (tasks) => { if (!tasks) return false; for (const t of tasks) { if ((t.status === 'WIP' || t.status === 'PENDING') && (t.assignee_id == this.currentUserId || t.assignee_id == this.currentUsername)) return true; if (find(t.child_tasks)) return true; } return false; };
|
||
return this.product ? find(this.product.task_tree) : false;
|
||
},
|
||
},
|
||
onLoad(options) { this.loadUsers(); this.loadCurrentUser(); const sn = options.serial || ""; if (sn) this.doQuery(sn); },
|
||
methods: {
|
||
formatName(name) { if (!name) return ""; return name.length === 2 ? name[0] + " " + name[1] : name; },
|
||
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"; } },
|
||
|
||
async doQuery(sn) { this.loading = true; this.error = ""; try { this.product = await get(`/products/scan/${sn}`); this.$nextTick(() => { this.currentMode = 'workspace'; this.autoLockTaskId = this.findMyImmersiveTask() || ''; if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } }); } catch (e) { this.error = e?.data?.detail || "查询失败"; } finally { this.loading = false; } },
|
||
findMyImmersiveTask() {
|
||
// 🚀 扫描用户的 WIP/PENDING 任务,自动沉浸锁定
|
||
if (!this.product || !this.product.task_tree) return null;
|
||
let wipTask = null, pendingTask = null;
|
||
const walk = (tasks) => {
|
||
if (!tasks) return;
|
||
for (const t of tasks) {
|
||
const isMine = t.assignee_id == this.currentUserId || t.assignee_id == this.currentUsername;
|
||
if (isMine && t.status === 'WIP') wipTask = t;
|
||
if (isMine && t.status === 'PENDING' && !pendingTask) pendingTask = t;
|
||
walk(t.child_tasks);
|
||
}
|
||
};
|
||
walk(this.product.task_tree);
|
||
return (wipTask || pendingTask) ? (wipTask || pendingTask).id : null;
|
||
},
|
||
toggleMode() { if (this.currentMode === 'workspace') this.currentMode = 'swipe'; else if (this.currentMode === 'swipe') this.currentMode = 'tree'; else this.currentMode = 'workspace'; },
|
||
|
||
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 { const res = await get("/users/", { limit: 200, dept: "IRIS" }); this.users = (res || []).filter(u => u.department === "IRIS"); this.userOptions = this.users.map(u => ({ id: u.username || u.id, name: u.full_name || u.username })); } catch {} },
|
||
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; 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.isWarehouseTransfer = false; if (this.currentMode === 'tree') this.currentMode = 'workspace'; this.firstForm = { assignee_id: "", assigneeLabel: "", note: "" }; this.createFirstVisible = true; },
|
||
handleOverallBarClick() { if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } else { this.showStatusPicker = true; } },
|
||
openWarehouseTransfer() { this.isWarehouseTransfer = true; this.openCreateFirstTask(); },
|
||
async doCreateFirstTask() { this.firstSaving = true; try { await post("/tasks/", { product_id: this.product.id, task_name: "待确认", assignee_id: this.firstForm.assignee_id, notify_parent_on_complete: false, remark: this.firstForm.note.trim() || undefined }); if (this.product.current_location_id === 'virtual_warehouse') { try { await patch(`/products/${this.product.id}`, { current_location_id: this.firstForm.assignee_id }); } catch {} } uni.showToast({ title: "任务已派发,待接收", icon: "success" }); this.createFirstVisible = false; this.doQuery(this.product.serial_number); } catch {} finally { this.firstSaving = false; } },
|
||
|
||
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; },
|
||
uploadFile(filePath) { return new Promise((resolve) => { uni.uploadFile({ url: "http://192.168.9.80:8011/api/v1/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.recordForm.images.splice(i, 1); },
|
||
previewRecordImage(i) { uni.previewImage({ urls: this.recordForm.images, 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 }) {
|
||
if (type === "record") { this.openRecordPopup(task); return; }
|
||
if (type === "deleteRecord") { this.doDeleteRecord(record); return; }
|
||
if (type === "end") { this.confirmEndBranch(task); return; }
|
||
if (type === "recall") { this.confirmRecall(task); return; }
|
||
if (type === "transfer" || type === "spawn") { uni.showLoading({ title: "加载数据..." }); try { if (!this.userOptions.length) await this.loadUsers(); if (!this.processOptions.length) this.processOptions = ["🏭 入库 (virtual_warehouse)", ...TASK_NAME_OPTIONS]; } finally { uni.hideLoading(); } }
|
||
this.actionPopup = { visible: true, type, task }; this.rejectReason = ""; this.receiveRemark = ""; this.receiveTaskName = "";
|
||
this.transferForm = { selectedUserId: "", isWarehouse: false, note: "" };
|
||
this.spawnForm = { assignee_id: "", remark: "" };
|
||
},
|
||
handleViewRecords(task) { uni.navigateTo({ url: `/pages/scan/records?taskId=${task.id}` }); },
|
||
async doDeleteRecord(record) { try { await request({ url: `/records/${record.id}`, method: "DELETE" }); uni.showToast({ title: "记录已删除", icon: "success" }); this.doQuery(this.product.serial_number); } catch {} },
|
||
confirmEndBranch(task) { uni.showModal({ title: "结束分支", content: `确定结束「${task.task_name}」吗?`, success: (res) => { if (res.confirm) this.doEndBranch(task); } }); },
|
||
confirmRecall(task) { uni.showModal({ title: "撤回转交", content: `确定撤回「${task.task_name}」吗?撤回后任务将回到您的手中。`, success: (res) => { if (res.confirm) this.doRecall(task); } }); },
|
||
async doRecall(task) { try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${task.id}/recall?operator_id=${encodeURIComponent(opId)}`); uni.showToast({ title: "已撤回", icon: "success" }); this.doQuery(this.product.serial_number); } catch {} },
|
||
async doEndBranch(task) { try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${task.id}/end?operator_id=${encodeURIComponent(opId)}`); uni.showToast({ title: "分支已结束", icon: "success" }); this.doQuery(this.product.serial_number); } catch {} },
|
||
closeActionPopup() { this.actionPopup = { visible: false, type: "", task: 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; } },
|
||
// 转交 — 互斥选择
|
||
selectTransferUser(userId) { this.transferForm.selectedUserId = userId; this.transferForm.isWarehouse = false; },
|
||
toggleWarehouse() { this.transferForm.isWarehouse = !this.transferForm.isWarehouse; if (this.transferForm.isWarehouse) this.transferForm.selectedUserId = ""; },
|
||
async doTransfer() { this.actionLoading = true; try { const assignees = this.transferForm.isWarehouse ? ["virtual_warehouse"] : [this.transferForm.selectedUserId]; const taskName = this.transferForm.isWarehouse ? "🏭 入库 (virtual_warehouse)" : "待确认"; await post(`/tasks/${this.actionPopup.task.id}/transfer`, { next_tasks: [{ task_name: taskName, assignees }], note: this.transferForm.note.trim() || undefined }); uni.showToast({ title: this.transferForm.isWarehouse ? "已入库" : "转交成功", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } },
|
||
// 派发协助分支
|
||
async doSpawn() { this.actionLoading = true; try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/spawn?operator_id=${encodeURIComponent(opId)}`, { task_name: "待确认", assignee_id: this.spawnForm.assignee_id, remark: this.spawnForm.remark.trim() || undefined }); uni.showToast({ title: "协助分支已派发", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } },
|
||
},
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page-container { height: 100vh; display: flex; flex-direction: column; background-color: #f3f4f6; box-sizing: border-box; padding: 16px; padding-bottom: 0; overflow: hidden; }
|
||
.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); flex-shrink: 0; }
|
||
.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; }
|
||
.mode-toggle { font-size: 13px; font-weight: 700; padding: 4px 10px; border-radius: 8px; background: #eff6ff; color: #2563eb; }
|
||
.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; }
|
||
.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; }
|
||
.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; }
|
||
.branch-item { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; margin-bottom: 10px; }
|
||
.branch-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
|
||
.branch-label { font-size: 13px; font-weight: 700; color: #374151; }
|
||
.branch-del { font-size: 12px; color: #ef4444; font-weight: 600; padding: 2px 8px; }
|
||
.btn-add-branch { width: 100%; height: 40px; border: 2px dashed #93c5fd; border-radius: 10px; background: #eff6ff; color: #2563eb; font-size: 14px; font-weight: 700; line-height: 40px; margin: 4px 0; }
|
||
.btn-add-branch::after { border: none; }
|
||
.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; }
|
||
.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; }
|
||
.form-item { margin: 10px 0; }
|
||
.form-label { font-size: 14px; font-weight: 600; color: #374151; display: block; margin-bottom: 4px; }
|
||
.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; }
|
||
.user-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
||
.user-grid-item { padding: 12px 8px; border-radius: 10px; background: #f3f4f6; text-align: center; font-size: 14px; font-weight: 600; color: #374151; border: 2px solid transparent; }
|
||
.user-grid-active { background: #dbeafe; color: #2563eb; border-color: #2563eb; }
|
||
.warehouse-hint { font-size: 13px; background: #ede9fe; color: #7c3aed; padding: 10px 14px; border-radius: 10px; margin: 8px 0; text-align: center; }
|
||
.warehouse-transfer-banner { display: flex; align-items: center; gap: 12px; font-weight: 700; background: linear-gradient(135deg, #ede9fe, #dbeafe); color: #5b21b6; padding: 14px 16px; border-radius: 12px; margin-bottom: 12px; border: 2px dashed #a78bfa; }
|
||
.wt-icon { font-size: 24px; }
|
||
.wt-text { font-size: 14px; flex: 1; }
|
||
.preview-hint { font-size: 12px; background: #f0fdf4; color: #16a34a; padding: 8px 10px; border-radius: 8px; margin: 6px 0; }
|
||
</style>
|