feat(权限): 业务分组对全员开放(操作分层)+ 操作审计仅超管可见

需求:业务分组开放给所有人,主管可操作、其余人只读;操作审计前端不显示。

⚠️ 这里有个必须收窄的边界:按数据范围规则,被分进组的 SUPERVISOR 会从
「全厂」降级为只看本组。若允许他改可见范围,他把自己那组改成「生产+售后」
就恢复全厂视野;若允许建组,他新建一个组再把自己塞进去,同样绕过。
**「能管成员」与「能配范围」必须分开** —— 前者安全(组长给自己加组会被
唯一约束挡住、移出自己只是失去权限),后者是提权入口。

因此分层如下:

  | 谁               | 看           | 改                                 |
  |------------------|--------------|------------------------------------|
  | SUPER_ADMIN      | 所有组       | 全部(建/改/删组、配范围、管成员) |
  | 主管 / 本组组长  | 自己所属的组 | **仅本组成员**(加人/移人/设组长) |
  | 普通成员         | 自己所属的组 | 无(只读)                         |

后端(groups.py):
· 去掉路由级的 require_roles(SUPER_ADMIN),改为逐端点校验
· list_groups / get_group:非超管只返回自己所属的组;看别组详情 403
  (连「这个组存在但你没份」都不暴露,避免被拿来推测组织架构)
· create/update/delete:_require_super —— 这三个 + 配范围是提权入口
· add/update/remove member:_can_manage_members(超管 / 主管 / 本组组长)
· GroupOut 新增 can_manage_members / can_manage_group / is_my_leader
  —— 由服务端算好下发,前端不按 role 自行推导(组长身份是按组算的)

前端:
· AdminGroupsPage 去掉整页超管门禁,改为按能力显示按钮:
  无权限时「组长」列退化成纯展示而非可点按钮
  没有任何组时给「你还没有被分配到任何业务分组」的引导提示
