feat: 移动端工序选项隔离与售后标签

This commit is contained in:
2026-09-14 14:49:38 +08:00
parent 3238e4819a
commit 31d085881b
2 changed files with 118 additions and 8 deletions

View File

@ -10,6 +10,8 @@
<view class="overall-bar" @tap="handleOverallBarClick">
<text class="overall-label">宏观状态</text>
<text :class="['overall-val', overallStatusClass(product.overall_status)]">{{ product.overall_status || '未激活 — 点击发起首道工序' }}</text>
<!-- 🔧 生命周期标识生产阶段发货测试 vs 出库回流后售后维修 -->
<text v-if="lifeBadge" :class="['life-badge', lifeBadge.cls]">{{ lifeBadge.label }}</text>
<text v-if="product.task_tree && product.task_tree.length && canEditOverallStatus" class="overall-arrow"></text>
</view>
@ -70,7 +72,7 @@
<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 v-for="opt in availableOverallOptions" :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>
@ -124,7 +126,7 @@
<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"
<view v-for="opt in availableTaskOptions" :key="opt"
:class="['user-grid-item', receiveTaskName === opt ? 'user-grid-active' : '']"
@tap="receiveTaskName = opt">{{ opt }}</view>
</view>
@ -217,21 +219,22 @@
<script>
import request, { get, post, patch, put, getBaseUrl } from "../../utils/request";
import { setUserNameMap, formatUserName, formatUserAvatar } from "../../utils/format";
import { taskOptionsFor, overallOptionsFor, lifecycleBadge } from "../../utils/lifecycle";
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 = ["备货", "生产", "测试", "维修", "在库"];
// 🔧 工序可选项不再写死 —— 由 computed availableOverallOptions / availableTaskOptions
// 按生命周期阶段(生产制造 / 售后回流)动态收窄,词表见 utils/lifecycle.js
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", OUTBOUND: "已出库", CANCELED: "已撤回" };
export default {
components: { WorkspaceArea, TreeCanvas, TaskSwipeCards },
data() {
return {
OVERALL_OPTIONS, loading: true, error: "", product: null,
loading: true, error: "", product: null,
showStatusPicker: false, editProductVisible: false, editForm: { order_no: "", external_serial: "" }, editSaving: false,
users: [], TASK_NAME_OPTIONS,
users: [],
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: "", currentUserRole: "", currentMode: "workspace", autoLockTaskId: "",
@ -287,6 +290,42 @@ export default {
checkTask(this.product.task_tree);
return hasPermission;
},
// 🔧 生命周期标签:只在售后回流环节(发货测试 / 售后维修)打红色标签
lifeBadge() {
return lifecycleBadge(
this.product && this.product.overall_status,
this.product && this.product.lifecycle_phase,
);
},
// ── 🔧 选项隔离:按生命周期阶段收窄可选工序 ──
// 生产制造设备只能排「备货/生产/测试/维修/在库」;
// 售后回流设备只能选「发货测试/售后维修/在库」,排不回前期环节。
isAfterSales() {
return !!(this.product && this.product.lifecycle_phase === "AFTER_SALES");
},
// 是否已有"真实工序"历史 —— 仅判断 task_tree 非空是不够的:
// 老设备首次「发起首道工序」建出来的任务名是占位符「待确认」,
// 此时产品已有一条任务,但接收人还没机会声明工序。若按 task_tree 非空
// 就判定"有历史",接收下拉将只给生产工序,老设备永远选不到「售后维修」,
// 售后通路直接断掉。故这里必须排除占位符。
hasHistory() {
const walk = (tasks) => {
if (!tasks) return false;
for (const t of tasks) {
const name = String(t.task_name || "").trim();
if (name && name !== "待确认" && !name.includes("virtual_warehouse")) return true;
if (walk(t.child_tasks)) return true;
}
return false;
};
return walk(this.product && this.product.task_tree);
},
availableOverallOptions() {
return overallOptionsFor(this.product && this.product.lifecycle_phase, this.hasHistory);
},
availableTaskOptions() {
return taskOptionsFor(this.product && this.product.lifecycle_phase, this.hasHistory);
},
},
onLoad(options) { this.loadUsers(); this.loadCurrentUser(); const sn = options.serial || ""; if (sn) { this.doQuery(sn); return; } /* 🚀 兜底: 从 taskId 反查 product */ const tid = options.taskId || ""; if (tid) this.doQueryByTask(tid); },
// 🚀 onShow 生命周期:每次页面显示时刷新留言板(解决从聊天室退回不更新问题)
@ -335,7 +374,7 @@ export default {
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 })); setUserNameMap(this.users); this.dictVersion += 1; } catch (e) { console.error('[loadUsers] 获取用户列表失败:', e); } },
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 || ""; this.currentUserRole = user.role || ""; } } catch {} },
onTaskNameChange(e) { const idx = e.detail.value; this.firstForm.taskNameIdx = idx; this.firstForm.task_name = TASK_NAME_OPTIONS[idx]; },
onTaskNameChange(e) { const idx = e.detail.value; this.firstForm.taskNameIdx = idx; this.firstForm.task_name = this.availableTaskOptions[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 if (this.canEditOverallStatus) { this.showStatusPicker = true; } else { uni.showToast({ title: '仅超级管理员或当前主线负责人可修改状态', icon: 'none', duration: 2500 }); } },
@ -356,7 +395,7 @@ export default {
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(); } }
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.transferForm = { selectedUserId: "", isWarehouse: false, note: "" };
this.spawnForm = { assignee_id: "", remark: "" };
@ -490,6 +529,9 @@ 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; }
.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; }

