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:
@ -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