feat: PC 端直接完结入口与当前工序口径(前端)

【直接完结入口】
- 共享 TransferModal 新增「🏁 直接完结」开关,与「入库」互斥;选中后下游字段
  禁用(保留输入便于反悔)、标题/预览/按钮切换为独立视觉
- 入口仅超管/主管可见(useAuth + isAdminRole),并加防御性 useEffect 清掉
  非管理员的残留选中态,避免提交出 finish_directly
- 表单重置改为「打开时重置」:TransferModal 被 memo 后常驻挂载会跨次残留状态,
  且原实现于提交时清空会让等待期主题从「直接完结」闪回普通转交

【角色判断收口】
- 新增 ADMIN_ROLES / isAdminRole(与后端 task_service.ADMIN_ROLES 对齐)
  此前该判断散落在 TaskFlowView / AdminProductsPage 各处,移动端那份还只判了
  SUPER_ADMIN 漏了 SUPERVISOR,导致主管被前端误挡

【当前工序口径】
- 「当前工序」列改用后端派生的 current_step,不再用 overall_status ——
  后者会被"最新主干任务名"覆盖,让已 COMPLETED 的历史工序(扫码出库/测试…)
  冒充当前工序。显示与筛选值一并切换以保持一致
- ProductResponse 类型补齐 current_step
This commit is contained in:
2026-09-17 17:05:51 +08:00
parent d666bbd4ed
commit ee25b8fd23
4 changed files with 62 additions and 10 deletions

View File

@ -19,7 +19,8 @@ import {
} from "../../services/taskApi"; } from "../../services/taskApi";
import { useToast } from "../ui/Toast"; import { useToast } from "../ui/Toast";
import type { ProductScanResponse, TaskResponse } from "../../types/api"; 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 ImageUploader from "../ui/ImageUploader";
import { extractErrorMessage } from "../../utils/errorMessage"; import { extractErrorMessage } from "../../utils/errorMessage";
@ -266,6 +267,16 @@ export const TransferModal = memo(function TransferModal({
setNote(""); setNote("");
}, [open]); }, [open]);
// 🔒 直接完结入口仅超管/主管【可见】:普通人看不到这个选项,而不是点了才被后端 403。
// 后端那道硬拦截依然保留 —— 这里是体验层,角色来自客户端存储、可被伪造。
const { user } = useAuth();
const canFinishDirectly = isAdminRole(user?.role);
// 防御:非管理员若因任何原因残留了选中态,强制清掉,避免提交出 finish_directly
useEffect(() => {
if (!canFinishDirectly) setFinishDirectly(false);
}, [canFinishDirectly]);
if (!task) return null; if (!task) return null;
function handleAddAssignee() { function handleAddAssignee() {
@ -467,7 +478,8 @@ export const TransferModal = memo(function TransferModal({
</span> </span>
</label> </label>
{/* 🏁 直接完结 — 与转交/入库互斥 */} {/* 🏁 直接完结 — 与转交/入库互斥。仅超管/主管可见 */}
{canFinishDirectly && (
<div> <div>
<label className="flex items-center gap-2 cursor-pointer select-none"> <label className="flex items-center gap-2 cursor-pointer select-none">
<input <input
@ -482,12 +494,15 @@ export const TransferModal = memo(function TransferModal({
</label> </label>
{finishDirectly && ( {finishDirectly && (
<p className="mt-1.5 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700"> <p className="mt-1.5 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700">
<span className="font-semibold"></span> <span className="font-semibold"></span>
<span className="font-semibold"></span>
WIP
</p> </p>
)} )}
</div> </div>
)}
{/* 备注 */} {/* 备注 */}
<div> <div>

View File

@ -200,8 +200,17 @@ export const AFTER_SALES_OVERALL_OPTIONS = [
export function taskOptionsFor( export function taskOptionsFor(
phase: string | null | undefined, phase: string | null | undefined,
hasHistory = true, hasHistory = true,
overallStatus?: string | null,
): string[] { ): 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]; if (!hasHistory) return [...PRODUCTION_TASK_OPTIONS, ...AFTER_SALES_ONLY_STEPS];
return PRODUCTION_TASK_OPTIONS; 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 = [ export const ALL_OVERALL_OPTIONS = [
...PRODUCTION_OVERALL_OPTIONS, ...AFTER_SALES_ONLY_STEPS, ...PRODUCTION_OVERALL_OPTIONS, ...AFTER_SALES_ONLY_STEPS,
]; ];

View File

@ -114,15 +114,19 @@ export default function AdminTasksPage() {
render: (p) => <div className="text-xs text-gray-500 truncate">{p.spec_model || p.material_name || p.material_id || "—"}</div>, render: (p) => <div className="text-xs text-gray-500 truncate">{p.spec_model || p.material_name || p.material_id || "—"}</div>,
}, },
{ {
key: "overall_status", label: "当前工序", colSpan: 1, key: "current_step", label: "当前工序", colSpan: 1,
filterType: "enum", getFilterValue: (p) => p.overall_status || "—", // 🔧 改用后端派生的 current_step而不是 overall_status ——
// 后者的取值被"最新主干任务名"覆盖(含已 COMPLETED 的历史工序),
// 会让「扫码出库 / 测试 / 发货测试」等已完结工序冒充"当前工序"。
// current_step 只认活跃主干任务;无活跃任务时返回终态或空。
filterType: "enum", getFilterValue: (p) => p.current_step || "—",
// 🔧 筛选用两阶段并集:售后设备的「发货测试 / 售后维修」也要能被筛出来 // 🔧 筛选用两阶段并集:售后设备的「发货测试 / 售后维修」也要能被筛出来
enumOptions: ALL_OVERALL_OPTIONS.map((v) => ({ value: v, label: v })), enumOptions: ALL_OVERALL_OPTIONS.map((v) => ({ value: v, label: v })),
render: (p) => { render: (p) => {
const lb = lifecycleBadge(p.overall_status, p.lifecycle_phase); const lb = lifecycleBadge(p.current_step, p.lifecycle_phase);
return ( return (
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="text-xs font-medium text-gray-700">{p.overall_status || "—"}</span> <span className="text-xs font-medium text-gray-700">{p.current_step || "—"}</span>
{lb && ( {lb && (
<span className={`shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold leading-none ${lb.className}`}> <span className={`shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold leading-none ${lb.className}`}>
{lb.label} {lb.label}

View File

@ -17,6 +17,14 @@ export interface ProductResponse {
current_location_name: string | null; current_location_name: string | null;
macro_status: string | null; macro_status: string | null;
overall_status: string | null; overall_status: string | null;
/**
* 【当前工序】— 只反映"此刻在做什么",绝不拿已完结的历史工序冒充。
* · 有活跃主干任务(WIP/PENDING) → 该工序名
* · 否则处于宏观终态(待仓库收货/已入库/在库/已出库) → 该终态
* · 否则(活已干完)→ ""(列表显示「—」)
* ⚠️ 不要用 overall_status 渲染该列:它会被"最新主干任务名"覆盖,含已 COMPLETED 的。
*/
current_step?: string;
status: string; status: string;
/** 🔧 生命周期阶段PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修) */ /** 🔧 生命周期阶段PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修) */
lifecycle_phase?: LifecyclePhase; lifecycle_phase?: LifecyclePhase;