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:
468
backend/app/api/v1/endpoints/groups.py
Normal file
468
backend/app/api/v1/endpoints/groups.py
Normal 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()
|
||||
@ -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)
|
||||
|
||||
65
backend/app/schemas/group.py
Normal file
65
backend/app/schemas/group.py
Normal 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 # 真实姓名,仅供界面展示
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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 });
|
||||
}, []);
|
||||
|
||||
474
frontend/src/pages/admin/AdminGroupsPage.tsx
Normal file
474
frontend/src/pages/admin/AdminGroupsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
frontend/src/services/groupApi.ts
Normal file
124
frontend/src/services/groupApi.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user