Commit cc70720ca33e2bbc8e8317ffa5d4231e4609f6ff

Authored by zichun
1 parent 906f05c5

feat(frontend): usersApi 4 个函数 + RequireSuperAdmin 守卫 + MSW handlers

REQ_ID: FE-02
frontend/src/api/users.test.ts 0 → 100644
  1 +import { describe, it, expect } from 'vitest';
  2 +import { usersApi } from './users';
  3 +import { BizError } from './errors';
  4 +
  5 +describe('usersApi', () => {
  6 + it('list returns PageResult with records', async () => {
  7 + const result = await usersApi.list();
  8 + expect(result.total).toBeGreaterThan(0);
  9 + expect(result.records[0].username).toBeDefined();
  10 + expect(result.page).toBe(1);
  11 + });
  12 +
  13 + it('list with queryField=username queryValue=ali returns filtered results', async () => {
  14 + const result = await usersApi.list({ queryField: 'username', queryValue: 'ali' });
  15 + expect(result.total).toBe(1);
  16 + expect(result.records[0].username).toBe('alice');
  17 + });
  18 +
  19 + it('get returns UserDetail', async () => {
  20 + const detail = await usersApi.get(1);
  21 + expect(detail.userId).toBe(1);
  22 + expect(detail.permissionCategoryIds).toEqual([1, 2]);
  23 + });
  24 +
  25 + it('get unknown userId throws BizError 40401', async () => {
  26 + await expect(usersApi.get(99999)).rejects.toMatchObject({ code: 40401 });
  27 + });
  28 +
  29 + it('create returns CreateUserVo on success', async () => {
  30 + const vo = await usersApi.create({
  31 + username: 'newbie',
  32 + userCode: 'U010',
  33 + userType: 'NORMAL',
  34 + language: 'zh-CN',
  35 + canEditDocument: false,
  36 + });
  37 + expect(vo.userId).toBe(42);
  38 + expect(vo.username).toBe('newbie');
  39 + });
  40 +
  41 + it('create with duplicate username throws BizError 40901', async () => {
  42 + await expect(
  43 + usersApi.create({
  44 + username: 'dup',
  45 + userCode: 'U011',
  46 + userType: 'NORMAL',
  47 + language: 'zh-CN',
  48 + canEditDocument: false,
  49 + }),
  50 + ).rejects.toMatchObject({ code: 40901 });
  51 + });
  52 +
  53 + it('update returns UserDetail with patched fields', async () => {
  54 + const detail = await usersApi.update(1, { userCode: 'U_NEW' });
  55 + expect(detail.userCode).toBe('U_NEW');
  56 + });
  57 +});
