feat: 状态渲染修复+中文姓名映射+SUPERVISOR/SUPER_ADMIN权限控制(前端+后端)

This commit is contained in:
2026-08-07 17:26:14 +08:00
parent 592bde7cab
commit 65ff05fb2f
4 changed files with 110 additions and 43 deletions

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import uuid import uuid
from fastapi import APIRouter, Depends, Query, Body from fastapi import APIRouter, Depends, Query, Body
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.services.auth_service import get_current_user
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
@ -173,6 +174,7 @@ async def receive_task_endpoint(
remark: str | None = Body(None, description="接收备注", embed=True), remark: str | None = Body(None, description="接收备注", embed=True),
task_name: str | None = Body(None, description="接收人选定的工序名称", embed=True), task_name: str | None = Body(None, description="接收人选定的工序名称", embed=True),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
): ):
""" """
**确认接收任务。工人选定工序名称后接收。** **确认接收任务。工人选定工序名称后接收。**
@ -181,7 +183,8 @@ async def receive_task_endpoint(
动作:状态改为 WIP记录 received_at更新 task_name同步产品宏观状态。 动作:状态改为 WIP记录 received_at更新 task_name同步产品宏观状态。
""" """
return await task_service.receive_task( return await task_service.receive_task(
db, uuid.UUID(task_id), operator_id, remark, task_name db, uuid.UUID(task_id), operator_id, remark, task_name,
operator_role=current_user.get("role"),
) )
@ -195,6 +198,7 @@ async def reject_task_endpoint(
request: TaskRejectRequest, request: TaskRejectRequest,
operator_id: str | None = Query(None, description="操作人ID"), operator_id: str | None = Query(None, description="操作人ID"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
): ):
""" """
**品质驳回:将任务标记为 REJECTED自动创建返工任务。** **品质驳回:将任务标记为 REJECTED自动创建返工任务。**
@ -205,7 +209,8 @@ async def reject_task_endpoint(
3. 为该负责人新建返工任务is_rework=True, status=PENDING 3. 为该负责人新建返工任务is_rework=True, status=PENDING
""" """
return await task_service.reject_task( return await task_service.reject_task(
db, uuid.UUID(task_id), request, operator_id db, uuid.UUID(task_id), request, operator_id,
operator_role=current_user.get("role"),
) )
@ -219,6 +224,7 @@ async def transfer_task_endpoint(
request: TaskTransferRequest, request: TaskTransferRequest,
operator_id: str | None = Query(None, description="操作人ID"), operator_id: str | None = Query(None, description="操作人ID"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
): ):
""" """
**完工并裂变转交:完成当前任务,批量创建下一道工序任务。** **完工并裂变转交:完成当前任务,批量创建下一道工序任务。**
@ -237,7 +243,8 @@ async def transfer_task_endpoint(
- 否则 → 顶层同级转交。 - 否则 → 顶层同级转交。
""" """
return await task_service.transfer_task( return await task_service.transfer_task(
db, uuid.UUID(task_id), request, operator_id db, uuid.UUID(task_id), request, operator_id,
operator_role=current_user.get("role"),
) )

View File

@ -30,6 +30,20 @@ from app.schemas.task import (
# 特殊位置常量 # 特殊位置常量
VIRTUAL_WAREHOUSE = "virtual_warehouse" VIRTUAL_WAREHOUSE = "virtual_warehouse"
# 管理员/主管角色白名单 — 拥有上帝视角操作权限
ADMIN_ROLES = {"SUPER_ADMIN", "SUPERVISOR"}
def _check_permission(task_assignee_id: str | None, operator_id: str | None, operator_role: str | None = None) -> None:
"""权限校验:本人 或 管理员/主管 可操作"""
if operator_role and operator_role in ADMIN_ROLES:
return # 上帝视角,直接放行
if operator_id and task_assignee_id and operator_id != task_assignee_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"您无权操作此任务,当前任务负责人为 {task_assignee_id}",
)
# ============================================================ # ============================================================
# 内部辅助函数 # 内部辅助函数
@ -433,6 +447,7 @@ ARCHIVED_STATUS = "ARCHIVED"
async def receive_task( async def receive_task(
db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None, db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None,
remark: str | None = None, task_name: str | None = None, remark: str | None = None, task_name: str | None = None,
operator_role: str | None = None,
) -> TaskResponse: ) -> TaskResponse:
""" """
操作员确认接收任务。工人选定工序名称后接收。 操作员确认接收任务。工人选定工序名称后接收。
@ -442,12 +457,8 @@ async def receive_task(
""" """
task = await _get_task_or_404(db, task_id) task = await _get_task_or_404(db, task_id)
# 权限校验:只有负责人本人可接收 # 权限校验:本人 或 管理员/主管 可操作
if operator_id and task.assignee_id and operator_id != task.assignee_id: _check_permission(task.assignee_id, operator_id, operator_role)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"您无权操作此任务,当前任务负责人为 {task.assignee_id}",
)
# 校验:只有 PENDING 状态可接收 # 校验:只有 PENDING 状态可接收
if task.status != TASK_STATUS_PENDING: if task.status != TASK_STATUS_PENDING:
@ -492,7 +503,8 @@ async def receive_task(
# ============================================================ # ============================================================
async def reject_task( async def reject_task(
db: AsyncSession, task_id: uuid.UUID, request: TaskRejectRequest, operator_id: str | None = None db: AsyncSession, task_id: uuid.UUID, request: TaskRejectRequest, operator_id: str | None = None,
operator_role: str | None = None,
) -> TaskResponse: ) -> TaskResponse:
""" """
品质驳回:将当前任务标记为 REJECTED并自动创建返工任务给上一道工序负责人。 品质驳回:将当前任务标记为 REJECTED并自动创建返工任务给上一道工序负责人。
@ -507,12 +519,8 @@ async def reject_task(
""" """
task = await _get_task_or_404(db, task_id) task = await _get_task_or_404(db, task_id)
# 权限校验:只有负责人本人可驳回 # 权限校验:本人 或 管理员/主管 可驳回
if operator_id and task.assignee_id and operator_id != task.assignee_id: _check_permission(task.assignee_id, operator_id, operator_role)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"您无权操作此任务,当前任务负责人为 {task.assignee_id}",
)
# 校验:不能重复驳回已完成/已驳回的任务 # 校验:不能重复驳回已完成/已驳回的任务
if task.status in (TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED): if task.status in (TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED):
@ -612,7 +620,8 @@ async def reject_task(
# ============================================================ # ============================================================
async def transfer_task( async def transfer_task(
db: AsyncSession, task_id: uuid.UUID, request: TaskTransferRequest, operator_id: str | None = None db: AsyncSession, task_id: uuid.UUID, request: TaskTransferRequest, operator_id: str | None = None,
operator_role: str | None = None,
) -> TaskTransferResponse: ) -> TaskTransferResponse:
""" """
完工并裂变转交: 完工并裂变转交:
@ -633,6 +642,9 @@ async def transfer_task(
""" """
task = await _get_task_or_404(db, task_id) task = await _get_task_or_404(db, task_id)
# 权限校验:本人 或 管理员/主管 可转交
_check_permission(task.assignee_id, operator_id, operator_role)
# 校验:不能重复完成 # 校验:不能重复完成
if task.status == TASK_STATUS_COMPLETED: if task.status == TASK_STATUS_COMPLETED:
raise HTTPException( raise HTTPException(

View File

@ -92,15 +92,23 @@ const FlowCard = memo(function FlowCard({
task, task,
isActive, isActive,
onAction, onAction,
currentUser,
assigneeName,
}: { }: {
task: TaskResponse; task: TaskResponse;
isActive: boolean; isActive: boolean;
onAction: (target: ModalTarget) => void; onAction: (target: ModalTarget) => void;
currentUser?: { username?: string; role?: string } | null;
assigneeName?: string;
}) { }) {
const cfg = getStatusConfig(task.status); const cfg = getStatusConfig(task.status);
const dwell = calcDwell(task.received_at, task.completed_at, task.status); const dwell = calcDwell(task.received_at, task.completed_at, task.status);
const isCompleted = task.status?.toUpperCase() === TASK_STATUS.COMPLETED; const isCompleted = task.status?.toUpperCase() === TASK_STATUS.COMPLETED;
const isArchived = task.status?.toUpperCase() === TASK_STATUS.ARCHIVED; const isArchived = task.status?.toUpperCase() === TASK_STATUS.ARCHIVED;
// 权限:本人或管理员/主管可操作
const isOwner = !!(currentUser?.username && task.assignee_id === currentUser.username);
const isManager = currentUser?.role === "SUPER_ADMIN" || currentUser?.role === "SUPERVISOR";
const canOperate = isOwner || isManager;
return ( return (
<div <div
@ -137,7 +145,7 @@ const FlowCard = memo(function FlowCard({
</span> </span>
{task.assignee_id && ( {task.assignee_id && (
<span className="text-[10px] text-gray-400 truncate max-w-[80px]"> <span className="text-[10px] text-gray-400 truncate max-w-[80px]">
{task.assignee_id} {assigneeName || task.assignee_id}
</span> </span>
)} )}
</div> </div>
@ -160,49 +168,75 @@ const FlowCard = memo(function FlowCard({
{task.received_at && `接收: ${new Date(task.received_at).toLocaleDateString("zh-CN")}`} {task.received_at && `接收: ${new Date(task.received_at).toLocaleDateString("zh-CN")}`}
</p> </p>
{/* 操作按钮 — 仅激活态显示 */} {/* 操作按钮 — 仅激活态 + 有权限时显示 */}
{isActive && ( {isActive && canOperate && (
<div className="mt-2 flex gap-1.5 border-t border-gray-100 pt-2"> <div className="mt-2 flex gap-1.5 border-t border-gray-100 pt-2">
{task.status?.toUpperCase() === TASK_STATUS.PENDING && ( {task.status?.toUpperCase() === TASK_STATUS.PENDING && (
<> <>
<button <button onClick={() => onAction({ task, action: "receive" })}
onClick={() => onAction({ task, action: "receive" })} className="flex-1 rounded border border-blue-200 bg-blue-50 py-1 text-[10px] font-medium text-blue-600 hover:bg-blue-100">
className="flex-1 rounded border border-blue-200 bg-blue-50 py-1 text-[10px] font-medium text-blue-600 hover:bg-blue-100"
>
</button> </button>
<button <button onClick={() => onAction({ task, action: "reject" })}
onClick={() => onAction({ task, action: "reject" })} className="rounded border border-red-200 bg-red-50 px-2 py-1 text-[10px] font-medium text-red-500 hover:bg-red-100">
className="rounded border border-red-200 bg-red-50 px-2 py-1 text-[10px] font-medium text-red-500 hover:bg-red-100"
>
</button> </button>
<button <button onClick={() => onAction({ task, action: "transfer" })}
onClick={() => onAction({ task, action: "transfer" })} className="rounded border border-green-200 bg-green-50 px-2 py-1 text-[10px] font-medium text-green-600 hover:bg-green-100">
className="rounded border border-green-200 bg-green-50 px-2 py-1 text-[10px] font-medium text-green-600 hover:bg-green-100"
>
</button> </button>
</> </>
)} )}
{task.status?.toUpperCase() === TASK_STATUS.WIP && ( {task.status?.toUpperCase() === TASK_STATUS.WIP && (
<> <>
<button <button onClick={() => onAction({ task, action: "reject" })}
onClick={() => onAction({ task, action: "reject" })} className="flex-1 rounded border border-red-200 bg-red-50 py-1 text-[10px] font-medium text-red-500 hover:bg-red-100">
className="flex-1 rounded border border-red-200 bg-red-50 py-1 text-[10px] font-medium text-red-500 hover:bg-red-100"
>
</button> </button>
<button <button onClick={() => onAction({ task, action: "transfer" })}
onClick={() => onAction({ task, action: "transfer" })} className="flex-1 rounded border border-green-200 bg-green-50 py-1 text-[10px] font-medium text-green-600 hover:bg-green-100">
className="flex-1 rounded border border-green-200 bg-green-50 py-1 text-[10px] font-medium text-green-600 hover:bg-green-100"
>
</button> </button>
</> </>
)} )}
</div> </div>
)} )}
{/* 非本人但管理员可见:干预提示 */}
{isActive && !isOwner && isManager && (
<div className="mt-2 border-t border-orange-100 pt-2">
<p className="text-[9px] text-orange-500 mb-1"> </p>
{task.status?.toUpperCase() === TASK_STATUS.PENDING && (
<div className="flex gap-1.5">
<button onClick={() => onAction({ task, action: "reject" })}
className="flex-1 rounded border border-orange-200 bg-orange-50 py-1 text-[10px] font-medium text-orange-600 hover:bg-orange-100">
</button>
<button onClick={() => onAction({ task, action: "transfer" })}
className="flex-1 rounded border border-orange-200 bg-orange-50 py-1 text-[10px] font-medium text-orange-600 hover:bg-orange-100">
</button>
</div>
)}
{task.status?.toUpperCase() === TASK_STATUS.WIP && (
<div className="flex gap-1.5">
<button onClick={() => onAction({ task, action: "reject" })}
className="flex-1 rounded border border-orange-200 bg-orange-50 py-1 text-[10px] font-medium text-orange-600 hover:bg-orange-100">
</button>
<button onClick={() => onAction({ task, action: "transfer" })}
className="flex-1 rounded border border-orange-200 bg-orange-50 py-1 text-[10px] font-medium text-orange-600 hover:bg-orange-100">
</button>
</div>
)}
</div>
)}
{/* 非本人非管理员:只读提示 */}
{isActive && !canOperate && (
<div className="mt-2 border-t border-gray-100 pt-2">
<p className="text-[9px] text-gray-400 text-center"></p>
</div>
)}
</div> </div>
); );
}); });
@ -212,9 +246,11 @@ const FlowCard = memo(function FlowCard({
interface TaskFlowViewProps { interface TaskFlowViewProps {
tasks: TaskResponse[]; tasks: TaskResponse[];
onAction: (target: ModalTarget) => void; onAction: (target: ModalTarget) => void;
currentUser?: { username?: string; role?: string } | null;
assigneeNames?: Record<string, string>;
} }
export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction }: TaskFlowViewProps) { export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, currentUser, assigneeNames }: TaskFlowViewProps) {
// 按 depth 分组的层级数据 // 按 depth 分组的层级数据
const depthMap = useMemo(() => { const depthMap = useMemo(() => {
if (!tasks || tasks.length === 0) return new Map<number, TaskResponse[]>(); if (!tasks || tasks.length === 0) return new Map<number, TaskResponse[]>();
@ -266,6 +302,8 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction }: Task
task={task} task={task}
isActive={isActive} isActive={isActive}
onAction={onAction} onAction={onAction}
currentUser={currentUser}
assigneeName={assigneeNames?.[task.assignee_id || ""]}
/> />
</div> </div>
); );

View File

@ -16,6 +16,7 @@ import type { ProductResponse } from "../../types/admin";
import type { ProductScanResponse, TaskResponse } from "../../types/api"; import type { ProductScanResponse, TaskResponse } from "../../types/api";
import { useToast } from "../../components/ui/Toast"; import { useToast } from "../../components/ui/Toast";
import { getStatusConfig } from "../../constants/task"; import { getStatusConfig } from "../../constants/task";
import { useAuth } from "../../contexts/AuthContext";
const STATUS_TABS = [ const STATUS_TABS = [
{ key: "", label: "全部" }, { key: "", label: "全部" },
@ -33,6 +34,7 @@ interface OrderGroup {
export default function AdminTasksPage() { export default function AdminTasksPage() {
const { toast } = useToast(); const { toast } = useToast();
const { user: currentUser } = useAuth();
// 搜索 & 筛选 // 搜索 & 筛选
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
@ -357,7 +359,14 @@ export default function AdminTasksPage() {
const productExpanded = expandedProducts.has(p.serial_number); const productExpanded = expandedProducts.has(p.serial_number);
const isTreeLoading = treeLoading[p.serial_number]; const isTreeLoading = treeLoading[p.serial_number];
const tree = taskTrees[p.serial_number]; const tree = taskTrees[p.serial_number];
const statusCfg = getStatusConfig(p.status); // 综合状态:优先流转树状态,兜底产品状态
const treeStatus = tree
? (tree.task_tree?.some(t => t.status === "WIP") ? "WIP"
: tree.task_tree?.every(t => t.status === "COMPLETED" || t.status === "ARCHIVED") ? "COMPLETED"
: tree.task_tree?.[0]?.status)
: null;
const displayStatus = treeStatus || p.status;
const statusCfg = getStatusConfig(displayStatus);
return ( return (
<div key={p.id}> <div key={p.id}>
@ -386,7 +395,7 @@ export default function AdminTasksPage() {
🏭 🏭
</span> </span>
) : ( ) : (
p.current_location_id ?? "—" p.current_location_name || p.current_location_id || "—"
)} )}
</div> </div>
<div className="col-span-2 text-xs text-gray-400"> <div className="col-span-2 text-xs text-gray-400">
@ -420,6 +429,7 @@ export default function AdminTasksPage() {
<TaskFlowView <TaskFlowView
tasks={tree.task_tree} tasks={tree.task_tree}
onAction={setModalTarget} onAction={setModalTarget}
currentUser={currentUser}
/> />
) : ( ) : (
<div className="flex flex-col items-center justify-center py-12 text-gray-400"> <div className="flex flex-col items-center justify-center py-12 text-gray-400">