diff --git a/frontend/src/components/TaskTree/TaskTreeViewer.tsx b/frontend/src/components/TaskTree/TaskTreeViewer.tsx index c040f15..fd06858 100644 --- a/frontend/src/components/TaskTree/TaskTreeViewer.tsx +++ b/frontend/src/components/TaskTree/TaskTreeViewer.tsx @@ -19,7 +19,8 @@ import { } from "../../services/taskApi"; import { useToast } from "../ui/Toast"; import type { ProductScanResponse, TaskResponse } from "../../types/api"; -import { getStatusConfig } from "../../constants/task"; +import { getStatusConfig, isAdminRole } from "../../constants/task"; +import { useAuth } from "../../contexts/AuthContext"; import ImageUploader from "../ui/ImageUploader"; import { extractErrorMessage } from "../../utils/errorMessage"; @@ -266,6 +267,16 @@ export const TransferModal = memo(function TransferModal({ setNote(""); }, [open]); + // 🔒 直接完结入口仅超管/主管【可见】:普通人看不到这个选项,而不是点了才被后端 403。 + // 后端那道硬拦截依然保留 —— 这里是体验层,角色来自客户端存储、可被伪造。 + const { user } = useAuth(); + const canFinishDirectly = isAdminRole(user?.role); + + // 防御:非管理员若因任何原因残留了选中态,强制清掉,避免提交出 finish_directly + useEffect(() => { + if (!canFinishDirectly) setFinishDirectly(false); + }, [canFinishDirectly]); + if (!task) return null; function handleAddAssignee() { @@ -467,7 +478,8 @@ export const TransferModal = memo(function TransferModal({ - {/* 🏁 直接完结 — 与转交/入库互斥 */} + {/* 🏁 直接完结 — 与转交/入库互斥。仅超管/主管可见 */} + {canFinishDirectly && (
+ )} {/* 备注 */}
diff --git a/frontend/src/constants/task.ts b/frontend/src/constants/task.ts index bdd1030..fec0b5d 100644 --- a/frontend/src/constants/task.ts +++ b/frontend/src/constants/task.ts @@ -200,8 +200,17 @@ export const AFTER_SALES_OVERALL_OPTIONS = [ export function taskOptionsFor( phase: string | null | undefined, hasHistory = true, + overallStatus?: string | null, ): string[] { - if (phase === LIFECYCLE_PHASE.AFTER_SALES) return AFTER_SALES_TASK_OPTIONS; + // 🔧 出库设备与售后生命周期同等待遇(与 track-uniapp/src/utils/lifecycle.js 同步): + // 后端为消除不可回退的售后烙印,已不再把「正常已出库做质检」的设备翻成 + // AFTER_SALES。若这里仍只认 phase,工人永远选不到「发货测试 / 售后维修」。 + if ( + phase === LIFECYCLE_PHASE.AFTER_SALES || + String(overallStatus || "").trim() === "已出库" + ) { + return AFTER_SALES_TASK_OPTIONS; + } if (!hasHistory) return [...PRODUCTION_TASK_OPTIONS, ...AFTER_SALES_ONLY_STEPS]; return PRODUCTION_TASK_OPTIONS; } @@ -217,6 +226,22 @@ export function overallOptionsFor( } /** 列表筛选枚举 — 两阶段并集(用于筛选,不是录入项) */ +/** + * 管理角色 — 必须与后端 task_service.ADMIN_ROLES 保持一致。 + * + * ⚠️ 收敛到这里的理由:此前这段判断散落在多处(TaskFlowView / AdminProductsPage), + * 而移动端那份只判了 SUPER_ADMIN、漏了 SUPERVISOR,导致主管被前端误挡。 + * 新增判断一律用 isAdminRole(),不要再手写 === 比较。 + * + * 注意:这是【体验层】判断,角色来自客户端存储、可被伪造。 + * 真正的拦截在后端(transfer_task 对 finish_directly 有 403 硬校验)。 + */ +export const ADMIN_ROLES = ["SUPER_ADMIN", "SUPERVISOR"] as const; + +export function isAdminRole(role?: string | null): boolean { + return !!role && (ADMIN_ROLES as readonly string[]).includes(role); +} + export const ALL_OVERALL_OPTIONS = [ ...PRODUCTION_OVERALL_OPTIONS, ...AFTER_SALES_ONLY_STEPS, ]; diff --git a/frontend/src/pages/admin/AdminTasksPage.tsx b/frontend/src/pages/admin/AdminTasksPage.tsx index 0a35d4c..8b2e817 100644 --- a/frontend/src/pages/admin/AdminTasksPage.tsx +++ b/frontend/src/pages/admin/AdminTasksPage.tsx @@ -114,15 +114,19 @@ export default function AdminTasksPage() { render: (p) =>
{p.spec_model || p.material_name || p.material_id || "—"}
, }, { - key: "overall_status", label: "当前工序", colSpan: 1, - filterType: "enum", getFilterValue: (p) => p.overall_status || "—", + key: "current_step", label: "当前工序", colSpan: 1, + // 🔧 改用后端派生的 current_step,而不是 overall_status —— + // 后者的取值被"最新主干任务名"覆盖(含已 COMPLETED 的历史工序), + // 会让「扫码出库 / 测试 / 发货测试」等已完结工序冒充"当前工序"。 + // current_step 只认活跃主干任务;无活跃任务时返回终态或空。 + filterType: "enum", getFilterValue: (p) => p.current_step || "—", // 🔧 筛选用两阶段并集:售后设备的「发货测试 / 售后维修」也要能被筛出来 enumOptions: ALL_OVERALL_OPTIONS.map((v) => ({ value: v, label: v })), render: (p) => { - const lb = lifecycleBadge(p.overall_status, p.lifecycle_phase); + const lb = lifecycleBadge(p.current_step, p.lifecycle_phase); return ( - {p.overall_status || "—"} + {p.current_step || "—"} {lb && ( {lb.label} diff --git a/frontend/src/types/admin.ts b/frontend/src/types/admin.ts index 9767d56..7c2d6fa 100644 --- a/frontend/src/types/admin.ts +++ b/frontend/src/types/admin.ts @@ -17,6 +17,14 @@ export interface ProductResponse { current_location_name: string | null; macro_status: string | null; overall_status: string | null; + /** + * 【当前工序】— 只反映"此刻在做什么",绝不拿已完结的历史工序冒充。 + * · 有活跃主干任务(WIP/PENDING) → 该工序名 + * · 否则处于宏观终态(待仓库收货/已入库/在库/已出库) → 该终态 + * · 否则(活已干完)→ ""(列表显示「—」) + * ⚠️ 不要用 overall_status 渲染该列:它会被"最新主干任务名"覆盖,含已 COMPLETED 的。 + */ + current_step?: string; status: string; /** 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修) */ lifecycle_phase?: LifecyclePhase;