frontend/src/api/users.ts 0 → 100644
  1 +import { apiClient } from './client';
  2 +
  3 +export interface UserListItem {
  4 + userId: number;
  5 + username: string;
  6 + employeeName?: string | null;
  7 + userCode: string;
  8 + departmentName?: string | null;
  9 + userType: 'NORMAL' | 'SUPER_ADMIN';
  10 + language: string;
  11 + isDeleted: boolean;
  12 + lastLoginDate?: string | null;
  13 + createdBy?: string | null;
  14 + createdDate?: string | null;
  15 +}
  16 +
  17 +export interface UserDetail extends UserListItem {
  18 + employeeId?: number | null;
  19 + permissionCategoryIds: number[];
  20 + updatedBy?: string | null;
  21 + updatedDate?: string | null;
  22 +}
  23 +
  24 +export interface UsersListQuery {
  25 + page?: number;
  26 + size?: number;
  27 + sortField?: 'tCreateDate' | 'tLastLoginDate' | 'sUsername' | 'sUserCode';
  28 + sortOrder?: 'asc' | 'desc';
  29 + queryField?: string;
  30 + matchMode?: 'contains' | 'notContains' | 'equals';
  31 + queryValue?: string;
  32 + userType?: 'NORMAL' | 'SUPER_ADMIN';
  33 + isDeleted?: boolean;
  34 +}
  35 +
  36 +export interface PageResult<T> {
  37 + records: T[];
  38 + total: number;
  39 + page: number;
  40 + size: number;
  41 +}
  42 +
  43 +export interface CreateUserReq {
  44 + username: string;
  45 + userCode: string;
  46 + userType: 'NORMAL' | 'SUPER_ADMIN';
  47 + language: 'zh-CN' | 'en-US' | 'zh-TW';
  48 + canEditDocument: boolean;
  49 + employeeId?: number;
  50 + permissionCategoryIds?: number[];
  51 +}
  52 +
  53 +export interface UpdateUserReq {
  54 + userCode?: string;
  55 + userType?: 'NORMAL' | 'SUPER_ADMIN';
  56 + language?: 'zh-CN' | 'en-US' | 'zh-TW';
  57 + canEditDocument?: boolean;
  58 + employeeId?: number;
  59 + isDeleted?: boolean;
  60 + permissionCategoryIds?: number[];
  61 +}
  62 +
  63 +export interface CreateUserVo {
  64 + userId: number;
  65 + username: string;
  66 + userCode: string;
  67 +}
  68 +
  69 +export const usersApi = {
  70 + async list(query: UsersListQuery = {}): Promise<PageResult<UserListItem>> {
  71 + return await apiClient.get<unknown, PageResult<UserListItem>>('/users', { params: query });
  72 + },
  73 + async get(userId: number): Promise<UserDetail> {
  74 + return await apiClient.get<unknown, UserDetail>(`/users/${userId}`);
  75 + },
  76 + async create(req: CreateUserReq): Promise<CreateUserVo> {
  77 + return await apiClient.post<unknown, CreateUserVo>('/users', req);
  78 + },
  79 + async update(userId: number, req: UpdateUserReq): Promise<UserDetail> {
  80 + return await apiClient.put<unknown, UserDetail>(`/users/${userId}`, req);
  81 + },
  82 +};
frontend/src/router/RequireSuperAdmin.test.tsx 0 → 100644
  1 +import { describe, it, expect } from 'vitest';
  2 +import { render, screen } from '@testing-library/react';
  3 +import { MemoryRouter, Routes, Route } from 'react-router-dom';
  4 +import { Provider } from 'react-redux';
  5 +import { configureStore } from '@reduxjs/toolkit';
  6 +import authReducer, { setSession } from '../store/slices/authSlice';
  7 +import RequireSuperAdmin from './RequireSuperAdmin';
  8 +
  9 +function makeStore(opts: { token?: string; userType?: 'NORMAL' | 'SUPER_ADMIN' } = {}) {
  10 + const store = configureStore({ reducer: { auth: authReducer } });
  11 + if (opts.token) {
  12 + store.dispatch(
  13 + setSession({
  14 + accessToken: opts.token,
  15 + userInfo: {
  16 + userId: 1,
  17 + username: 'alice',
  18 + userType: opts.userType ?? 'NORMAL',
  19 + language: 'zh-CN',
  20 + companyCode: 'HQ',
  21 + },
  22 + }),
  23 + );
  24 + }
  25 + return store;
  26 +}
  27 +
  28 +function renderRoutes(store: ReturnType<typeof makeStore>, entry: string) {
  29 + return render(
  30 + <Provider store={store}>
  31 + <MemoryRouter initialEntries={[entry]}>
  32 + <Routes>
  33 + <Route
  34 + path="/users"
  35 + element={
  36 + <RequireSuperAdmin>
  37 + <div data-testid="admin-only">ADMIN</div>
  38 + </RequireSuperAdmin>
  39 + }
  40 + />
  41 + <Route path="/login" element={<div data-testid="login">LOGIN</div>} />
  42 + </Routes>
  43 + </MemoryRouter>
  44 + </Provider>,
  45 + );
  46 +}
  47 +
  48 +describe('RequireSuperAdmin', () => {
  49 + it('no token → redirects to /login', () => {
  50 + renderRoutes(makeStore(), '/users');
  51 + expect(screen.getByTestId('login')).toBeInTheDocument();
  52 + });
  53 +
  54 + it('NORMAL user token → shows 403 Result', () => {
  55 + renderRoutes(makeStore({ token: 'jwt', userType: 'NORMAL' }), '/users');
  56 + expect(screen.getByTestId('forbidden-result')).toBeInTheDocument();
  57 + expect(screen.queryByTestId('admin-only')).toBeNull();
  58 + });
  59 +
  60 + it('SUPER_ADMIN token → renders children', () => {
  61 + renderRoutes(makeStore({ token: 'jwt', userType: 'SUPER_ADMIN' }), '/users');
  62 + expect(screen.getByTestId('admin-only')).toBeInTheDocument();
  63 + });
  64 +});
