提交 2c95eb73 authored 作者: 王鹏飞's avatar 王鹏飞

refactor(admin): migrate access role module

上级 163696c5
# access/role migration report
## Status
Migrated `/jurisdiction/role` into `modules/access/role`. The active access route
now lazy-loads the module route and view. The old role page was removed after a
successful module build; its request file remains as a compatibility dependency
for the separately owned teachers module.
## Preserved contract
- List API remains `POST /user/role/getList` through `utils/request.js` with a
`qs.stringify` body and `page`, `page_size`, `name`, `start_time`, `end_time`.
- Add, detail, edit, delete, status, selected-permission, permission-save, and
all-permissions endpoints are unchanged.
- Status mutation still sends string `"1"` for enabled and `"2"` for disabled.
- Permission save still sends `{ role_id, power_list }`.
- Existing business success/error messages and false-return behavior remain in
the API adapter.
- Filters remain role name and creation time; date values use local-day second
timestamps. Pagination remains 1/10 with 10/20/30/40 choices.
- All seven columns remain in order. The displayed ID remains the page-relative
sequence number, and remarks retain the 300px three-line Popover treatment.
- Permissions remain exact: `changeStatus`, `getInfoById`, `getPowerByRoleId`,
`del`, and `add` under `/jurisdiction/role/*`.
- Add/edit validation, deletion confirmation, role types, and mutation refresh
behavior are retained.
- The authorization tree remains a module-private component with strict parent/
child checking, descendant selection, ancestor selection, and the original
fixed submit footer. It is not part of AppList.
## Architecture
- `api.ts`: legacy request protocol and awaited response handling.
- `query.ts`: role filter mapping.
- `useRoleList.ts`: controlled rows, filters, pagination, loading, error, retry,
and stale-response protection.
- `views/RoleListView.tsx`: permissions, mutations, AppList, and drawer control.
- `components/`: role form, delete confirmation, and permission tree drawer.
- `routes.tsx`: module-owned leaf route with unchanged public URL.
## Verification
- `modules/access/role` oxlint and oxfmt checks pass.
- `pnpm test` passed all 11 global Node tests.
- Post-delete `pnpm build` passed with only the existing formula-editor and
large-chunk warnings.
- `pnpm exec tsc --noEmit` contains no access/role diagnostics; unrelated
existing/concurrent workspace diagnostics remain.
## Concerns
1. No API/query/Hook tests were added, per instruction.
2. `apps/admin/src/modules/teachers/list/views/TeacherListView.jsx` still imports
`pages/jurisdiction/role/request.js` for `getAllList`. That request file is
therefore retained; removing the cross-module dependency requires a teachers
task and is outside this scope.
3. Deprecated `src/routes/routes.jsx` and `src/config/component.js` still contain
the old role page path as inactive metadata. Active routes and builds use the
new module.
import { message } from "antd";
// @ts-expect-error The legacy workspace does not install the qs declaration package.
import qs from "qs";
import request from "@/utils/request.js";
import type { RoleQuery } from "./query";
export interface RoleRow {
id: number;
name?: string;
type?: number | string;
remarks?: string;
status?: number | string;
create_time?: string;
}
export interface PermissionRecord {
id: number;
label?: string;
children?: PermissionRecord[];
[key: string]: unknown;
}
export interface RoleListResult {
rows: RoleRow[];
total: number;
}
type ApiParams = Record<string, unknown>;
async function getPayload<T>(url: string, params: ApiParams): Promise<T | false> {
try {
const response = await request.post(url, qs.stringify(params));
const body = response.data as { code?: number; data?: T; message?: string };
const code = body?.code ?? 0;
const data = (body?.data ?? []) as T;
const text = body?.message ?? "数据获取失败!";
if (code === 200) return data;
message.error(text);
return false;
} catch {
return false;
}
}
async function mutate(url: string, params: ApiParams) {
try {
const response = await request.post(url, qs.stringify(params));
const body = response.data as { code?: number; message?: string };
const code = body?.code ?? 0;
const text = body?.message ?? "数据获取失败!";
if (code === 200) {
message.success(text);
return true;
}
message.error(text);
return false;
} catch {
return false;
}
}
export async function getRoleList(params: RoleQuery): Promise<RoleListResult | false> {
const data = await getPayload<{ list?: RoleRow[]; total?: number }>("/user/role/getList", {
...params,
});
if (data === false) return false;
return { rows: data.list ?? [], total: data.total ?? 0 };
}
export function getRoleInfo(id: number) {
return getPayload<RoleRow>("/user/role/getInfoById", { id });
}
export function addRole(params: ApiParams) {
return mutate("/user/role/add", params);
}
export function editRole(params: ApiParams) {
return mutate("/user/role/edit", params);
}
export function deleteRole(id: number) {
return mutate("/user/role/del", { id });
}
export function changeRoleStatus(id: number, status: "1" | "2") {
return mutate("/user/role/changeStatus", { status, id });
}
export function getRolePermissionIds(roleId: number) {
return getPayload<Array<string | number>>("/user/role/getPowerByRoleId", { role_id: roleId });
}
export function setRolePermissions(roleId: number, powerList: Array<string | number>) {
return mutate("/user/role/setRolePower", { role_id: roleId, power_list: powerList });
}
export function getAllPermissions() {
return getPayload<PermissionRecord[]>("/system/power/getListAll", {});
}
import { Modal, Row, Space } from "antd";
interface DeleteRoleModalProps {
open: boolean;
onCancel: () => void;
onConfirm: () => void | Promise<void>;
}
export default function DeleteRoleModal({ open, onCancel, onConfirm }: DeleteRoleModalProps) {
return (
<Modal
mask={false}
centered
open={open}
onOk={onConfirm}
onCancel={onCancel}
footer={(_, { OkBtn, CancelBtn }) => (
<Row justify="center" className="delModal" style={{ marginBottom: "30px" }}>
<Space size={20}>
<CancelBtn />
<OkBtn />
</Space>
</Row>
)}
okText="确认"
>
<p style={{ textAlign: "center", padding: "30px 0 18px", fontSize: 16 }}>
角色将永久删除,是否继续?
</p>
</Modal>
);
}
import { Button, Drawer, Form, Space, Tree } from "antd";
import type { DataNode } from "antd/es/tree";
import type { Key } from "react";
import { findParents } from "@/utils/common.js";
export type PermissionTreeNode = DataNode & {
key: Key;
children?: PermissionTreeNode[];
};
interface PermissionDrawerProps {
open: boolean;
treeData: PermissionTreeNode[];
checkedKeys: Key[];
onCheckedKeysChange: (keys: Key[]) => void;
onClose: () => void;
onSubmit: () => void | Promise<void>;
}
function collectNodeKeys(node: PermissionTreeNode) {
const keys: Key[] = [node.key];
node.children?.forEach((child) => keys.push(...collectNodeKeys(child)));
return keys;
}
export default function PermissionDrawer({
open,
treeData,
checkedKeys,
onCheckedKeysChange,
onClose,
onSubmit,
}: PermissionDrawerProps) {
return (
<Drawer placement="right" open={open} onClose={onClose} mask={false}>
<Form labelCol={{ span: 6 }}>
<Form.Item name="power_list" style={{ padding: 10, paddingTop: 0 }}>
<Tree
checkable
checkedKeys={checkedKeys}
defaultExpandAll={false}
treeData={treeData}
checkStrictly
onCheck={(nextCheckedKeys, info) => {
const nextKeys = Array.isArray(nextCheckedKeys)
? nextCheckedKeys
: nextCheckedKeys.checked;
const node = info.node as PermissionTreeNode;
const nodeAndChildren = collectNodeKeys(node);
if (info.checked) {
const parents = (findParents(treeData, "key", node.key) ?? []) as Key[];
onCheckedKeysChange([...nextKeys, ...nodeAndChildren, ...parents]);
return;
}
onCheckedKeysChange(nextKeys.filter((key) => !nodeAndChildren.includes(key)));
}}
/>
</Form.Item>
<Form.Item
wrapperCol={{ offset: 7, span: 16 }}
style={{
position: "fixed",
bottom: 0,
width: "30%",
background: "#fff",
paddingBottom: 20,
margin: 0,
}}
>
<Space size={20}>
<Button onClick={onClose}>取消</Button>
<Button type="primary" htmlType="submit" onClick={onSubmit}>
提交
</Button>
</Space>
</Form.Item>
</Form>
</Drawer>
);
}
import { Button, Drawer, Form, Input, Radio, Space } from "antd";
import { useEffect } from "react";
const { TextArea } = Input;
export interface RoleFormValues {
name: string;
type: string;
remarks?: string;
}
interface RoleFormDrawerProps {
open: boolean;
initialRole: Partial<RoleFormValues> | null;
onClose: () => void;
onSubmit: (values: RoleFormValues) => void | Promise<void>;
}
export default function RoleFormDrawer({
open,
initialRole,
onClose,
onSubmit,
}: RoleFormDrawerProps) {
const [form] = Form.useForm<RoleFormValues>();
useEffect(() => {
if (!open) {
form.resetFields();
return;
}
if (initialRole) form.setFieldsValue(initialRole);
}, [form, initialRole, open]);
return (
<Drawer placement="right" onClose={onClose} open={open} mask={false}>
<Form form={form} onFinish={onSubmit} labelCol={{ span: 6 }}>
<Form.Item
name="name"
label="角色名称"
rules={[
{ required: true, message: "请输入角色名称" },
{ max: 50, message: "角色名称最多可输入50个字符" },
]}
>
<Input autoComplete="off" placeholder="请输入角色名称" />
</Form.Item>
<Form.Item
name="type"
label="角色类型"
rules={[{ required: true, message: "请选择角色类型" }]}
>
<Radio.Group style={{ textAlign: "center" }}>
<Radio value="1">管理员</Radio>
<Radio value="2">老师</Radio>
</Radio.Group>
</Form.Item>
<Form.Item
name="remarks"
label="备注"
rules={[
{ required: false, message: "请输入备注" },
{ max: 100, message: "备注最多可输入100个字符" },
]}
>
<TextArea autoSize={{ minRows: 5, maxRows: 8 }} placeholder="请输入备注" />
</Form.Item>
<Form.Item wrapperCol={{ offset: 12, span: 16 }}>
<Space size={20}>
<Button onClick={onClose}>取消</Button>
<Button type="primary" htmlType="submit">
提交
</Button>
</Space>
</Form.Item>
</Form>
</Drawer>
);
}
import type { Dayjs } from "dayjs";
export interface RoleFilters {
name?: string;
time?: [Dayjs | null, Dayjs | null] | null;
}
export interface RoleQuery {
page: number;
page_size: number;
name?: string;
start_time?: number;
end_time?: number;
}
interface BuildRoleQueryInput {
page: number;
pageSize: number;
filters: RoleFilters;
}
export function buildRoleQuery({ page, pageSize, filters }: BuildRoleQueryInput): RoleQuery {
const query: RoleQuery = { page, page_size: pageSize };
if (filters.name) query.name = filters.name;
if (filters.time?.[0] && filters.time[1]) {
query.start_time = filters.time[0].startOf("day").unix();
query.end_time = filters.time[1].endOf("day").unix();
}
return query;
}
import { Navigate, useRoutes } from "react-router";
import RoleListView from "./views/RoleListView";
export default function RoleRoutes() {
return useRoutes([
{ index: true, Component: RoleListView },
{ path: "*", element: <Navigate to="/jurisdiction/role" /> },
]);
}
import { useCallback, useEffect, useRef, useState } from "react";
import { getRoleList, type RoleListResult, type RoleRow } from "./api";
import { buildRoleQuery, type RoleFilters, type RoleQuery } from "./query";
const INITIAL_PAGE = 1;
const INITIAL_PAGE_SIZE = 10;
function isCleared(previous: RoleFilters, next: RoleFilters) {
const keys = new Set([...Object.keys(previous), ...Object.keys(next)]) as Set<keyof RoleFilters>;
return [...keys].some((key) => previous[key] !== next[key] && !next[key]);
}
export function useRoleList() {
const [filters, setFilterState] = useState<RoleFilters>({});
const [appliedFilters, setAppliedFilters] = useState<RoleFilters>({});
const [page, setPage] = useState(INITIAL_PAGE);
const [pageSize, setPageSize] = useState(INITIAL_PAGE_SIZE);
const [rows, setRows] = useState<RoleRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const requestId = useRef(0);
const lastQuery = useRef<RoleQuery>(
buildRoleQuery({ page: INITIAL_PAGE, pageSize: INITIAL_PAGE_SIZE, filters: {} }),
);
const load = useCallback(async (query: RoleQuery) => {
const currentRequest = ++requestId.current;
lastQuery.current = query;
setLoading(true);
setError(null);
try {
const result: RoleListResult | false = await getRoleList(query);
if (currentRequest !== requestId.current) return;
if (result === false) {
setError(new Error("数据获取失败!"));
return;
}
setRows(result.rows);
setTotal(result.total);
} catch (reason) {
if (currentRequest === requestId.current) {
setError(reason instanceof Error ? reason : new Error("数据获取失败!"));
}
} finally {
if (currentRequest === requestId.current) setLoading(false);
}
}, []);
const loadFor = useCallback(
(nextPage: number, nextPageSize: number, nextFilters: RoleFilters) =>
load(buildRoleQuery({ page: nextPage, pageSize: nextPageSize, filters: nextFilters })),
[load],
);
useEffect(() => {
void load(lastQuery.current);
return () => {
requestId.current += 1;
};
}, [load]);
const setFilters = useCallback(
(nextFilters: RoleFilters) => {
const shouldReload = isCleared(filters, nextFilters);
setFilterState(nextFilters);
if (shouldReload) {
setAppliedFilters(nextFilters);
void loadFor(page, pageSize, nextFilters);
}
},
[filters, loadFor, page, pageSize],
);
const search = useCallback(() => {
setAppliedFilters(filters);
setPage(INITIAL_PAGE);
void loadFor(INITIAL_PAGE, pageSize, filters);
}, [filters, loadFor, pageSize]);
const reset = useCallback(() => {
const emptyFilters: RoleFilters = {};
setFilterState(emptyFilters);
setAppliedFilters(emptyFilters);
setPage(INITIAL_PAGE);
void loadFor(INITIAL_PAGE, pageSize, emptyFilters);
}, [loadFor, pageSize]);
const refresh = useCallback(() => {
setAppliedFilters(filters);
void loadFor(page, pageSize, filters);
}, [filters, loadFor, page, pageSize]);
const refreshUnfiltered = useCallback(() => {
void loadFor(page, pageSize, {});
}, [loadFor, page, pageSize]);
const changePage = useCallback(
(nextPage: number) => {
setPage(nextPage);
void loadFor(nextPage, pageSize, appliedFilters);
},
[appliedFilters, loadFor, pageSize],
);
const changePageSize = useCallback(
(nextPageSize: number) => {
setPage(INITIAL_PAGE);
setPageSize(nextPageSize);
void loadFor(INITIAL_PAGE, nextPageSize, appliedFilters);
},
[appliedFilters, loadFor],
);
const retry = useCallback(() => void load(lastQuery.current), [load]);
return {
filters,
setFilters,
page,
pageSize,
rows,
total,
loading,
error,
search,
reset,
refresh,
refreshUnfiltered,
changePage,
changePageSize,
retry,
};
}
import { Button, DatePicker, Input, Popover, Space, Switch } from "antd";
import type { ColumnsType } from "antd/es/table";
import type { Key } from "react";
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList";
import { convertToAntdTreeData } from "@/utils/common.js";
import {
addRole,
changeRoleStatus,
deleteRole,
editRole,
getAllPermissions,
getRoleInfo,
getRolePermissionIds,
setRolePermissions,
type RoleRow,
} from "../api";
import DeleteRoleModal from "../components/DeleteRoleModal";
import PermissionDrawer, { type PermissionTreeNode } from "../components/PermissionDrawer";
import RoleFormDrawer, { type RoleFormValues } from "../components/RoleFormDrawer";
import type { RoleFilters } from "../query";
import { useRoleList } from "../useRoleList";
interface RootState {
user: {
operationPermissionsList?: string[];
};
}
const { RangePicker } = DatePicker;
export default function RoleListView() {
const list = useRoleList();
const permissions = useSelector((state: RootState) => state.user.operationPermissionsList ?? []);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [isAdd, setIsAdd] = useState(false);
const [initialRole, setInitialRole] = useState<Partial<RoleFormValues> | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [permissionOpen, setPermissionOpen] = useState(false);
const [permissionTree, setPermissionTree] = useState<PermissionTreeNode[]>([]);
const [checkedKeys, setCheckedKeys] = useState<Key[]>([]);
useEffect(() => {
let active = true;
void getAllPermissions().then((result) => {
if (active && result !== false) {
setPermissionTree(convertToAntdTreeData(result) as PermissionTreeNode[]);
}
});
return () => {
active = false;
};
}, []);
const handleEdit = async (id: number) => {
const role = await getRoleInfo(id);
if (role === false) return;
setSelectedId(id);
setIsAdd(false);
setInitialRole({
name: role.name,
remarks: role.remarks,
type: String(role.type),
});
setFormOpen(true);
};
const handleFormSubmit = async (values: RoleFormValues) => {
const params: Record<string, unknown> = { ...values };
if (!params.remarks) delete params.remarks;
const success = isAdd ? await addRole(params) : await editRole({ ...params, id: selectedId });
if (!success) return;
list.refreshUnfiltered();
setFormOpen(false);
};
const handleDelete = async () => {
if (selectedId === null) return;
const success = await deleteRole(selectedId);
if (success) list.refreshUnfiltered();
setDeleteOpen(false);
};
const handleStatus = async (checked: boolean, id: number) => {
const success = await changeRoleStatus(id, checked ? "1" : "2");
if (success) list.refreshUnfiltered();
};
const handleEmpower = async (id: number) => {
setSelectedId(id);
setPermissionOpen(true);
const result = await getRolePermissionIds(id);
setCheckedKeys(result === false ? [] : result);
};
const submitPermissions = async () => {
if (selectedId === null) return;
const success = await setRolePermissions(selectedId, checkedKeys as Array<string | number>);
if (!success) return;
list.refreshUnfiltered();
setPermissionOpen(false);
};
const columns: ColumnsType<RoleRow> = [
{
title: "ID",
key: "id",
dataIndex: "id",
render: (_, __, index) => list.pageSize * (list.page - 1) + index + 1,
},
{
title: "角色名称",
key: "name",
align: "center",
dataIndex: "name",
width: 300,
},
{
title: "角色类型",
key: "type",
align: "center",
dataIndex: "type",
width: 300,
render: (value) => {
if (Number(value) === 1) return "管理员";
if (Number(value) === 2) return "老师";
return "未知";
},
},
{
title: "备注",
key: "remarks",
align: "center",
dataIndex: "remarks",
width: 300,
render: (text: string) => (
<Popover content={text} overlayStyle={{ maxWidth: "400px" }}>
<div
style={{
display: "-webkit-box",
WebkitLineClamp: "3",
WebkitBoxOrient: "vertical",
overflow: "hidden",
textOverflow: "ellipsis",
textAlign: "center",
}}
>
{text}
</div>
</Popover>
),
},
{
title: "状态",
align: "center",
key: "status",
dataIndex: "status",
width: 300,
render: (_, record) => (
<Switch
checked={Number(record.status) === 1}
onChange={(checked) => void handleStatus(checked, record.id)}
disabled={!permissions.includes("/jurisdiction/role/changeStatus")}
/>
),
},
{
title: "创建时间",
key: "create_time",
align: "center",
dataIndex: "create_time",
},
{
title: "操作",
key: "handle",
align: "center",
dataIndex: "handle",
width: 400,
render: (_, record) => (
<Space>
{permissions.includes("/jurisdiction/role/getInfoById") ? (
<Button onClick={() => void handleEdit(record.id)}>编辑</Button>
) : null}
{permissions.includes("/jurisdiction/role/getPowerByRoleId") ? (
<Button onClick={() => void handleEmpower(record.id)}>授权</Button>
) : null}
{permissions.includes("/jurisdiction/role/del") ? (
<Button
onClick={() => {
setSelectedId(record.id);
setDeleteOpen(true);
}}
>
删除
</Button>
) : null}
</Space>
),
},
];
const filterItems: AppListFilterItem<RoleFilters>[] = [
{
name: "name",
label: "角色名称",
render: (field) => (
<Input
autoComplete="off"
allowClear
value={field.value ?? ""}
onChange={(event) => field.onChange(event.target.value)}
placeholder="请输入角色名称"
id="name"
/>
),
},
{
name: "time",
label: "创建时间",
render: (field) => (
<RangePicker
value={field.value}
onChange={(value) => field.onChange(value)}
id="createTime"
/>
),
},
];
const toolbar = permissions.includes("/jurisdiction/role/add") ? (
<Button
type="primary"
ghost
onClick={() => {
setSelectedId(null);
setInitialRole(null);
setIsAdd(true);
setFormOpen(true);
}}
>
添加
</Button>
) : undefined;
return (
<>
<AppList<RoleRow, RoleFilters>
rowKey="id"
columns={columns}
data={{
rows: list.rows,
total: list.total,
loading: list.loading,
error: list.error,
}}
filters={{
value: list.filters,
items: filterItems,
onChange: list.setFilters,
onSearch: list.search,
onReset: list.reset,
onRefresh: list.refresh,
}}
pagination={{
page: list.page,
pageSize: list.pageSize,
pageSizeOptions: [10, 20, 30, 40],
onChange: list.changePage,
onPageSizeChange: list.changePageSize,
}}
toolbar={toolbar}
onRetry={list.retry}
/>
<RoleFormDrawer
open={formOpen}
initialRole={initialRole}
onClose={() => setFormOpen(false)}
onSubmit={handleFormSubmit}
/>
<DeleteRoleModal
open={deleteOpen}
onCancel={() => setDeleteOpen(false)}
onConfirm={handleDelete}
/>
<PermissionDrawer
open={permissionOpen}
treeData={permissionTree}
checkedKeys={checkedKeys}
onCheckedKeysChange={setCheckedKeys}
onClose={() => setPermissionOpen(false)}
onSubmit={submitPermissions}
/>
</>
);
}
......@@ -4,7 +4,7 @@ import ModuleBoundary from "@/app/ModuleBoundary";
const AccessLayout = lazy(() => import("@/pages/jurisdiction/index"));
const AdminRoutes = lazy(() => import("./admin/routes"));
const Role = lazy(() => import("@/pages/jurisdiction/role/index"));
const RoleRoutes = lazy(() => import("./role/routes"));
export default function AccessRoutes() {
const routes = useRoutes([
......@@ -13,7 +13,7 @@ export default function AccessRoutes() {
children: [
{ index: true, element: <Navigate to="/jurisdiction/role" /> },
{ path: "admin/*", Component: AdminRoutes },
{ path: "role", Component: Role },
{ path: "role/*", Component: RoleRoutes },
],
},
{ path: "*", element: <Navigate to="/" /> },
......
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论