View File

@ -0,0 +1,68 @@
/**
* 生命周期阶段lifecycle_phase与工序选项隔离 — 移动端口径
*
* ⚠️ 与后端 backend/app/core/lifecycle.py、PC 端 frontend/src/constants/task.ts
* 是同一份词表,改动请三处同步。
*
* 售后回流阶段使用**独立工序名**(发货测试 / 售后维修),不复用生产阶段的
* 「测试 / 维修」,选中售后专属工序即代表设备进入售后生命周期。
*
* 本模块只负责"该给操作员看哪些选项"(体验层);
* 真正的拦截在后端 task_service._enforce_step_isolation防伪造传参
*/
export const LIFECYCLE_PHASE = {
PRODUCTION: "PRODUCTION",
AFTER_SALES: "AFTER_SALES",
};
/** 售后专属工序名 — 选中即代表设备进入售后生命周期 */
export const STEP_SHIP_TEST = "发货测试";
export const STEP_AFTER_SALES_REPAIR = "售后维修";
const AFTER_SALES_ONLY_STEPS = [STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR];
// ── 生产制造阶段合法工序(原有词表,一字未改)──
export const PRODUCTION_TASK_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
export const PRODUCTION_OVERALL_OPTIONS = [
"备货", "生产", "测试", "维修", "在库", "已入库", "已出库",
];
// ── 售后回流阶段:只保留「发货测试 / 售后维修 / 入库出库」──
export const AFTER_SALES_TASK_OPTIONS = [STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库"];
export const AFTER_SALES_OVERALL_OPTIONS = [
STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库", "已入库", "已出库",
];
/**
* 接收任务时可选工序。
*
* hasHistory = false无任何历史任务的首次激活 / 老设备)时返回**并集**
* 这类设备既可能是新投产,也可能是直接返厂售后的老设备,让操作员自己声明。
* 一旦选定售后专属工序,后端即把设备判为 AFTER_SALES之后下拉自动收窄。
*/
export function taskOptionsFor(phase, hasHistory = true) {
if (phase === LIFECYCLE_PHASE.AFTER_SALES) return AFTER_SALES_TASK_OPTIONS;
if (!hasHistory) return [...PRODUCTION_TASK_OPTIONS, ...AFTER_SALES_ONLY_STEPS];
return PRODUCTION_TASK_OPTIONS;
}
/** 宏观状态可选值 — 同上,含已入库/已出库等终态 */
export function overallOptionsFor(phase, hasHistory = true) {
if (phase === LIFECYCLE_PHASE.AFTER_SALES) return AFTER_SALES_OVERALL_OPTIONS;
if (!hasHistory) return [...PRODUCTION_OVERALL_OPTIONS, ...AFTER_SALES_ONLY_STEPS];
return PRODUCTION_OVERALL_OPTIONS;
}
/**
* 生命周期标签 — 只在设备确实处于售后回流环节时返回:
* 生命周期为 AFTER_SALES 且当前工序是「发货测试」或「售后维修」
* → 红底白字,文案即工序名。
* 生产制造阶段一律返回 null不打标避免与售后设备混淆。
*/
export function lifecycleBadge(overallStatus, phase) {
if (phase !== LIFECYCLE_PHASE.AFTER_SALES) return null;
const step = String(overallStatus || "").trim();
if (step !== STEP_SHIP_TEST && step !== STEP_AFTER_SALES_REPAIR) return null;
return { label: step, phase: LIFECYCLE_PHASE.AFTER_SALES, cls: "life-badge-after" };
}