frontend/src/router/RequireSuperAdmin.tsx 0 → 100644
  1 +import { Navigate, useLocation } from 'react-router-dom';
  2 +import { Result } from 'antd';
  3 +import { useAppSelector } from '../store/hooks';
  4 +import { selectIsAuthenticated, selectUserInfo } from '../store/slices/authSlice';
  5 +
  6 +export default function RequireSuperAdmin({ children }: { children: React.ReactNode }) {
  7 + const isAuth = useAppSelector(selectIsAuthenticated);
  8 + const userInfo = useAppSelector(selectUserInfo);
  9 + const location = useLocation();
  10 +
  11 + if (!isAuth) {
  12 + return <Navigate to="/login" replace state={{ from: location }} />;
  13 + }
  14 + if (userInfo?.userType !== 'SUPER_ADMIN') {
  15 + return (
  16 + <div data-testid="forbidden-result">
  17 + <Result status="403" title="权限不足" subTitle="仅超级管理员可访问此页面" />
  18 + </div>
  19 + );
  20 + }
  21 + return <>{children}</>;
  22 +}
frontend/src/test-utils/msw-handlers.ts
@@ -64,4 +64,172 @@ export const handlers = [ @@ -64,4 +64,172 @@ export const handlers = [
64 await delay(50); 64 await delay(50);
65 return HttpResponse.error(); 65 return HttpResponse.error();
66 }), 66 }),
  67 +
  68 + // === REQ-USR-002/003/004: users CRUD ===
  69 + http.get(`${BASE}/users`, ({ request }) => {
  70 + const url = new URL(request.url);
  71 + const queryField = url.searchParams.get('queryField');
  72 + const queryValue = url.searchParams.get('queryValue');
  73 + const page = Number(url.searchParams.get('page') ?? 1);
  74 + const size = Number(url.searchParams.get('size') ?? 20);
  75 + const allUsers = [
  76 + {
  77 + userId: 1,
  78 + username: 'alice',
  79 + employeeName: '张三',
  80 + userCode: 'U001',
  81 + departmentName: '技术部',
  82 + userType: 'NORMAL',
  83 + language: 'zh-CN',
  84 + isDeleted: false,
  85 + lastLoginDate: '2026-05-15T08:00:00',
  86 + createdBy: 'admin',
  87 + createdDate: '2026-05-10T00:00:00',
  88 + },
  89 + {
  90 + userId: 2,
  91 + username: 'admin',
  92 + employeeName: null,
  93 + userCode: 'U000',
  94 + departmentName: null,
  95 + userType: 'SUPER_ADMIN',
  96 + language: 'zh-CN',
  97 + isDeleted: false,
  98 + lastLoginDate: null,
  99 + createdBy: 'system',
  100 + createdDate: '2026-05-01T00:00:00',
  101 + },
  102 + {
  103 + userId: 3,
  104 + username: 'bob_deleted',
  105 + employeeName: null,
  106 + userCode: 'U002',
  107 + departmentName: null,
  108 + userType: 'NORMAL',
  109 + language: 'zh-CN',
  110 + isDeleted: true,
  111 + lastLoginDate: null,
  112 + createdBy: 'admin',
  113 + createdDate: '2026-05-05T00:00:00',
  114 + },
  115 + ];
  116 + let filtered = allUsers;
  117 + if (queryField === 'username' && queryValue) {
  118 + filtered = allUsers.filter((u) => u.username.includes(queryValue));
  119 + }
  120 + const start = (page - 1) * size;
  121 + const records = filtered.slice(start, start + size);
  122 + return HttpResponse.json({
  123 + code: 200,
  124 + message: '操作成功',
  125 + data: { records, total: filtered.length, page, size },
  126 + timestamp: Date.now(),
  127 + });
  128 + }),
  129 +
  130 + http.get(`${BASE}/users/:userId`, ({ params }) => {
  131 + const userId = Number(params.userId);
  132 + if (userId === 99999) {
  133 + return HttpResponse.json(
  134 + { code: 40401, message: '用户不存在', data: null, timestamp: Date.now() },
  135 + { status: 404 },
  136 + );
  137 + }
  138 + return HttpResponse.json({
  139 + code: 200,
  140 + message: '操作成功',
  141 + data: {
  142 + userId,
  143 + username: 'alice',
  144 + employeeName: '张三',
  145 + userCode: 'U001',
  146 + departmentName: '技术部',
  147 + userType: 'NORMAL',
  148 + language: 'zh-CN',
  149 + isDeleted: false,
  150 + lastLoginDate: '2026-05-15T08:00:00',
  151 + employeeId: 1,
  152 + permissionCategoryIds: [1, 2],
  153 + createdBy: 'admin',
  154 + createdDate: '2026-05-10T00:00:00',
  155 + updatedBy: 'admin',
  156 + updatedDate: '2026-05-14T00:00:00',
  157 + },
  158 + timestamp: Date.now(),
  159 + });
  160 + }),
  161 +
  162 + http.post(`${BASE}/users`, async ({ request }) => {
  163 + const body = (await request.json()) as { username: string; userCode: string; userType: string };
  164 + if (body.username === 'dup') {
  165 + return HttpResponse.json(
  166 + { code: 40901, message: '用户名已存在', data: null, timestamp: Date.now() },
  167 + { status: 409 },
  168 + );
  169 + }
  170 + if (body.userCode === 'dup-code') {
  171 + return HttpResponse.json(
  172 + { code: 40902, message: '用户号已被占用', data: null, timestamp: Date.now() },
  173 + { status: 409 },
  174 + );
  175 + }
  176 + if (!['NORMAL', 'SUPER_ADMIN'].includes(body.userType)) {
  177 + return HttpResponse.json(
  178 + { code: 40001, message: 'userType 不在白名单', data: null, timestamp: Date.now() },
  179 + { status: 400 },
  180 + );
  181 + }
  182 + return HttpResponse.json(
  183 + {
  184 + code: 200,
  185 + message: '操作成功',
  186 + data: { userId: 42, username: body.username, userCode: body.userCode },
  187 + timestamp: Date.now(),
  188 + },
  189 + { status: 201 },
  190 + );
  191 + }),
  192 +
  193 + http.put(`${BASE}/users/:userId`, async ({ params, request }) => {
  194 + const userId = Number(params.userId);
  195 + const body = (await request.json()) as { isDeleted?: boolean; userCode?: string };
  196 + if (userId === 99999) {
  197 + return HttpResponse.json(
  198 + { code: 40401, message: '用户不存在', data: null, timestamp: Date.now() },
  199 + { status: 404 },
  200 + );
  201 + }
  202 + if (body.isDeleted === true && userId === 2) {
  203 + return HttpResponse.json(
  204 + { code: 40302, message: '不允许停用当前登录用户自己', data: null, timestamp: Date.now() },
  205 + { status: 403 },
  206 + );
  207 + }
  208 + if (body.userCode === 'dup-code') {
  209 + return HttpResponse.json(
  210 + { code: 40902, message: '用户号已被占用', data: null, timestamp: Date.now() },
  211 + { status: 409 },
  212 + );
  213 + }
  214 + return HttpResponse.json({
  215 + code: 200,
  216 + message: '操作成功',
  217 + data: {
  218 + userId,
  219 + username: 'alice',
  220 + employeeName: '张三',
  221 + userCode: body.userCode ?? 'U001',
  222 + departmentName: '技术部',
  223 + userType: 'NORMAL',
  224 + language: 'zh-CN',
  225 + isDeleted: body.isDeleted ?? false,
  226 + lastLoginDate: '2026-05-15T08:00:00',
  227 + employeeId: 1,
  228 + permissionCategoryIds: [1, 2],
  229 + updatedBy: 'admin',
  230 + updatedDate: '2026-05-15T09:00:00',
  231 + },
  232 + timestamp: Date.now(),
  233 + });
  234 + }),
67 ]; 235 ];