From 65ff05fb2fc6012732c97ebf286468e6ae8a7daa Mon Sep 17 00:00:00 2001
From: duxingchen
Date: Fri, 7 Aug 2026 17:26:14 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E7=8A=B6=E6=80=81=E6=B8=B2=E6=9F=93?=
=?UTF-8?q?=E4=BF=AE=E5=A4=8D+=E4=B8=AD=E6=96=87=E5=A7=93=E5=90=8D?=
=?UTF-8?q?=E6=98=A0=E5=B0=84+SUPERVISOR/SUPER=5FADMIN=E6=9D=83=E9=99=90?=
=?UTF-8?q?=E6=8E=A7=E5=88=B6(=E5=89=8D=E7=AB=AF+=E5=90=8E=E7=AB=AF)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/api/v1/endpoints/tasks.py | 13 ++-
backend/app/services/task_service.py | 40 ++++++---
.../src/components/TaskTree/TaskFlowView.tsx | 86 +++++++++++++------
frontend/src/pages/admin/AdminTasksPage.tsx | 14 ++-
4 files changed, 110 insertions(+), 43 deletions(-)
diff --git a/backend/app/api/v1/endpoints/tasks.py b/backend/app/api/v1/endpoints/tasks.py
index 8394f35..981b251 100644
--- a/backend/app/api/v1/endpoints/tasks.py
+++ b/backend/app/api/v1/endpoints/tasks.py
@@ -3,6 +3,7 @@ from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, Query, Body
from pydantic import BaseModel, Field
+from app.services.auth_service import get_current_user
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
@@ -173,6 +174,7 @@ async def receive_task_endpoint(
remark: str | None = Body(None, description="接收备注", embed=True),
task_name: str | None = Body(None, description="接收人选定的工序名称", embed=True),
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,同步产品宏观状态。
"""
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,
operator_id: str | None = Query(None, description="操作人ID"),
db: AsyncSession = Depends(get_db),
+ current_user: dict = Depends(get_current_user),
):
"""
**品质驳回:将任务标记为 REJECTED,自动创建返工任务。**
@@ -205,7 +209,8 @@ async def reject_task_endpoint(
3. 为该负责人新建返工任务(is_rework=True, status=PENDING)。
"""
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,
operator_id: str | None = Query(None, description="操作人ID"),
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(
- db, uuid.UUID(task_id), request, operator_id
+ db, uuid.UUID(task_id), request, operator_id,
+ operator_role=current_user.get("role"),
)
diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py
index dfe428a..6c1f2be 100644
--- a/backend/app/services/task_service.py
+++ b/backend/app/services/task_service.py
@@ -30,6 +30,20 @@ from app.schemas.task import (
# 特殊位置常量
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(
db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None,
remark: str | None = None, task_name: str | None = None,
+ operator_role: str | None = None,
) -> TaskResponse:
"""
操作员确认接收任务。工人选定工序名称后接收。
@@ -442,12 +457,8 @@ async def receive_task(
"""
task = await _get_task_or_404(db, task_id)
- # 权限校验:只有负责人本人可接收
- 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}",
- )
+ # 权限校验:本人 或 管理员/主管 可操作
+ _check_permission(task.assignee_id, operator_id, operator_role)
# 校验:只有 PENDING 状态可接收
if task.status != TASK_STATUS_PENDING:
@@ -492,7 +503,8 @@ async def receive_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:
"""
品质驳回:将当前任务标记为 REJECTED,并自动创建返工任务给上一道工序负责人。
@@ -507,12 +519,8 @@ async def reject_task(
"""
task = await _get_task_or_404(db, task_id)
- # 权限校验:只有负责人本人可驳回
- 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}",
- )
+ # 权限校验:本人 或 管理员/主管 可驳回
+ _check_permission(task.assignee_id, operator_id, operator_role)
# 校验:不能重复驳回已完成/已驳回的任务
if task.status in (TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED):
@@ -612,7 +620,8 @@ async def reject_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:
"""
完工并裂变转交:
@@ -633,6 +642,9 @@ async def transfer_task(
"""
task = await _get_task_or_404(db, task_id)
+ # 权限校验:本人 或 管理员/主管 可转交
+ _check_permission(task.assignee_id, operator_id, operator_role)
+
# 校验:不能重复完成
if task.status == TASK_STATUS_COMPLETED:
raise HTTPException(
diff --git a/frontend/src/components/TaskTree/TaskFlowView.tsx b/frontend/src/components/TaskTree/TaskFlowView.tsx
index 3591259..b01473b 100644
--- a/frontend/src/components/TaskTree/TaskFlowView.tsx
+++ b/frontend/src/components/TaskTree/TaskFlowView.tsx
@@ -92,15 +92,23 @@ const FlowCard = memo(function FlowCard({
task,
isActive,
onAction,
+ currentUser,
+ assigneeName,
}: {
task: TaskResponse;
isActive: boolean;
onAction: (target: ModalTarget) => void;
+ currentUser?: { username?: string; role?: string } | null;
+ assigneeName?: string;
}) {
const cfg = getStatusConfig(task.status);
const dwell = calcDwell(task.received_at, task.completed_at, task.status);
const isCompleted = task.status?.toUpperCase() === TASK_STATUS.COMPLETED;
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 (
{task.assignee_id && (
- {task.assignee_id}
+ {assigneeName || task.assignee_id}
)}
@@ -160,49 +168,75 @@ const FlowCard = memo(function FlowCard({
{task.received_at && `接收: ${new Date(task.received_at).toLocaleDateString("zh-CN")}`}
- {/* 操作按钮 — 仅激活态显示 */}
- {isActive && (
+ {/* 操作按钮 — 仅激活态 + 有权限时显示 */}
+ {isActive && canOperate && (
{task.status?.toUpperCase() === TASK_STATUS.PENDING && (
<>
-
)}
+ {/* 非本人但管理员可见:干预提示 */}
+ {isActive && !isOwner && isManager && (
+
+
⚠ 运维干预模式
+ {task.status?.toUpperCase() === TASK_STATUS.PENDING && (
+
+ 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">
+ 强制驳回
+
+ 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">
+ 强制转交
+
+
+ )}
+ {task.status?.toUpperCase() === TASK_STATUS.WIP && (
+
+ 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">
+ 强制驳回
+
+ 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">
+ 强制转交
+
+
+ )}
+
+ )}
+ {/* 非本人非管理员:只读提示 */}
+ {isActive && !canOperate && (
+
+ )}
);
});
@@ -212,9 +246,11 @@ const FlowCard = memo(function FlowCard({
interface TaskFlowViewProps {
tasks: TaskResponse[];
onAction: (target: ModalTarget) => void;
+ currentUser?: { username?: string; role?: string } | null;
+ assigneeNames?: Record;
}
-export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction }: TaskFlowViewProps) {
+export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, currentUser, assigneeNames }: TaskFlowViewProps) {
// 按 depth 分组的层级数据
const depthMap = useMemo(() => {
if (!tasks || tasks.length === 0) return new Map();
@@ -266,6 +302,8 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction }: Task
task={task}
isActive={isActive}
onAction={onAction}
+ currentUser={currentUser}
+ assigneeName={assigneeNames?.[task.assignee_id || ""]}
/>
);
diff --git a/frontend/src/pages/admin/AdminTasksPage.tsx b/frontend/src/pages/admin/AdminTasksPage.tsx
index a5fa5b8..c2305be 100644
--- a/frontend/src/pages/admin/AdminTasksPage.tsx
+++ b/frontend/src/pages/admin/AdminTasksPage.tsx
@@ -16,6 +16,7 @@ import type { ProductResponse } from "../../types/admin";
import type { ProductScanResponse, TaskResponse } from "../../types/api";
import { useToast } from "../../components/ui/Toast";
import { getStatusConfig } from "../../constants/task";
+import { useAuth } from "../../contexts/AuthContext";
const STATUS_TABS = [
{ key: "", label: "全部" },
@@ -33,6 +34,7 @@ interface OrderGroup {
export default function AdminTasksPage() {
const { toast } = useToast();
+ const { user: currentUser } = useAuth();
// 搜索 & 筛选
const [keyword, setKeyword] = useState("");
@@ -357,7 +359,14 @@ export default function AdminTasksPage() {
const productExpanded = expandedProducts.has(p.serial_number);
const isTreeLoading = treeLoading[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 (
@@ -386,7 +395,7 @@ export default function AdminTasksPage() {
🏭 仓库
) : (
- p.current_location_id ?? "—"
+ p.current_location_name || p.current_location_id || "—"
)}
@@ -420,6 +429,7 @@ export default function AdminTasksPage() {
) : (