· AdminLayout 的 MENU 支持 superOnly 标记,「操作审计」只对超管显示
  (后端 /audit/* 本就挂了 require_admin,这里只是不给入口,
   否则普通用户点进去只会看到一堆 403)

实测(四类账号):
  建组/改范围:仅超管通过,其余全 403
  管成员:超管/主管/本组组长通过;非本组 403;跨组组长 403
  可见性:主管未分组看到 0 个组、维修组长只看到维修大组、生产组员只看到生产小组
  详情页能力标记正确下发;越权查别组详情 403
This commit is contained in:
2026-09-21 17:38:07 +08:00
parent 44116d99c5
commit 15097baa2b
5 changed files with 286 additions and 82 deletions

View File

@ -1,13 +1,20 @@
"""业务分组管理 API —— **仅超级管理员**可访问 """业务分组 API —— 全员可见,操作按角色分层
⚠️ 为什么只有 SUPER_ADMIN 能管分组SUPERVISOR 不行 权限模型2026-09 调整:从「仅超管」放开为分层,但**操作边界必须收窄**
按数据范围规则,被显式分进组的 SUPERVISOR 会从「全厂」**降级**为只看本组。 | 谁 | 看 | 改 |
如果允许 SUPERVISOR 管理分组,那么他被分组之后,只要把自己从组里移出去 |---------------------|------------------------|--------------------------------------|
就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他完全无效。 | SUPER_ADMIN | 所有组 | 全部(建组/改组/删组/配范围/管成员) |
| 主管(SUPERVISOR) | 自己所属的组 | **仅本组成员**(加人/移人/设组长) |
| 组长(is_leader) | 自己所属的组 | **仅本组成员** |
| 普通成员 | 自己所属的组 | 无(只读) |
所以这里用 require_roles(SUPER_ADMIN)**不能**用 require_admin ⚠️ **为什么「主管」不能改可见范围、不能建删组**
(后者含 SUPERVISOR 按数据范围规则,被分进组的 SUPERVISOR 会从「全厂」降级为只看本组
若允许他改范围,他只要把自己那组改成「生产 + 售后」就恢复全厂视野;
若能建组,他新建一个组再把自己塞进去,同样绕过。**能给自己加组 = 提权**。
所以「管成员」与「配范围」必须分开:前者安全(组长给自己加组会被唯一约束
挡住,移出自己只是失去权限),后者是提权入口。
写操作会被 audit_middleware 自动采集 —— 分组变更是高权限动作,追责必须有据。 写操作会被 audit_middleware 自动采集 —— 分组变更是高权限动作,追责必须有据。
""" """
@ -19,10 +26,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.core.deps import require_roles
from app.core.lifecycle import PHASE_LABELS from app.core.lifecycle import PHASE_LABELS
from app.core.mom_database import MomSessionLocal from app.core.mom_database import MomSessionLocal
from app.core.roles import SUPER_ADMIN from app.core.roles import ADMIN_ROLES, SUPER_ADMIN
from app.services.auth_service import get_current_user
from app.models.business_group import ( from app.models.business_group import (
BusinessGroup, BusinessGroup,
BusinessGroupMember, BusinessGroupMember,
@ -40,11 +47,71 @@ from app.schemas.group import (
PhaseOption, PhaseOption,
) )
router = APIRouter( router = APIRouter(prefix="/groups", tags=["业务分组"])
prefix="/groups",
tags=["业务分组"],
dependencies=[Depends(require_roles(SUPER_ADMIN))], # ============================================================
) # 权限判定
# ============================================================
def _is_super(user: dict) -> bool:
return (user or {}).get("role") == SUPER_ADMIN
async def _my_group_ids(db: AsyncSession, user: dict) -> set[int]:
"""当前用户所属(且启用中)的组 id"""
username = (user or {}).get("username") or ""
if not username:
return set()
rows = await db.execute(
select(BusinessGroupMember.group_id)
.join(BusinessGroup, BusinessGroup.id == BusinessGroupMember.group_id)
.where(
BusinessGroupMember.user_id == username,
BusinessGroup.is_active.is_(True),
)
)
return {r[0] for r in rows.all()}
async def _leader_group_ids(db: AsyncSession, user: dict) -> set[int]:
"""当前用户当组长的组 id"""
username = (user or {}).get("username") or ""
if not username:
return set()
rows = await db.execute(
select(BusinessGroupMember.group_id)
.join(BusinessGroup, BusinessGroup.id == BusinessGroupMember.group_id)
.where(
BusinessGroupMember.user_id == username,
BusinessGroupMember.is_leader.is_(True),
BusinessGroup.is_active.is_(True),
)
)
return {r[0] for r in rows.all()}
async def _can_manage_members(db: AsyncSession, user: dict, group_id: int) -> bool:
"""能否管理**这个组**的成员。
超管、部门主管(SUPERVISOR)、本组组长 —— 三者之一即可。
⚠️ 组长只能管自己当组长的组;主管虽然能管所有组的成员,但**改不了范围**
(范围相关端点单独限超管),所以不构成提权。
"""
if _is_super(user):
return True
if (user or {}).get("role") in ADMIN_ROLES:
return True
return group_id in await _leader_group_ids(db, user)
def _require_super(user: dict) -> None:
"""建组 / 改组 / 删组 / 配范围 —— 这些是提权入口,只给超管"""
if not _is_super(user):
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"只有超级管理员可以新建、修改或删除分组,以及调整分组的可见范围",
)
# ============================================================ # ============================================================
@ -94,7 +161,14 @@ async def _member_counts(db: AsyncSession) -> dict[int, int]:
def _to_out(group: BusinessGroup, phases: list[str], count: int, def _to_out(group: BusinessGroup, phases: list[str], count: int,
parent_name: str | None, own_phases: list[str]) -> GroupOut: parent_name: str | None, own_phases: list[str],
*, can_manage_members: bool = False, can_manage_group: bool = False,
is_my_leader: bool = False) -> GroupOut:
"""把 ORM 对象转成响应。
能力标记(能否管成员 / 能否改组)由服务端算好下发,前端不要自己按 role 推导 ——
组长身份是按组算的,前端凭 role 推不出来。
"""
return GroupOut( return GroupOut(
id=group.id, id=group.id,
name=group.name, name=group.name,
@ -107,6 +181,9 @@ def _to_out(group: BusinessGroup, phases: list[str], count: int,
effective_phases=phases, effective_phases=phases,
phase_labels=[PHASE_LABELS.get(p, p) for p in phases], phase_labels=[PHASE_LABELS.get(p, p) for p in phases],
member_count=count, member_count=count,
can_manage_members=can_manage_members,
can_manage_group=can_manage_group,
is_my_leader=is_my_leader,
) )
@ -123,7 +200,7 @@ async def _get_group_or_404(db: AsyncSession, group_id: int) -> BusinessGroup:
# ============================================================ # ============================================================
@router.get("/phase-options", response_model=list[PhaseOption]) @router.get("/phase-options", response_model=list[PhaseOption])
async def list_phase_options(): async def list_phase_options(current_user: dict = Depends(get_current_user)):
"""可选的生命周期阶段 —— 供前端渲染勾选框,避免前端写死这两个值""" """可选的生命周期阶段 —— 供前端渲染勾选框,避免前端写死这两个值"""
return [PhaseOption(value=v, label=l) for v, l in _valid_phases().items()] return [PhaseOption(value=v, label=l) for v, l in _valid_phases().items()]
@ -132,6 +209,7 @@ async def list_phase_options():
def list_member_candidates( def list_member_candidates(
keyword: str = Query("", description="按姓名/账号模糊搜索"), keyword: str = Query("", description="按姓名/账号模糊搜索"),
limit: int = Query(500, ge=1, le=1000), limit: int = Query(500, ge=1, le=1000),
current_user: dict = Depends(get_current_user),
): ):
"""候选人下拉 —— 复用与 users.py 一致的 MOM 查询口径(部门已钉死为 ORG_DEPARTMENT """候选人下拉 —— 复用与 users.py 一致的 MOM 查询口径(部门已钉死为 ORG_DEPARTMENT
@ -176,16 +254,34 @@ def list_member_candidates(
# ============================================================ # ============================================================
@router.get("", response_model=list[GroupOut]) @router.get("", response_model=list[GroupOut])
async def list_groups(db: AsyncSession = Depends(get_db)): async def list_groups(
"""列出全部业务分组(含停用的),带成员数与生效范围""" db: AsyncSession = Depends(get_db),
groups = (await db.execute( current_user: dict = Depends(get_current_user),
select(BusinessGroup).order_by(BusinessGroup.sort_order, BusinessGroup.id) ):
)).scalars().all() """列出业务分组。
- 超管:全部(含停用的)
- 其他人:**只返回自己所属的组** —— 普通成员不该看到别人的组有哪些人。
"这个组存在但你没份"都不暴露,避免被拿来推测组织架构。
"""
stmt = select(BusinessGroup).order_by(BusinessGroup.sort_order, BusinessGroup.id)
if not _is_super(current_user):
mine = await _my_group_ids(db, current_user)
if not mine:
return []
stmt = stmt.where(BusinessGroup.id.in_(tuple(mine)))
groups = (await db.execute(stmt)).scalars().all()
own = await _load_phases(db, [g.id for g in groups]) own = await _load_phases(db, [g.id for g in groups])
counts = await _member_counts(db) counts = await _member_counts(db)
names = {g.id: g.name for g in groups} names = {g.id: g.name for g in groups}
# 能力标记一次算好,避免在循环里反复查库
is_super = _is_super(current_user)
is_admin_role = (current_user or {}).get("role") in ADMIN_ROLES
leader_ids = set() if is_super else await _leader_group_ids(db, current_user)
return [ return [
_to_out( _to_out(
g, g,
@ -193,15 +289,28 @@ async def list_groups(db: AsyncSession = Depends(get_db)):
counts.get(g.id, 0), counts.get(g.id, 0),
names.get(g.parent_id) if g.parent_id else None, names.get(g.parent_id) if g.parent_id else None,
own.get(g.id, []), own.get(g.id, []),
can_manage_members=is_super or is_admin_role or g.id in leader_ids,
can_manage_group=is_super,
is_my_leader=g.id in leader_ids,
) )
for g in groups for g in groups
] ]
@router.get("/{group_id}", response_model=GroupDetailOut) @router.get("/{group_id}", response_model=GroupDetailOut)
async def get_group(group_id: int, db: AsyncSession = Depends(get_db)): async def get_group(
"""分组详情 + 成员列表""" group_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""分组详情 + 成员列表(非超管只能看自己所属的组)"""
group = await _get_group_or_404(db, group_id) group = await _get_group_or_404(db, group_id)
if not _is_super(current_user):
mine = await _my_group_ids(db, current_user)
if group_id not in mine:
raise HTTPException(
status.HTTP_403_FORBIDDEN, "你不在该分组中,无法查看"
)
own = await _load_phases(db, [group.id] + ([group.parent_id] if group.parent_id else [])) own = await _load_phases(db, [group.id] + ([group.parent_id] if group.parent_id else []))
members = (await db.execute( members = (await db.execute(
@ -219,12 +328,19 @@ async def get_group(group_id: int, db: AsyncSession = Depends(get_db)):
parent = await db.get(BusinessGroup, group.parent_id) parent = await db.get(BusinessGroup, group.parent_id)
parent_name = parent.name if parent else None parent_name = parent.name if parent else None
is_super = _is_super(current_user)
is_admin_role = (current_user or {}).get("role") in ADMIN_ROLES
leader_ids = set() if is_super else await _leader_group_ids(db, current_user)
base = _to_out( base = _to_out(
group, group,
await _effective_phases(db, group, own), await _effective_phases(db, group, own),
len(members), len(members),
parent_name, parent_name,
own.get(group.id, []), own.get(group.id, []),
can_manage_members=is_super or is_admin_role or group.id in leader_ids,
can_manage_group=is_super,
is_my_leader=group.id in leader_ids,
) )
return GroupDetailOut( return GroupDetailOut(
**base.model_dump(), **base.model_dump(),
@ -240,8 +356,16 @@ async def get_group(group_id: int, db: AsyncSession = Depends(get_db)):
@router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED) @router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
async def create_group(payload: GroupCreate, db: AsyncSession = Depends(get_db)): async def create_group(
"""新建分组。parent_id 为空即建大组,否则是挂在某个大组下的小组。""" payload: GroupCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""新建分组。parent_id 为空即建大组,否则是挂在某个大组下的小组。
⚠️ 仅超管 —— 「能建组」意味着「能新建一个组再把自己塞进去」,是提权入口。
"""
_require_super(current_user)
valid = _valid_phases() valid = _valid_phases()
bad = [p for p in payload.phases if p not in valid] bad = [p for p in payload.phases if p not in valid]
if bad: if bad:
@ -291,13 +415,21 @@ async def create_group(payload: GroupCreate, db: AsyncSession = Depends(get_db))
@router.patch("/{group_id}", response_model=GroupOut) @router.patch("/{group_id}", response_model=GroupOut)
async def update_group( async def update_group(
group_id: int, payload: GroupUpdate, db: AsyncSession = Depends(get_db), group_id: int,
payload: GroupUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
): ):
"""修改分组。 """修改分组(含可见范围)
⚠️ **仅超管** —— 这是最关键的一条:`phases` 决定能看到哪些数据,谁能改它
谁就能给自己扩权(比如把本组改成「生产 + 售后」= 全厂)。
主管能管成员但不能碰范围,就是这个原因。
⚠️ `is_active=false` 的语义是「该组所有成员**立即**退回未分组状态」—— ⚠️ `is_active=false` 的语义是「该组所有成员**立即**退回未分组状态」——
这是一次批量权限变更,前端必须二次确认后再调。 这是一次批量权限变更,前端必须二次确认后再调。
""" """
_require_super(current_user)
group = await _get_group_or_404(db, group_id) group = await _get_group_or_404(db, group_id)
if payload.name is not None and payload.name != group.name: if payload.name is not None and payload.name != group.name:
@ -358,13 +490,18 @@ async def update_group(
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_group(group_id: int, db: AsyncSession = Depends(get_db)): async def delete_group(
"""删除分组。 group_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""删除分组(仅超管)。
⚠️ **仅允许删空组**。有成员时返回 409要求先移除成员或改为停用。 ⚠️ **仅允许删空组**。有成员时返回 409要求先移除成员或改为停用。
级联删除是一次**静默的批量权限变更** —— 误点一下,一批人就突然看不到 级联删除是一次**静默的批量权限变更** —— 误点一下,一批人就突然看不到
数据了。强制多走一步,出错时是可见的。 数据了。强制多走一步,出错时是可见的。
""" """
_require_super(current_user)
group = await _get_group_or_404(db, group_id) group = await _get_group_or_404(db, group_id)
member_count = await db.scalar( member_count = await db.scalar(
@ -398,10 +535,21 @@ async def delete_group(group_id: int, db: AsyncSession = Depends(get_db)):
@router.post("/{group_id}/members", response_model=GroupMemberOut, @router.post("/{group_id}/members", response_model=GroupMemberOut,
status_code=status.HTTP_201_CREATED) status_code=status.HTTP_201_CREATED)
async def add_member( async def add_member(
group_id: int, payload: GroupMemberAdd, db: AsyncSession = Depends(get_db), group_id: int,
payload: GroupMemberAdd,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
): ):
"""把一个人加进分组。一个人可以在多个组 —— 多组 = 多看一个范围。""" """把一个人加进分组。一个人可以在多个组 —— 多组 = 多看一个范围。
权限:超管 / 部门主管 / **本组组长**。组长给自己加组会被唯一约束挡住
(他本来就在组里),所以这里不构成提权。
"""
await _get_group_or_404(db, group_id) await _get_group_or_404(db, group_id)
if not await _can_manage_members(db, current_user, group_id):
raise HTTPException(
status.HTTP_403_FORBIDDEN, "只有超级管理员、主管或本组组长可以添加成员"
)
exists = await db.scalar( exists = await db.scalar(
select(BusinessGroupMember.id).where( select(BusinessGroupMember.id).where(
@ -431,8 +579,13 @@ async def add_member(
async def update_member( async def update_member(
group_id: int, user_id: str, payload: GroupMemberUpdate, group_id: int, user_id: str, payload: GroupMemberUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
): ):
"""设置/取消组长。组长数据范围与组员相同,额外能管理本组成员。""" """设置/取消组长。组长数据范围与组员相同,额外能管理本组成员。"""
if not await _can_manage_members(db, current_user, group_id):
raise HTTPException(
status.HTTP_403_FORBIDDEN, "只有超级管理员、主管或本组组长可以调整组长"
)
member = (await db.execute( member = (await db.execute(
select(BusinessGroupMember).where( select(BusinessGroupMember).where(
BusinessGroupMember.group_id == group_id, BusinessGroupMember.group_id == group_id,
@ -455,8 +608,16 @@ async def update_member(
@router.delete("/{group_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) @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)): async def remove_member(
group_id: int, user_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""把成员移出分组。若此人不再属于任何组,将退回「未分组」状态。""" """把成员移出分组。若此人不再属于任何组,将退回「未分组」状态。"""
if not await _can_manage_members(db, current_user, group_id):
raise HTTPException(
status.HTTP_403_FORBIDDEN, "只有超级管理员、主管或本组组长可以移除成员"
)
result = await db.execute( result = await db.execute(
delete(BusinessGroupMember).where( delete(BusinessGroupMember).where(
BusinessGroupMember.group_id == group_id, BusinessGroupMember.group_id == group_id,

View File

@ -26,6 +26,10 @@ class GroupOut(BaseModel):
effective_phases: list[str] = [] # 实际生效范围(自己没配则取父组的) effective_phases: list[str] = [] # 实际生效范围(自己没配则取父组的)
phase_labels: list[str] = [] # 生效范围的中文标签 phase_labels: list[str] = [] # 生效范围的中文标签
member_count: int = 0 member_count: int = 0
# ---- 当前用户对这个组能做什么(服务端算好,前端别自己推导)----
can_manage_members: bool = False # 能加/移成员、设组长
can_manage_group: bool = False # 能改组名/范围/启停/删除 —— 仅超管
is_my_leader: bool = False # 我是这个组的组长
class GroupDetailOut(GroupOut): class GroupDetailOut(GroupOut):

View File

@ -1,6 +1,7 @@
import { NavLink, Outlet, useLocation, useNavigate, Navigate } from "react-router-dom"; import { NavLink, Outlet, useLocation, useNavigate, Navigate } from "react-router-dom";
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3, Table2, Tv, ScrollText, ShieldCheck } 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"; import { useAuth } from "../../contexts/AuthContext";
import { isSuperAdmin } from "../../constants/task";
const MENU = [ const MENU = [
{ {
@ -50,6 +51,10 @@ const MENU = [
path: "/admin/audit", path: "/admin/audit",
icon: ScrollText, icon: ScrollText,
description: "谁在何时操作了什么 · 含失败与被拒请求", description: "谁在何时操作了什么 · 含失败与被拒请求",
// 仅超级管理员可见 —— 审计里有全员的 IP、操作明细与失败记录
// 对普通成员展示既无必要也多一层信息暴露面。
// 后端 /audit/* 本来就挂了 require_admin这里只是不给入口。
superOnly: true,
}, },
{ {
title: "管理层大屏", title: "管理层大屏",
@ -64,6 +69,11 @@ export default function AdminLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user, logout, isAuthenticated, loading } = useAuth(); const { user, logout, isAuthenticated, loading } = useAuth();
// 菜单按角色过滤:标了 superOnly 的(操作审计)只对超管显示。
// 真正的拦截在后端(/audit/* 挂了 require_admin这里只是不给入口
// 否则普通用户点进去只会看到一堆 403。
const visibleMenu = MENU.filter((m) => !m.superOnly || isSuperAdmin(user?.role));
// 数据范围徽标 —— 让用户看得见「我为什么只看到这些」。 // 数据范围徽标 —— 让用户看得见「我为什么只看到这些」。
// 空范围(未分组)必须显眼:列表全空却没有任何提示,用户会以为系统坏了, // 空范围(未分组)必须显眼:列表全空却没有任何提示,用户会以为系统坏了,
// 这是最难排查的一类反馈。 // 这是最难排查的一类反馈。
@ -115,7 +125,7 @@ export default function AdminLayout() {
</div> </div>
<nav className="flex-1 space-y-0.5 px-3 py-4"> <nav className="flex-1 space-y-0.5 px-3 py-4">
{MENU.map((item) => { {visibleMenu.map((item) => {
const isActive = location.pathname.startsWith(item.path); const isActive = location.pathname.startsWith(item.path);
return ( return (
<NavLink <NavLink
@ -160,7 +170,7 @@ export default function AdminLayout() {
<span></span> <span></span>
<span>/</span> <span>/</span>
<span className="font-medium text-gray-600"> <span className="font-medium text-gray-600">
{MENU.find((m) => location.pathname.startsWith(m.path))?.title ?? "页面"} {visibleMenu.find((m) => location.pathname.startsWith(m.path))?.title ?? "页面"}
</span> </span>
</div> </div>

View File

@ -17,18 +17,24 @@ import {
fetchMemberCandidates, fetchMemberCandidates,
type BusinessGroup, type BusinessGroup,
type GroupDetail, type GroupDetail,
type GroupMember,
type PhaseOption, type PhaseOption,
type MemberCandidate, type MemberCandidate,
} from "../../services/groupApi"; } from "../../services/groupApi";
/** /**
* 业务分组管理 —— 组决定成员能看到哪一段生命周期的数据 * 业务分组 —— 全员可见,操作按角色分层
* *
* 页面自带体验层门禁isSuperAdmin但真正的拦截在后端路由依赖 * 超管 → 所有组 + 全部操作(建组/改组/配范围/删组/管成员)
* (仅 require_roles(SUPER_ADMIN))—— 这里只是别让普通用户看到空壳。 * 主管/组长 → 只看到自己所属的组,能管**本组成员**
* 普通成员 → 只看到自己所属的组,纯只读
* *
* ⚠️ 为什么只有超管能进:被分进组的 SUPERVISOR 会从「全厂」降级为只看本组, * ⚠️ 为什么主管不能改组范围:被分进组的 SUPERVISOR 会从「全厂」降级为只看本组,
* 若允许主管管理分组,他把自己移出组就能恢复全厂视野 —— 那是提权路径 * 若他能改范围,把自己那组改成「生产+售后」就恢复全厂视野了
* 「能管成员」安全(组长给自己加组会被唯一约束挡住),「能配范围」是提权入口。
*
* 能否操作由服务端下发的 can_manage_group / can_manage_members 决定 ——
* 组长身份是按组算的,前端凭 role 推不出来。
*/ */
export default function AdminGroupsPage() { export default function AdminGroupsPage() {
const { message, modal } = App.useApp(); const { message, modal } = App.useApp();
@ -236,14 +242,8 @@ export default function AdminGroupsPage() {
// ============================================================ // ============================================================
// 渲染 // 渲染
// ============================================================ // ============================================================
if (!isSuperAdmin(user?.role)) { // 建组 / 改组 / 配范围 / 删组 —— 仅超管(服务端同样硬校验,这里只是不给按钮)
return ( const canCreateGroup = isSuperAdmin(user?.role);
<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) { if (loading) {
return ( return (
@ -284,29 +284,43 @@ export default function AdminGroupsPage() {
<div> <div>
<h2 className="text-xl font-bold text-gray-800"></h2> <h2 className="text-xl font-bold text-gray-800"></h2>
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-gray-500">
{canCreateGroup
? "组决定成员能看到哪一段生命周期的数据。可在组内配置可见范围、分配成员。"
: "你所属的业务分组。分组决定你能看到哪一段生命周期的数据。"}
</p> </p>
</div> </div>
<Button type="primary" icon={<Plus size={16} />} onClick={() => openCreate(null)}> {canCreateGroup && (
<Button type="primary" icon={<Plus size={16} />} onClick={() => openCreate(null)}>
</Button>
</Button>
)}
</div> </div>
<div className="rounded-xl bg-white p-5 shadow-sm"> {groups.length === 0 ? (
<Table <div className="flex flex-col items-center justify-center rounded-xl bg-white py-16 shadow-sm">
rowKey="id" <ShieldCheck className="mb-3 h-10 w-10 text-gray-300" />
columns={groupColumns} <p className="text-sm text-gray-500"></p>
dataSource={groups} <p className="mt-1 text-xs text-gray-400">
size="small"
pagination={false} </p>
locale={{ emptyText: <Empty description="还没有分组" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }} </div>
rowClassName={(g) => (g.id === selectedId ? "bg-blue-50 cursor-pointer" : "cursor-pointer")} ) : (
onRow={(g) => ({ onClick: () => setSelectedId(g.id) })} <div className="rounded-xl bg-white p-5 shadow-sm">
/> <Table
<p className="mt-3 text-xs text-gray-400"> rowKey="id"
columns={groupColumns}
</p> dataSource={groups}
</div> 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 && ( {detail && (
<div className="rounded-xl bg-white p-5 shadow-sm"> <div className="rounded-xl bg-white p-5 shadow-sm">
@ -315,19 +329,23 @@ export default function AdminGroupsPage() {
<h3 className="text-lg font-semibold text-gray-800">{detail.name}</h3> <h3 className="text-lg font-semibold text-gray-800">{detail.name}</h3>
{detail.parent_name && <Tag> {detail.parent_name}</Tag>} {detail.parent_name && <Tag> {detail.parent_name}</Tag>}
{!detail.is_active && <Tag color="default"></Tag>} {!detail.is_active && <Tag color="default"></Tag>}
{detail.parent_id === null && ( {detail.is_my_leader && <Tag color="gold"></Tag>}
{detail.can_manage_group && detail.parent_id === null && (
<Button size="small" icon={<Plus size={14} />} onClick={() => openCreate(detail.id)}> <Button size="small" icon={<Plus size={14} />} onClick={() => openCreate(detail.id)}>
</Button> </Button>
)} )}
</div> </div>
<div className="flex gap-2"> {/* 改组名/范围/启停/删除 —— 仅超管(服务端同样硬校验) */}
<Button size="small" icon={<Pencil size={14} />} onClick={() => openEdit(detail)}></Button> {detail.can_manage_group && (
<Button size="small" onClick={() => handleToggleActive(detail)}> <div className="flex gap-2">
{detail.is_active ? "停用" : "启用"} <Button size="small" icon={<Pencil size={14} />} onClick={() => openEdit(detail)}></Button>
</Button> <Button size="small" onClick={() => handleToggleActive(detail)}>
<Button size="small" danger icon={<Trash2 size={14} />} onClick={() => handleDelete(detail)}></Button> {detail.is_active ? "停用" : "启用"}
</div> </Button>
<Button size="small" danger icon={<Trash2 size={14} />} onClick={() => handleDelete(detail)}></Button>
</div>
)}
</div> </div>
<div className="mb-5"> <div className="mb-5">
@ -346,9 +364,11 @@ export default function AdminGroupsPage() {
<div className="text-sm font-medium text-gray-700"> <div className="text-sm font-medium text-gray-700">
<span className="text-gray-400">({detail.members.length})</span> <span className="text-gray-400">({detail.members.length})</span>
</div> </div>
<Button size="small" icon={<UserPlus size={14} />} onClick={openAddMember}> {detail.can_manage_members && (
<Button size="small" icon={<UserPlus size={14} />} onClick={openAddMember}>
</Button>
</Button>
)}
</div> </div>
{detailLoading ? ( {detailLoading ? (
@ -365,24 +385,29 @@ export default function AdminGroupsPage() {
{ title: "姓名", dataIndex: "display_name", key: "display_name" }, { 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: "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, title: "组长", dataIndex: "is_leader", key: "is_leader", width: 100,
render: (v: boolean, m) => ( // 无权限时退化成纯展示(灰底文字),不给可点的样子
render: (v: boolean, m) => detail.can_manage_members ? (
<button <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"}`} 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); }} onClick={(e) => { e.stopPropagation(); handleToggleLeader(m.user_id, !v); }}
> >
<Crown size={12} />{v ? "组长" : "设为组长"} <Crown size={12} />{v ? "组长" : "设为组长"}
</button> </button>
) : (
<span className={v ? "inline-flex items-center gap-1 text-xs text-amber-600" : "text-xs text-gray-300"}>
<Crown size={12} />{v ? "组长" : "—"}
</span>
), ),
}, },
{ ...(detail.can_manage_members ? [{
title: "操作", key: "action", width: 70, title: "操作", key: "action", width: 70,
render: (_: unknown, m) => ( render: (_: unknown, m: GroupMember) => (
<Button type="link" size="small" danger onClick={(e) => { e.stopPropagation(); handleRemoveMember(m.user_id); }}> <Button type="link" size="small" danger onClick={(e) => { e.stopPropagation(); handleRemoveMember(m.user_id); }}>
</Button> </Button>
), ),
}, }] : []),
]} ]}
/> />
)} )}

View File

@ -23,6 +23,10 @@ export interface BusinessGroup {
effective_phases: string[]; // 实际生效范围(自己没配则取父组的) effective_phases: string[]; // 实际生效范围(自己没配则取父组的)
phase_labels: string[]; // 生效范围的中文标签 phase_labels: string[]; // 生效范围的中文标签
member_count: number; member_count: number;
// ---- 当前用户对这个组能做什么(服务端算好的,别按 role 自己推导)----
can_manage_members: boolean; // 能加/移成员、设组长
can_manage_group: boolean; // 能改组名/范围/启停/删除 —— 仅超管
is_my_leader: boolean; // 我是这个组的组长
} }
export interface GroupMember { export interface GroupMember {