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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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