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;