feat(分组权限): 分组管理接口 + 管理页 + 端到端验收

后端 endpoints/groups.py(**仅 SUPER_ADMIN**):
· 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的
  生产/测试小组都不用再配)
· 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT)
· **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然
  看不到数据了;强制「先移人再删组」多一步,但出错时是可见的
· 停用组的语义写死在接口文档:成员**立即**退回未分组状态

为什么只有超管能管分组(不是偏好,是必须):
  被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组,
  他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。

前端:
· AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认
· AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼
  提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈
· AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它)
· constants/task.ts 新增 isSuperAdmin,不手写 === 比较

端到端验收(实测):先造 1 生产 + 1 售后产品,然后
  生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来
  维修组员 → 1 条,全 AFTER_SALES
  超管     → 2 条,全量
  任务列表 total 与 returned 一致(验证 count/select 双过滤)
  扫码跨组仍 200(符合「能看、不能操作」的既定决策)
  停用维修大组 → 成员立即退回未分组
  上述测试数据已还原
This commit is contained in:
2026-09-21 17:10:33 +08:00
parent cfcfca7269
commit 3c1c5d6fb5
9 changed files with 1218 additions and 3 deletions

View File

@ -0,0 +1,468 @@
"""业务分组管理 API —— **仅超级管理员**可访问
⚠️ 为什么只有 SUPER_ADMIN 能管分组,SUPERVISOR 不行:
按数据范围规则,被显式分进组的 SUPERVISOR 会从「全厂」**降级**为只看本组。
如果允许 SUPERVISOR 管理分组,那么他被分组之后,只要把自己从组里移出去
就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他完全无效。
所以这里用 require_roles(SUPER_ADMIN),**不能**用 require_admin
(后者含 SUPERVISOR)。
写操作会被 audit_middleware 自动采集 —— 分组变更是高权限动作,追责必须有据。
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.deps import require_roles
from app.core.lifecycle import PHASE_LABELS
from app.core.mom_database import MomSessionLocal
from app.core.roles import SUPER_ADMIN
from app.models.business_group import (
BusinessGroup,
BusinessGroupMember,
BusinessGroupPhase,
)
from app.schemas.group import (
GroupCreate,
GroupDetailOut,
GroupMemberAdd,
GroupMemberOut,
GroupMemberUpdate,
GroupOut,
GroupUpdate,
MemberCandidate,
PhaseOption,
)
router = APIRouter(
prefix="/groups",
tags=["业务分组"],
dependencies=[Depends(require_roles(SUPER_ADMIN))],
)
# ============================================================
# 内部工具
# ============================================================
def _valid_phases() -> dict[str, str]:
"""合法的 phase 取值 → 中文标签(单一事实来源是 core/lifecycle.py)"""
return dict(PHASE_LABELS)
async def _load_phases(db: AsyncSession, group_ids: list[int]) -> dict[int, list[str]]:
"""批量取这些组**自己配置**的可见范围(不含继承)"""
if not group_ids:
return {}
rows = await db.execute(
select(BusinessGroupPhase.group_id, BusinessGroupPhase.phase)
.where(BusinessGroupPhase.group_id.in_(group_ids))
)
out: dict[int, list[str]] = {}
for gid, ph in rows.all():
out.setdefault(gid, []).append(ph)
return out
async def _effective_phases(
db: AsyncSession, group: BusinessGroup, own: dict[int, list[str]],
) -> list[str]:
"""实际生效的可见范围:自己配了就用,没配则向上取父组的。
继承让「生产大组配一次 PRODUCTION,下面的生产/测试小组都不用再配」成立。
"""
mine = own.get(group.id)
if mine:
return sorted(mine)
if group.parent_id:
return sorted(own.get(group.parent_id, []))
return []
async def _member_counts(db: AsyncSession) -> dict[int, int]:
rows = await db.execute(
select(BusinessGroupMember.group_id, func.count())
.group_by(BusinessGroupMember.group_id)
)
return {gid: cnt for gid, cnt in rows.all()}
def _to_out(group: BusinessGroup, phases: list[str], count: int,
parent_name: str | None, own_phases: list[str]) -> GroupOut:
return GroupOut(
id=group.id,
name=group.name,
parent_id=group.parent_id,
parent_name=parent_name,
description=group.description,
sort_order=group.sort_order,
is_active=group.is_active,
phases=sorted(own_phases),
effective_phases=phases,
phase_labels=[PHASE_LABELS.get(p, p) for p in phases],
member_count=count,
)
async def _get_group_or_404(db: AsyncSession, group_id: int) -> BusinessGroup:
group = await db.get(BusinessGroup, group_id)
if not group:
raise HTTPException(status.HTTP_404_NOT_FOUND, f"分组 {group_id} 不存在")
return group
# ============================================================
# 元数据:可选阶段
# 放在 /{group_id} 之前注册,否则 "phases" 会被当成 group_id 解析
# ============================================================
@router.get("/phase-options", response_model=list[PhaseOption])
async def list_phase_options():
"""可选的生命周期阶段 —— 供前端渲染勾选框,避免前端写死这两个值"""
return [PhaseOption(value=v, label=l) for v, l in _valid_phases().items()]
@router.get("/member-candidates", response_model=list[MemberCandidate])
def list_member_candidates(
keyword: str = Query("", description="按姓名/账号模糊搜索"),
limit: int = Query(500, ge=1, le=1000),
):
"""候选人下拉 —— 复用与 users.py 一致的 MOM 查询口径(部门已钉死为 ORG_DEPARTMENT)。
注意这里**不复用 users.py 的端点函数**:那个函数与 FastAPI 的 Query 默认值
耦合,直接调用拿到的是 Query 对象而非值。所以照抄同一条 SQL 的写法,
但部门条件取自同一处 settings.ORG_DEPARTMENT,口径不会漂移。
"""
db = MomSessionLocal()
try:
base_sql = """
SELECT username,
SPLIT_PART(username, '/', 1) AS full_name
FROM sys_user
WHERE department = :dept
"""
params = {"dept": settings.ORG_DEPARTMENT, "lim": limit}
if keyword.strip():
sql_text = base_sql + " AND username ILIKE :kw ORDER BY username LIMIT :lim"
params["kw"] = f"%{keyword.strip()}%"
else:
sql_text = base_sql + " ORDER BY username LIMIT :lim"
from sqlalchemy import text
rows = db.execute(text(sql_text), params).fetchall()
return [
MemberCandidate(
username=row.username.split("/")[-1] if "/" in row.username else row.username,
full_name=row.full_name or row.username,
)
for row in rows
]
except Exception as e:
raise HTTPException(
status.HTTP_502_BAD_GATEWAY, f"MOM 用户查询失败: {str(e)}"
)
finally:
db.close()
# ============================================================
# 组的 CRUD
# ============================================================
@router.get("", response_model=list[GroupOut])
async def list_groups(db: AsyncSession = Depends(get_db)):
"""列出全部业务分组(含停用的),带成员数与生效范围"""
groups = (await db.execute(
select(BusinessGroup).order_by(BusinessGroup.sort_order, BusinessGroup.id)
)).scalars().all()
own = await _load_phases(db, [g.id for g in groups])
counts = await _member_counts(db)
names = {g.id: g.name for g in groups}
return [
_to_out(
g,
await _effective_phases(db, g, own),
counts.get(g.id, 0),
names.get(g.parent_id) if g.parent_id else None,
own.get(g.id, []),
)
for g in groups
]
@router.get("/{group_id}", response_model=GroupDetailOut)
async def get_group(group_id: int, db: AsyncSession = Depends(get_db)):
"""分组详情 + 成员列表"""
group = await _get_group_or_404(db, group_id)
own = await _load_phases(db, [group.id] + ([group.parent_id] if group.parent_id else []))
members = (await db.execute(
select(BusinessGroupMember)
.where(BusinessGroupMember.group_id == group.id)
.order_by(BusinessGroupMember.is_leader.desc(), BusinessGroupMember.user_id)
)).scalars().all()
# 成员姓名走 MOM(带 2h TTL 缓存,见 mom_cache)
from app.services.mom_cache import get_display_names
name_map = get_display_names([m.user_id for m in members]) if members else {}
parent_name = None
if group.parent_id:
parent = await db.get(BusinessGroup, group.parent_id)
parent_name = parent.name if parent else None
base = _to_out(
group,
await _effective_phases(db, group, own),
len(members),
parent_name,
own.get(group.id, []),
)
return GroupDetailOut(
**base.model_dump(),
members=[
GroupMemberOut(
user_id=m.user_id,
display_name=name_map.get(m.user_id) or m.user_id,
is_leader=m.is_leader,
)
for m in members
],
)
@router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
async def create_group(payload: GroupCreate, db: AsyncSession = Depends(get_db)):
"""新建分组。parent_id 为空即建大组,否则是挂在某个大组下的小组。"""
valid = _valid_phases()
bad = [p for p in payload.phases if p not in valid]
if bad:
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"非法的阶段取值: {bad}")
dup = await db.scalar(select(BusinessGroup.id).where(BusinessGroup.name == payload.name))
if dup:
raise HTTPException(status.HTTP_409_CONFLICT, f"分组名「{payload.name}」已存在")
if payload.parent_id is not None:
parent = await db.get(BusinessGroup, payload.parent_id)
if not parent:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "上级分组不存在")
if parent.parent_id is not None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "只支持两级:不能挂在子组下")
group = BusinessGroup(
name=payload.name,
parent_id=payload.parent_id,
description=payload.description,
sort_order=payload.sort_order,
)
db.add(group)
await db.flush()
for p in payload.phases:
db.add(BusinessGroupPhase(group_id=group.id, phase=p))
await db.commit()
await db.refresh(group)
# 生效范围要算上继承 —— 否则新建子组时返回的 effective_phases 是空的,
# 与紧接着的列表查询结果对不上(前端直接拿返回值渲染会闪一下「无范围」)
own = await _load_phases(db, [group.id] + ([group.parent_id] if group.parent_id else []))
parent_name = None
if group.parent_id:
parent = await db.get(BusinessGroup, group.parent_id)
parent_name = parent.name if parent else None
return _to_out(
group,
await _effective_phases(db, group, own),
0,
parent_name,
own.get(group.id, []),
)
@router.patch("/{group_id}", response_model=GroupOut)
async def update_group(
group_id: int, payload: GroupUpdate, db: AsyncSession = Depends(get_db),
):
"""修改分组。
⚠️ `is_active=false` 的语义是「该组所有成员**立即**退回未分组状态」——
这是一次批量权限变更,前端必须二次确认后再调。
"""
group = await _get_group_or_404(db, group_id)
if payload.name is not None and payload.name != group.name:
dup = await db.scalar(
select(BusinessGroup.id).where(
BusinessGroup.name == payload.name, BusinessGroup.id != group_id,
)
)
if dup:
raise HTTPException(status.HTTP_409_CONFLICT, f"分组名「{payload.name}」已存在")
group.name = payload.name
if payload.parent_id is not None:
if payload.parent_id == group_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "不能把自己设为自己的上级")
parent = await db.get(BusinessGroup, payload.parent_id)
if not parent:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "上级分组不存在")
if parent.parent_id is not None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "只支持两级:不能挂在子组下")
group.parent_id = payload.parent_id
if payload.description is not None:
group.description = payload.description
if payload.sort_order is not None:
group.sort_order = payload.sort_order
if payload.is_active is not None:
group.is_active = payload.is_active
# 可见范围:整组覆盖式更新
if payload.phases is not None:
valid = _valid_phases()
bad = [p for p in payload.phases if p not in valid]
if bad:
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"非法的阶段取值: {bad}")
await db.execute(
delete(BusinessGroupPhase).where(BusinessGroupPhase.group_id == group_id)
)
for p in payload.phases:
db.add(BusinessGroupPhase(group_id=group_id, phase=p))
await db.commit()
await db.refresh(group)
own = await _load_phases(db, [group.id] + ([group.parent_id] if group.parent_id else []))
counts = await _member_counts(db)
parent_name = None
if group.parent_id:
parent = await db.get(BusinessGroup, group.parent_id)
parent_name = parent.name if parent else None
return _to_out(
group,
await _effective_phases(db, group, own),
counts.get(group.id, 0),
parent_name,
own.get(group.id, []),
)
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_group(group_id: int, db: AsyncSession = Depends(get_db)):
"""删除分组。
⚠️ **仅允许删空组**。有成员时返回 409,要求先移除成员或改为停用。
级联删除是一次**静默的批量权限变更** —— 误点一下,一批人就突然看不到
数据了。强制多走一步,出错时是可见的。
"""
group = await _get_group_or_404(db, group_id)
member_count = await db.scalar(
select(func.count()).select_from(BusinessGroupMember)
.where(BusinessGroupMember.group_id == group_id)
) or 0
if member_count:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"该分组下还有 {member_count} 名成员。请先移除成员,或改为「停用」而不是删除。",
)
children = await db.scalar(
select(func.count()).select_from(BusinessGroup)
.where(BusinessGroup.parent_id == group_id)
) or 0
if children:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"该分组下还有 {children} 个子组,请先处理子组。",
)
await db.delete(group)
await db.commit()
# ============================================================
# 成员管理
# ============================================================
@router.post("/{group_id}/members", response_model=GroupMemberOut,
status_code=status.HTTP_201_CREATED)
async def add_member(
group_id: int, payload: GroupMemberAdd, db: AsyncSession = Depends(get_db),
):
"""把一个人加进分组。一个人可以在多个组 —— 多组 = 多看一个范围。"""
await _get_group_or_404(db, group_id)
exists = await db.scalar(
select(BusinessGroupMember.id).where(
BusinessGroupMember.group_id == group_id,
BusinessGroupMember.user_id == payload.user_id,
)
)
if exists:
raise HTTPException(status.HTTP_409_CONFLICT, "该成员已在此分组中")
member = BusinessGroupMember(
group_id=group_id, user_id=payload.user_id, is_leader=payload.is_leader,
)
db.add(member)
await db.commit()
from app.services.mom_cache import get_display_names
name_map = get_display_names([payload.user_id])
return GroupMemberOut(
user_id=payload.user_id,
display_name=name_map.get(payload.user_id) or payload.user_id,
is_leader=payload.is_leader,
)
@router.patch("/{group_id}/members/{user_id}", response_model=GroupMemberOut)
async def update_member(
group_id: int, user_id: str, payload: GroupMemberUpdate,
db: AsyncSession = Depends(get_db),
):
"""设置/取消组长。组长数据范围与组员相同,额外能管理本组成员。"""
member = (await db.execute(
select(BusinessGroupMember).where(
BusinessGroupMember.group_id == group_id,
BusinessGroupMember.user_id == user_id,
)
)).scalar_one_or_none()
if not member:
raise HTTPException(status.HTTP_404_NOT_FOUND, "该成员不在此分组中")
member.is_leader = payload.is_leader
await db.commit()
from app.services.mom_cache import get_display_names
name_map = get_display_names([user_id])
return GroupMemberOut(
user_id=user_id,
display_name=name_map.get(user_id) or user_id,
is_leader=payload.is_leader,
)
@router.delete("/{group_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_member(group_id: int, user_id: str, db: AsyncSession = Depends(get_db)):
"""把成员移出分组。若此人不再属于任何组,将退回「未分组」状态。"""
result = await db.execute(
delete(BusinessGroupMember).where(
BusinessGroupMember.group_id == group_id,
BusinessGroupMember.user_id == user_id,
)
)
if result.rowcount == 0:
raise HTTPException(status.HTTP_404_NOT_FOUND, "该成员不在此分组中")
await db.commit()

View File

@ -18,6 +18,7 @@ from app.api.v1.endpoints.webhooks import router as webhooks_router
from app.api.v1.endpoints.external_products import router as external_products_router
from app.api.v1.endpoints.screen import router as screen_router
from app.api.v1.endpoints.audit import router as audit_router
from app.api.v1.endpoints.groups import router as groups_router
api_router = APIRouter()
@ -39,3 +40,4 @@ api_router.include_router(webhooks_router)
api_router.include_router(external_products_router)
api_router.include_router(screen_router)
api_router.include_router(audit_router)
api_router.include_router(groups_router)

View File

@ -0,0 +1,65 @@
"""业务分组 Schemas"""
from pydantic import BaseModel, Field
class PhaseOption(BaseModel):
"""可选的生命周期阶段 —— 由服务端下发,前端不要写死这两个值"""
value: str
label: str
class GroupMemberOut(BaseModel):
user_id: str # MOM 短账号(与 Task.assignee_id 同口径)
display_name: str
is_leader: bool
class GroupOut(BaseModel):
id: int
name: str
parent_id: int | None = None
parent_name: str | None = None
description: str | None = None
sort_order: int = 0
is_active: bool = True
phases: list[str] = [] # 该组自己的可见范围(不含继承)
effective_phases: list[str] = [] # 实际生效范围(自己没配则取父组的)
phase_labels: list[str] = [] # 生效范围的中文标签
member_count: int = 0
class GroupDetailOut(GroupOut):
members: list[GroupMemberOut] = []
class GroupCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=50, description="分组显示名")
parent_id: int | None = Field(None, description="上级大组ID;为空则建的是大组")
description: str | None = Field(None, max_length=200)
sort_order: int = 0
phases: list[str] = Field(default_factory=list, description="可见的生命周期阶段")
class GroupUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=50)
parent_id: int | None = None
description: str | None = Field(None, max_length=200)
sort_order: int | None = None
is_active: bool | None = None
phases: list[str] | None = Field(
None, description="整组覆盖式更新;传 [] 表示清空(此时继承父组)",
)
class GroupMemberAdd(BaseModel):
user_id: str = Field(..., min_length=1, max_length=64, description="MOM 账号")
is_leader: bool = False
class GroupMemberUpdate(BaseModel):
is_leader: bool
class MemberCandidate(BaseModel):
username: str # 短账号,存进组的就是它
full_name: str # 真实姓名,仅供界面展示

View File

@ -45,6 +45,7 @@ const AdminAuditLogPage = lazy(() => import("./pages/admin/AdminAuditLogPage"));
const AnalyticsDashboard = lazy(() => import("./pages/admin/AnalyticsDashboard"));
const MatrixBoard = lazy(() => import("./pages/MatrixBoard"));
const ScreenDashboard = lazy(() => import("./pages/admin/ScreenDashboard"));
const AdminGroupsPage = lazy(() => import("./pages/admin/AdminGroupsPage"));
export default function App() {
return (
@ -80,6 +81,7 @@ export default function App() {
<Route path="/admin/print-config" element={<AdminPrintConfigPage />} />
<Route path="/admin/analytics" element={<AnalyticsDashboard />} />
<Route path="/admin/matrix" element={<MatrixBoard />} />
<Route path="/admin/groups" element={<AdminGroupsPage />} />
<Route path="/admin/audit" element={<AdminAuditLogPage />} />
</Route>
</Routes>

View File

@ -1,5 +1,5 @@
import { NavLink, Outlet, useLocation, useNavigate, Navigate } from "react-router-dom";
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3, Table2, Tv, ScrollText } from "lucide-react";
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3, Table2, Tv, ScrollText, ShieldCheck } from "lucide-react";
import { useAuth } from "../../contexts/AuthContext";
const MENU = [
@ -39,6 +39,12 @@ const MENU = [
icon: Table2,
description: "规格型号 × 人员/工序 在制品透视表",
},
{
title: "业务分组",
path: "/admin/groups",
icon: ShieldCheck,
description: "生产组 / 维修组 · 决定成员可见的数据范围",
},
{
title: "操作审计",
path: "/admin/audit",
@ -58,6 +64,26 @@ export default function AdminLayout() {
const navigate = useNavigate();
const { user, logout, isAuthenticated, loading } = useAuth();
// 数据范围徽标 —— 让用户看得见「我为什么只看到这些」。
// 空范围(未分组)必须显眼:列表全空却没有任何提示,用户会以为系统坏了,
// 这是最难排查的一类反馈。
const scope = user?.scope;
const scopeBadge = !scope
? null
: scope.is_empty
? {
text: "未分组 · 暂无数据权限",
cls: "bg-orange-50 text-orange-600",
tip: "你未被分入任何业务组,列表会是空的。请联系管理员把你分配到对应分组。",
}
: scope.is_unrestricted
? { text: "全厂", cls: "bg-gray-100 text-gray-500", tip: "可查看全部数据" }
: {
text: `${scope.groups.join(" / ") || "已分组"} · ${scope.phase_labels.join(" / ")}`,
cls: "bg-blue-50 text-blue-600",
tip: `可见数据范围:${scope.phase_labels.join("、")}`,
};
// 认证加载中
if (loading) {
return (
@ -138,8 +164,21 @@ export default function AdminLayout() {
</span>
</div>
{/* 用户信息 + 登出 */}
{/* 数据范围 + 用户信息 + 登出 */}
<div className="flex items-center gap-3">
{scopeBadge && (
<span
className={`rounded px-2 py-0.5 text-xs ${scopeBadge.cls}`}
title={scopeBadge.tip}
>
{scopeBadge.text}
</span>
)}
{scope?.is_leader && (
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-xs text-emerald-600" title="你是某个业务组的组长">
组长
</span>
)}
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<User className="h-3.5 w-3.5" />
<span className="font-medium text-gray-700">

View File

@ -243,6 +243,19 @@ export function isAdminRole(role?: string | null): boolean {
return !!role && (ADMIN_ROLES as readonly string[]).includes(role);
}
/**
* 超级管理员 —— 与后端 app/core/roles.py 的 SUPER_ADMIN 保持一致。
*
* ⚠️ 业务分组的**管理**入口只认超管,SUPERVISOR 不算。
* 原因:被分进业务组的 SUPERVISOR 会从「全厂」降级为只看本组,
* 若允许主管管理分组,他把自己移出组就能恢复全厂视野 —— 那是条提权路径。
* (注意这与 isAdminRole 用途不同:isAdminRole 管「能不能干活」,
* 这里管「能不能改分组」。两者别混用。)
*/
export function isSuperAdmin(role?: string | null): boolean {
return role === "SUPER_ADMIN";
}
export const ALL_OVERALL_OPTIONS = [
...PRODUCTION_OVERALL_OPTIONS, ...AFTER_SALES_ONLY_STEPS,
];

View File

@ -13,11 +13,28 @@ import { login as loginApi, getMe, logout as logoutApi } from "../services/authA
// 类型
// ============================================================
/**
* 业务分组数据范围 —— 来自 /auth/me。
*
* ⚠️ 登录接口(/auth/login)**不查库**、不返回 scope,所以登录后要先静默
* 调一次 getMe() 才拿得到;否则页头的范围徽标会空一下。
*/
export interface DataScopeInfo {
is_unrestricted: boolean; // 全厂(超管 / 未分组主管)
is_empty: boolean; // 未分组 —— 看不到任何数据
phases: string[];
phase_labels: string[]; // 中文标签,服务端下发
groups: string[];
is_leader: boolean;
reason: string;
}
export interface UserInfo {
id: string;
username: string;
display_name: string;
role: string;
scope?: DataScopeInfo;
}
interface AuthState {
@ -105,11 +122,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const result = await loginApi(username, password);
const accessToken = result.access_token;
const refreshToken = result.refresh_token;
const user: UserInfo = result.user;
let user: UserInfo = result.user;
// 存储双 Token
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
// 补拉一次 /auth/me 拿数据范围(scope)。
// ⚠️ 登录接口不查库、不返回 scope,直接用 result.user 的话页头范围徽标会
// 空一下,用户会以为分组没生效。多这一次请求换状态一致,值得。
// 失败不阻断登录:拿不到 scope 只是徽标不显示,不影响使用。
try {
user = { ...user, ...(await getMe(accessToken)) };
} catch {
/* 忽略:保持登录,只是暂时没有范围信息 */
}
localStorage.setItem(USER_KEY, JSON.stringify(user));
setState({ user, token: accessToken, loading: false });
}, []);

View File

@ -0,0 +1,474 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { Table, Button, Modal, Input, Checkbox, Tag, App, Spin, Empty } from "antd";
import { Plus, Pencil, Trash2, UserPlus, Crown, ShieldCheck } from "lucide-react";
import { useAuth } from "../../contexts/AuthContext";
import { isSuperAdmin } from "../../constants/task";
import { extractErrorMessage } from "../../utils/errorMessage";
import {
fetchGroups,
fetchGroupDetail,
fetchPhaseOptions,
createGroup,
updateGroup,
deleteGroup,
addGroupMember,
removeGroupMember,
setGroupLeader,
fetchMemberCandidates,
type BusinessGroup,
type GroupDetail,
type PhaseOption,
type MemberCandidate,
} from "../../services/groupApi";
/**
* 业务分组管理 —— 组决定成员能看到哪一段生命周期的数据。
*
* 页面自带体验层门禁(isSuperAdmin),但真正的拦截在后端路由依赖
* (仅 require_roles(SUPER_ADMIN))—— 这里只是别让普通用户看到空壳。
*
* ⚠️ 为什么只有超管能进:被分进组的 SUPERVISOR 会从「全厂」降级为只看本组,
* 若允许主管管理分组,他把自己移出组就能恢复全厂视野 —— 那是提权路径。
*/
export default function AdminGroupsPage() {
const { message, modal } = App.useApp();
const { user } = useAuth();
const [groups, setGroups] = useState<BusinessGroup[]>([]);
const [phaseOptions, setPhaseOptions] = useState<PhaseOption[]>([]);
const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [detail, setDetail] = useState<GroupDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
// 编辑弹窗
const [editOpen, setEditOpen] = useState(false);
const [editing, setEditing] = useState<BusinessGroup | null>(null); // null = 新建
const [form, setForm] = useState({ name: "", parent_id: null as number | null, description: "", phases: [] as string[] });
const [saving, setSaving] = useState(false);
// 加成员
const [candidates, setCandidates] = useState<MemberCandidate[]>([]);
const [picked, setPicked] = useState<string | undefined>(undefined);
const [pickedLeader, setPickedLeader] = useState(false);
const [adding, setAdding] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const [gs, opts] = await Promise.all([fetchGroups(), fetchPhaseOptions()]);
setGroups(gs);
setPhaseOptions(opts);
setSelectedId((prev) => prev ?? gs.find((g) => g.parent_id === null)?.id ?? gs[0]?.id ?? null);
} catch (err) {
message.error(extractErrorMessage(err, "加载分组失败"));
} finally {
setLoading(false);
}
}, [message]);
useEffect(() => { load(); }, [load]);
const loadDetail = useCallback(async (id: number) => {
setDetailLoading(true);
try {
setDetail(await fetchGroupDetail(id));
} catch (err) {
message.error(extractErrorMessage(err, "加载分组详情失败"));
setDetail(null);
} finally {
setDetailLoading(false);
}
}, [message]);
useEffect(() => { if (selectedId != null) loadDetail(selectedId); }, [selectedId, loadDetail]);
// 大组列表(下拉用)
const topGroups = useMemo(() => groups.filter((g) => g.parent_id === null), [groups]);
// ============================================================
// 编辑
// ============================================================
function openCreate(parentId: number | null = null) {
setEditing(null);
setForm({ name: "", parent_id: parentId, description: "", phases: [] });
setEditOpen(true);
}
function openEdit(g: BusinessGroup) {
setEditing(g);
setForm({
name: g.name,
parent_id: g.parent_id,
description: g.description ?? "",
phases: g.phases,
});
setEditOpen(true);
}
async function handleSave() {
if (!form.name.trim()) { message.warning("请填写分组名称"); return; }
setSaving(true);
try {
if (editing) {
await updateGroup(editing.id, {
name: form.name.trim(),
description: form.description.trim() || null,
phases: form.phases,
});
message.success("已保存");
} else {
const created = await createGroup({
name: form.name.trim(),
parent_id: form.parent_id,
description: form.description.trim() || null,
phases: form.phases,
});
setSelectedId(created.id);
message.success("已创建");
}
setEditOpen(false);
await load();
if (selectedId != null) await loadDetail(selectedId);
} catch (err) {
message.error(extractErrorMessage(err, "保存失败"));
} finally {
setSaving(false);
}
}
async function handleToggleActive(g: BusinessGroup) {
const next = !g.is_active;
// ⚠️ 停用 = 该组所有人立即退回未分组状态,是一次批量权限变更,必须二次确认
const tip = next
? `启用「${g.name}」后,其 ${g.member_count} 名成员将按该组的范围查看数据。`
: `停用「${g.name}」后,其 ${g.member_count} 名成员将【立即】失去该组带来的数据范围(等同于未分组)。确认停用?`;
modal.confirm({
title: next ? "确认启用分组" : "确认停用分组",
content: tip,
okText: next ? "启用" : "停用",
okButtonProps: { danger: !next },
cancelText: "取消",
onOk: async () => {
try {
await updateGroup(g.id, { is_active: next });
message.success(next ? "已启用" : "已停用");
await load();
if (selectedId === g.id) await loadDetail(g.id);
} catch (err) {
message.error(extractErrorMessage(err, "操作失败"));
}
},
});
}
function handleDelete(g: BusinessGroup) {
modal.confirm({
title: `删除分组「${g.name}」?`,
content: "仅空分组可删除。若组内还有成员,请先移除成员,或改为「停用」。",
okText: "删除", okButtonProps: { danger: true }, cancelText: "取消",
onOk: async () => {
try {
await deleteGroup(g.id);
message.success("已删除");
if (selectedId === g.id) setSelectedId(null);
await load();
} catch (err) {
message.error(extractErrorMessage(err, "删除失败"));
}
},
});
}
// ============================================================
// 成员
// ============================================================
async function openAddMember() {
if (selectedId == null) return;
try {
setCandidates(await fetchMemberCandidates());
setPicked(undefined);
setPickedLeader(false);
} catch (err) {
message.error(extractErrorMessage(err, "加载人员失败"));
}
}
async function handleAddMember() {
if (selectedId == null || !picked) { message.warning("请选择人员"); return; }
setAdding(true);
try {
await addGroupMember(selectedId, picked, pickedLeader);
message.success("已添加");
setPicked(undefined);
setPickedLeader(false);
setCandidates([]);
await Promise.all([loadDetail(selectedId), load()]);
} catch (err) {
message.error(extractErrorMessage(err, "添加失败"));
} finally {
setAdding(false);
}
}
async function handleRemoveMember(userId: string) {
if (selectedId == null) return;
try {
await removeGroupMember(selectedId, userId);
message.success("已移除");
await Promise.all([loadDetail(selectedId), load()]);
} catch (err) {
message.error(extractErrorMessage(err, "移除失败"));
}
}
async function handleToggleLeader(userId: string, next: boolean) {
if (selectedId == null) return;
try {
await setGroupLeader(selectedId, userId, next);
message.success(next ? "已设为组长" : "已取消组长");
await loadDetail(selectedId);
} catch (err) {
message.error(extractErrorMessage(err, "操作失败"));
}
}
// ============================================================
// 渲染
// ============================================================
if (!isSuperAdmin(user?.role)) {
return (
<div className="flex flex-col items-center justify-center rounded-xl bg-white py-20 shadow-sm">
<ShieldCheck className="mb-3 h-10 w-10 text-gray-300" />
<p className="text-sm text-gray-500">仅超级管理员可管理业务分组</p>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Spin />
</div>
);
}
const groupColumns = [
{
title: "分组",
dataIndex: "name",
key: "name",
render: (v: string, g: BusinessGroup) => (
<div className="flex items-center gap-1.5">
{g.parent_id !== null && <span className="text-gray-300">└</span>}
<span className="font-medium text-gray-800">{v}</span>
{g.parent_id === null && <Tag color="blue">大组</Tag>}
{!g.is_active && <Tag>已停用</Tag>}
</div>
),
},
{
title: "可见范围",
key: "scope",
render: (_: unknown, g: BusinessGroup) =>
g.phase_labels.length
? g.phase_labels.map((l) => <Tag key={l} color="cyan">{l}</Tag>)
: <span className="text-xs text-orange-500">未配置(看不到数据)</span>,
},
{ title: "成员", dataIndex: "member_count", key: "member_count", width: 60 },
];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-gray-800">业务分组</h2>
<p className="mt-1 text-sm text-gray-500">
组决定成员能看到哪一段生命周期的数据。可在组内配置可见范围、分配成员。
</p>
</div>
<Button type="primary" icon={<Plus size={16} />} onClick={() => openCreate(null)}>
新建大组
</Button>
</div>
<div className="rounded-xl bg-white p-5 shadow-sm">
<Table
rowKey="id"
columns={groupColumns}
dataSource={groups}
size="small"
pagination={false}
locale={{ emptyText: <Empty description="还没有分组" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
rowClassName={(g) => (g.id === selectedId ? "bg-blue-50 cursor-pointer" : "cursor-pointer")}
onRow={(g) => ({ onClick: () => setSelectedId(g.id) })}
/>
<p className="mt-3 text-xs text-gray-400">
点击一行查看详情与成员。小组未单独配置范围时,自动继承所属大组。
</p>
</div>
{detail && (
<div className="rounded-xl bg-white p-5 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="text-lg font-semibold text-gray-800">{detail.name}</h3>
{detail.parent_name && <Tag>属于 {detail.parent_name}</Tag>}
{!detail.is_active && <Tag color="default">已停用</Tag>}
{detail.parent_id === null && (
<Button size="small" icon={<Plus size={14} />} onClick={() => openCreate(detail.id)}>
新建小组
</Button>
)}
</div>
<div className="flex gap-2">
<Button size="small" icon={<Pencil size={14} />} onClick={() => openEdit(detail)}>编辑</Button>
<Button size="small" onClick={() => handleToggleActive(detail)}>
{detail.is_active ? "停用" : "启用"}
</Button>
<Button size="small" danger icon={<Trash2 size={14} />} onClick={() => handleDelete(detail)}>删除</Button>
</div>
</div>
<div className="mb-5">
<div className="mb-1 text-sm font-medium text-gray-700">可见范围</div>
<div className="flex flex-wrap items-center gap-2">
{detail.phase_labels.length
? detail.phase_labels.map((l) => <Tag key={l} color="cyan" className="text-sm">{l}</Tag>)
: <span className="text-sm text-orange-500">未配置 —— 该组成员看不到任何数据</span>}
{detail.phases.length === 0 && detail.parent_id !== null && (
<span className="text-xs text-gray-400">(继承自 {detail.parent_name})</span>
)}
</div>
</div>
<div className="mb-2 flex items-center justify-between">
<div className="text-sm font-medium text-gray-700">
成员 <span className="text-gray-400">({detail.members.length})</span>
</div>
<Button size="small" icon={<UserPlus size={14} />} onClick={openAddMember}>
添加成员
</Button>
</div>
{detailLoading ? (
<div className="flex justify-center py-6"><Spin /></div>
) : detail.members.length === 0 ? (
<Empty description="该分组还没有成员" image={Empty.PRESENTED_IMAGE_SIMPLE} className="py-6" />
) : (
<Table
rowKey="user_id"
size="small"
pagination={false}
dataSource={detail.members}
columns={[
{ title: "姓名", dataIndex: "display_name", key: "display_name" },
{ title: "账号", dataIndex: "user_id", key: "user_id", render: (v: string) => <span className="font-mono text-xs text-gray-500">{v}</span> },
{
title: "组长", dataIndex: "is_leader", key: "is_leader", width: 90,
render: (v: boolean, m) => (
<button
className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs ${v ? "bg-amber-50 text-amber-600" : "text-gray-400 hover:text-amber-500"}`}
onClick={(e) => { e.stopPropagation(); handleToggleLeader(m.user_id, !v); }}
>
<Crown size={12} />{v ? "组长" : "设为组长"}
</button>
),
},
{
title: "操作", key: "action", width: 70,
render: (_: unknown, m) => (
<Button type="link" size="small" danger onClick={(e) => { e.stopPropagation(); handleRemoveMember(m.user_id); }}>
移除
</Button>
),
},
]}
/>
)}
{/* 添加成员面板 */}
{candidates.length > 0 && (
<div className="mt-3 flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 p-3">
<select
className="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-blue-400 focus:outline-none"
value={picked ?? ""}
onChange={(e) => setPicked(e.target.value || undefined)}
>
<option value="">选择人员…</option>
{candidates.map((c) => (
<option key={c.username} value={c.username}>{c.full_name}({c.username})</option>
))}
</select>
<Checkbox checked={pickedLeader} onChange={(e) => setPickedLeader(e.target.checked)}>设为组长</Checkbox>
<Button type="primary" size="small" loading={adding} onClick={handleAddMember}>添加</Button>
<Button size="small" onClick={() => setCandidates([])}>取消</Button>
</div>
)}
</div>
)}
{/* 新建 / 编辑弹窗 */}
<Modal
title={editing ? `编辑分组 · ${editing.name}` : (form.parent_id ? "新建小组" : "新建大组")}
open={editOpen}
onCancel={() => setEditOpen(false)}
onOk={handleSave}
confirmLoading={saving}
okText="保存"
cancelText="取消"
width={520}
>
<div className="space-y-4 py-2">
<div>
<label className="mb-1 block text-sm font-medium text-gray-600">分组名称</label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="如 生产大组 / 维修大组 / 生产小组"
maxLength={50}
/>
</div>
{form.parent_id !== null && (
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-500">
上级:{topGroups.find((g) => g.id === form.parent_id)?.name ?? form.parent_id}
</div>
)}
<div>
<label className="mb-1 block text-sm font-medium text-gray-600">
可见范围
<span className="ml-2 text-xs font-normal text-gray-400">
{form.parent_id !== null ? "留空则继承上级大组" : "决定成员能看到哪些数据"}
</span>
</label>
<div className="space-y-1.5 rounded-lg border border-gray-200 p-3">
{phaseOptions.map((opt) => (
<Checkbox
key={opt.value}
checked={form.phases.includes(opt.value)}
onChange={(e) => setForm({
...form,
phases: e.target.checked
? [...form.phases, opt.value]
: form.phases.filter((p) => p !== opt.value),
})}
>
{opt.label}
</Checkbox>
))}
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-600">备注(选填)</label>
<Input
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="这个组负责什么"
maxLength={200}
/>
</div>
</div>
</Modal>
</div>
);
}

View File

@ -0,0 +1,124 @@
import api from "./api";
// ============================================================
// 类型
// ============================================================
/** 可选的生命周期阶段 —— 由服务端下发,前端不要写死 */
export interface PhaseOption {
value: string;
label: string;
}
/** 业务分组(大组 parent_id 为空) */
export interface BusinessGroup {
id: number; // 数字 ID(显示名可改,ID 不变)
name: string;
parent_id: number | null;
parent_name: string | null;
description: string | null;
sort_order: number;
is_active: boolean;
phases: string[]; // 该组自己配置的范围(不含继承)
effective_phases: string[]; // 实际生效范围(自己没配则取父组的)
phase_labels: string[]; // 生效范围的中文标签
member_count: number;
}
export interface GroupMember {
user_id: string; // MOM 短账号
display_name: string;
is_leader: boolean;
}
export interface GroupDetail extends BusinessGroup {
members: GroupMember[];
}
/** 候选人:从 MOM 拉,部门已由服务端钉死 */
export interface MemberCandidate {
username: string;
full_name: string;
}
export interface GroupPayload {
name: string;
parent_id?: number | null;
description?: string | null;
sort_order?: number;
phases?: string[];
}
// ============================================================
// API
// ============================================================
/** 可选的阶段选项(渲染勾选框用,别在前端枚举这两个值) */
export async function fetchPhaseOptions(): Promise<PhaseOption[]> {
const { data } = await api.get<PhaseOption[]>("/groups/phase-options");
return data;
}
export async function fetchGroups(): Promise<BusinessGroup[]> {
const { data } = await api.get<BusinessGroup[]>("/groups");
return data;
}
export async function fetchGroupDetail(groupId: number): Promise<GroupDetail> {
const { data } = await api.get<GroupDetail>(`/groups/${groupId}`);
return data;
}
export async function createGroup(payload: GroupPayload): Promise<BusinessGroup> {
const { data } = await api.post<BusinessGroup>("/groups", payload);
return data;
}
export async function updateGroup(
groupId: number,
payload: Partial<GroupPayload> & { is_active?: boolean }
): Promise<BusinessGroup> {
const { data } = await api.patch<BusinessGroup>(`/groups/${groupId}`, payload);
return data;
}
/** 删除分组 —— 仅空组可删,有成员时后端返回 409 */
export async function deleteGroup(groupId: number): Promise<void> {
await api.delete(`/groups/${groupId}`);
}
export async function addGroupMember(
groupId: number,
userId: string,
isLeader = false
): Promise<GroupMember> {
const { data } = await api.post<GroupMember>(`/groups/${groupId}/members`, {
user_id: userId,
is_leader: isLeader,
});
return data;
}
export async function removeGroupMember(groupId: number, userId: string): Promise<void> {
await api.delete(`/groups/${groupId}/members/${encodeURIComponent(userId)}`);
}
export async function setGroupLeader(
groupId: number,
userId: string,
isLeader: boolean
): Promise<GroupMember> {
const { data } = await api.patch<GroupMember>(
`/groups/${groupId}/members/${encodeURIComponent(userId)}`,
{ is_leader: isLeader }
);
return data;
}
/** 候选人下拉 —— 数据源是 MOM 的 LICA 部门人员 */
export async function fetchMemberCandidates(keyword?: string): Promise<MemberCandidate[]> {
const { data } = await api.get<MemberCandidate[]>("/groups/member-candidates", {
params: keyword ? { keyword } : {},
});
return data;
}