Skip to content

功能模块拆分

  • types.d.ts 各个文件需要的 ts 类型注解
  • services/index.ts 服务模块导出总出口
  • services/user.ts 请求服务端用户 API 相关方法
  • store/user.ts 实际业务 pinia store 文件
  • store/user.spec.ts 实际业务 store 的测试文件
typescript
// types.d.ts 其他文件需要的数据类型约束
// 登录表单数据结构
interface ILoginForm {
  email: string;
  password: string;
}

// 更新用户密码
interface IUpdatePasswordForm {
  old_password: string;
  new_password: string;
  confirm_password: string;
}

// 用户对象
interface IUserDto {
  id: number;
  username: string;
  email: string;
  avatar: string;
  status: number;
  created_at: string;
  token?: string;
  permissions?: IPermisssionDto[];
  roles?: IRoleDto[];
}

// 创建用户信息
type ICreateUserForm = Pick<IUserDto, 'username' | 'email' | 'password'> & {
  confirm: string;
  avatar?: string;
};

// 修改用户信息
type IUpdateUserForm = Partial<IUserDto>;

// 角色对象
interface IRoleDto {
  id: 1;
  role_name: '超级管理员';
  role_desc: '拥有所有权限';
  permissions?: IPermisssionDto[];
}

// 权限对象
interface IPermisssionDto {
  id: number;
  desc: string;
  type: number;
  method: string | null;
  icon: string;
  path: string;
  status: number;
  pid: number;
}

// 带有分页信息的响应数据
interface PaginateResponse<T> {
  count: number;
  rows: T[];
}

// 分页参数
interface IPagination {
  page: number;
  size: number;
}

// 搜索参数
interface ISearchParams extends IPagination {
  type?: number | string;
  content?: string;
}

// 获取用户接口的响应数据
type IGetUsersResponse = PaginateResponse<IUserDto>;
typescript
// services/index.ts services导出总出口, 这样的好处是在导入时, 不需要再 @/services/xxx 可以直接 @services
export * as userService from './user';
typescript
// services/user.ts
export function login(data: ILoginForm): Promise<IUserDto> {
  return http.post('/api/auth/login', data);
}

export function updatePassword(data: IUpdatePasswordForm): Promise<void> {
  return http.patch('/api/users/update_password', data);
}

export function getUsers(params: ISearchQuery): Promise<IGetUsersResponse> {
  return http.get('/api/users', { params });
}

export function createUser(data: ICreateUserForm): Promise<IUserDto> {
  return http.post('/api/users', data);
}

export function updateUser(id: number, data: IUpdateUserForm): Promise<void> {
  return http.patch(`/api/users/${id}`, data);
}
typescript
// store/user.ts 用户相关数据
import { removeToken, saveToken } from '@/utils/token';
import { defineStore } from 'pinia';
import { userService } from '@/services';
import { useLocalStorage } from '@vueuse/core';
import { reactive, watch, nextTick } from 'vue';

export const useUserStore = defineStore('user', () => {
  const defaultUser: IUserDto = {
    id: 0,
    username: '',
    email: '',
    avatar: '',
    status: 0,
    created_at: '',
    token: '',
    permissions: [],
  };

  // 登录和注销功能
  const authUser = useLocalStorage('pinia/authUser', defaultUser);
  function setAuthedUser(userInfo: IUserDto): void {
    authUser.value = userInfo;
  }
  function getAuthedUser() {
    return authUser.value;
  }
  async function login(loginForm: ILoginForm) {
    const authedUser = await userService.login(loginForm);
    setAuthedUser(authedUser);
    saveToken(authedUser.token!);
  }
  function logout() {
    setAuthedUser(defaultUser);
    removeToken();
  }

  // 更新用户密码功能
  async function updateUserPassword(data: IUpdatePasswordForm) {
    await userService.updatePassword(data);
    removeToken();
  }

  // 查询用户列表
  const users = reactive<IGetUsersResponse>({
    count: 0,
    rows: [],
  });
  const defaultSearchQuery: ISearchQuery = {
    page: 1,
    size: 10,
    type: '',
    content: '',
  };
  const searchParams = reactive<ISearchQuery>(defaultSearchQuery);
  async function getUsers() {
    const res = await userService.getUsers(searchParams);
    users.count = res.count;
    users.rows = res.rows;
  }
  watch(
    () => [searchParams.page, searchParams.size],
    async () => await getUsers(),
  );

  // 创建用户信息
  const createLayerData = reactive({
    visible: false,
  });
  const createFormModel = reactive<ICreateUserForm>({
    // 需要提交到服务端的表单数据
    username: '',
    email: '',
    password: '',
    confirm: '',
  });
  async function createUserInfo(user: ICreateUserForm) {
    await userService.createUser(user);
    await nextTick(getUsers);
  }

  // 修改用户信息
  async function updateUserInfo(id: number, user: IUpdateUserForm) {
    await userService.updateUser(id, user);
    await nextTick(getUsers);
  }
  const updateLayerData = reactive<{ visible: boolean; row?: IUserDto }>({
    visible: false,
    row: undefined,
  });
  const updateFormModel = reactive<IUpdateUserForm>({
    // 需要提交到服务端的表单数据
    id: 0,
    username: '',
    email: '',
    avatar: '',
  });
  watch(
    () => updateLayerData.row as IUserDto,
    (value: IUserDto) => {
      if (!value) return;
      updateFormModel.id = value.id;
      updateFormModel.username = value.username;
      updateFormModel.email = value.email;
      updateFormModel.avatar = value.avatar;
    },
  );

  return {
    login,
    logout,
    authUser,
    getAuthedUser,
    updateUserPassword,
    getUsers,
    searchParams,
    users,
    createLayerData,
    createFormModel,
    createUserInfo,
    updateLayerData,
    updateFormModel,
    updateUserInfo,
  };
});

