diff --git a/backend/app/api/v1/endpoints/groups.py b/backend/app/api/v1/endpoints/groups.py new file mode 100644 index 0000000..b96fd34 --- /dev/null +++ b/backend/app/api/v1/endpoints/groups.py @@ -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() diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 9134603..80b08c8 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -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) diff --git a/backend/app/schemas/group.py b/backend/app/schemas/group.py new file mode 100644 index 0000000..d981827 --- /dev/null +++ b/backend/app/schemas/group.py @@ -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 # 真实姓名,仅供界面展示 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e774b91..bcd8b31 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index 8bcbcc7..d76ea00 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -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() { - {/* 用户信息 + 登出 */} + {/* 数据范围 + 用户信息 + 登出 */}
+ {scopeBadge && ( + + {scopeBadge.text} + + )} + {scope?.is_leader && ( + + 组长 + + )}
diff --git a/frontend/src/constants/task.ts b/frontend/src/constants/task.ts index ec02d44..a8940d8 100644 --- a/frontend/src/constants/task.ts +++ b/frontend/src/constants/task.ts @@ -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, ]; diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 4e46b3c..54b4869 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -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 }); }, []); diff --git a/frontend/src/pages/admin/AdminGroupsPage.tsx b/frontend/src/pages/admin/AdminGroupsPage.tsx new file mode 100644 index 0000000..e3ffae1 --- /dev/null +++ b/frontend/src/pages/admin/AdminGroupsPage.tsx @@ -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([]); + const [phaseOptions, setPhaseOptions] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedId, setSelectedId] = useState(null); + const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + + // 编辑弹窗 + const [editOpen, setEditOpen] = useState(false); + const [editing, setEditing] = useState(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([]); + const [picked, setPicked] = useState(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 ( +
+ +

仅超级管理员可管理业务分组

+
+ ); + } + + if (loading) { + return ( +
+ +
+ ); + } + + const groupColumns = [ + { + title: "分组", + dataIndex: "name", + key: "name", + render: (v: string, g: BusinessGroup) => ( +
+ {g.parent_id !== null && } + {v} + {g.parent_id === null && 大组} + {!g.is_active && 已停用} +
+ ), + }, + { + title: "可见范围", + key: "scope", + render: (_: unknown, g: BusinessGroup) => + g.phase_labels.length + ? g.phase_labels.map((l) => {l}) + : 未配置(看不到数据), + }, + { title: "成员", dataIndex: "member_count", key: "member_count", width: 60 }, + ]; + + return ( +
+
+
+

业务分组

+

+ 组决定成员能看到哪一段生命周期的数据。可在组内配置可见范围、分配成员。 +

+
+ +
+ +
+ }} + rowClassName={(g) => (g.id === selectedId ? "bg-blue-50 cursor-pointer" : "cursor-pointer")} + onRow={(g) => ({ onClick: () => setSelectedId(g.id) })} + /> +

+ 点击一行查看详情与成员。小组未单独配置范围时,自动继承所属大组。 +

+ + + {detail && ( +
+
+
+

{detail.name}

+ {detail.parent_name && 属于 {detail.parent_name}} + {!detail.is_active && 已停用} + {detail.parent_id === null && ( + + )} +
+
+ + + +
+
+ +
+
可见范围
+
+ {detail.phase_labels.length + ? detail.phase_labels.map((l) => {l}) + : 未配置 —— 该组成员看不到任何数据} + {detail.phases.length === 0 && detail.parent_id !== null && ( + (继承自 {detail.parent_name}) + )} +
+
+ +
+
+ 成员 ({detail.members.length}) +
+ +
+ + {detailLoading ? ( +
+ ) : detail.members.length === 0 ? ( + + ) : ( +
{v} }, + { + title: "组长", dataIndex: "is_leader", key: "is_leader", width: 90, + render: (v: boolean, m) => ( + + ), + }, + { + title: "操作", key: "action", width: 70, + render: (_: unknown, m) => ( + + ), + }, + ]} + /> + )} + + {/* 添加成员面板 */} + {candidates.length > 0 && ( +
+ + setPickedLeader(e.target.checked)}>设为组长 + + +
+ )} + + )} + + {/* 新建 / 编辑弹窗 */} + setEditOpen(false)} + onOk={handleSave} + confirmLoading={saving} + okText="保存" + cancelText="取消" + width={520} + > +
+
+ + setForm({ ...form, name: e.target.value })} + placeholder="如 生产大组 / 维修大组 / 生产小组" + maxLength={50} + /> +
+ {form.parent_id !== null && ( +
+ 上级:{topGroups.find((g) => g.id === form.parent_id)?.name ?? form.parent_id} +
+ )} +
+ +
+ {phaseOptions.map((opt) => ( + setForm({ + ...form, + phases: e.target.checked + ? [...form.phases, opt.value] + : form.phases.filter((p) => p !== opt.value), + })} + > + {opt.label} + + ))} +
+
+
+ + setForm({ ...form, description: e.target.value })} + placeholder="这个组负责什么" + maxLength={200} + /> +
+
+
+ + ); +} diff --git a/frontend/src/services/groupApi.ts b/frontend/src/services/groupApi.ts new file mode 100644 index 0000000..b1b3f5c --- /dev/null +++ b/frontend/src/services/groupApi.ts @@ -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 { + const { data } = await api.get("/groups/phase-options"); + return data; +} + +export async function fetchGroups(): Promise { + const { data } = await api.get("/groups"); + return data; +} + +export async function fetchGroupDetail(groupId: number): Promise { + const { data } = await api.get(`/groups/${groupId}`); + return data; +} + +export async function createGroup(payload: GroupPayload): Promise { + const { data } = await api.post("/groups", payload); + return data; +} + +export async function updateGroup( + groupId: number, + payload: Partial & { is_active?: boolean } +): Promise { + const { data } = await api.patch(`/groups/${groupId}`, payload); + return data; +} + +/** 删除分组 —— 仅空组可删,有成员时后端返回 409 */ +export async function deleteGroup(groupId: number): Promise { + await api.delete(`/groups/${groupId}`); +} + +export async function addGroupMember( + groupId: number, + userId: string, + isLeader = false +): Promise { + const { data } = await api.post(`/groups/${groupId}/members`, { + user_id: userId, + is_leader: isLeader, + }); + return data; +} + +export async function removeGroupMember(groupId: number, userId: string): Promise { + await api.delete(`/groups/${groupId}/members/${encodeURIComponent(userId)}`); +} + +export async function setGroupLeader( + groupId: number, + userId: string, + isLeader: boolean +): Promise { + const { data } = await api.patch( + `/groups/${groupId}/members/${encodeURIComponent(userId)}`, + { is_leader: isLeader } + ); + return data; +} + +/** 候选人下拉 —— 数据源是 MOM 的 LICA 部门人员 */ +export async function fetchMemberCandidates(keyword?: string): Promise { + const { data } = await api.get("/groups/member-candidates", { + params: keyword ? { keyword } : {}, + }); + return data; +}