测试 store 业务逻辑

typescript
// store/user.spec.ts 业务逻辑单元测试
import { userService } from '@/services';
import { useUserStore } from '@/store/user';
import { hasToken, removeToken, saveToken } from '@/utils/token';
import { setActivePinia, createPinia } from 'pinia';

vi.mock('@/services', () => {
  return {
    userService: {
      login: () => {
        return {
          id: 1,
          username: 'admin',
          email: 'admin@qq.com',
          avatar: 'http://xxx.com/1.jpg',
          status: 0,
          created_at: '2020-12-18T07:08:56.000Z',
          token: 'token-string',
          permissions: [],
        };
      },
      updatePassword: vi.fn(),
      createUser: vi.fn(),
      updateUser: vi.fn(),
      getUsers: () => {
        return {
          count: 10,
          rows: [
            {
              id: 1,
              email: 'test@qq.com',
            },
          ],
        };
      },
    },
  };
});

describe('user store', () => {
  let store: any;
  beforeEach(() => {
    removeToken();
    setActivePinia(createPinia());
    store = useUserStore();
  });

  describe('login/logout', () => {
    it('authed user info should have email, username and token fields', () => {
      const authUserInfo = store.getAuthedUser();
      expect(authUserInfo).toHaveProperty('username');
      expect(authUserInfo).toHaveProperty('email');
      expect(authUserInfo).toHaveProperty('token');
    });

    it('should be set authed user info after login', async () => {
      await store.login({} as ILoginForm);
      const authUserInfo = store.getAuthedUser();
      expect(authUserInfo.username).toBe('admin');
    });

    it('should be saved token after login', async () => {
      await store.login({} as ILoginForm);
      expect(hasToken()).toBe(true);
    });
  });

  describe('update user password', () => {
    it('should be delete token after update user password', async () => {
      saveToken('token-string'); // mock logined status
      expect(hasToken()).toBe(true);

      await store.updateUserPassword({} as IUpdatePasswordForm);
      expect(hasToken()).toBe(false);
    });
  });

  describe('search user list', () => {
    it('should be have list data and pagination information', async () => {
      await store.getUsers();
      expect(Array.isArray(store.users.rows)).toBe(true);
      expect(store.users.count).toBeTypeOf('number');
    });

    it('should be get users list align when search params page or size field changed', async () => {
      function updatePatinationParams(isPage = false): Promise<void> {
        if (isPage) {
          store.searchParams.page += 1;
        } else {
          store.searchParams.size += 1;
        }
        return Promise.resolve();
        // 因为 watch 触发 reactive 的依赖是异步触发的, 所以应该 await
      }

      vi.spyOn(userService, 'getUsers');
      await updatePatinationParams();
      expect(userService.getUsers).toBeCalled();

      await updatePatinationParams(true);
      expect(userService.getUsers).toBeCalled();
    });
  });

  describe('create user info', () => {
    it('should be get users list align when created new user', async () => {
      vi.spyOn(userService, 'getUsers');
      await store.createUserInfo();
      expect(userService.getUsers).toBeCalled();
    });
  });

  describe('update user info', () => {
    it('should be get users list align when updated user info', async () => {
      vi.spyOn(userService, 'getUsers');
      await store.updateUserInfo();
      expect(userService.getUsers).toBeCalled();
    });
  });
});

Released under the MIT License.