Files
lingxi/apps/web/src/pages/users/use-user-list-controller.ts
T
Stev_Wang 4d9eb980af
ci / 质量门禁 (push) Canceled after 0s
feat(identity): 完成 A1 身份认证与用户管理全栈落地
背景:
- 开发计划 A1 批次要求登录/刷新/登出、用户生命周期、路由与按钮权限、
  权限三态用例;视觉已选定方案 B(主从分栏 + 分屏登录)。

改动:
- packages/shared:新增 identity 契约(User/Me/Login/Refresh/Department/Role)
  与 user:view 权限点;写接口 strict 校验。
- apps/api:新增 identity 模块与 Prisma expand 迁移(departments/users/roles/
  user_roles/role_permissions/refresh_tokens);Argon2id、JWT access+refresh
  旋转与 jti 吊销、登录失败锁定、首登强改、守卫链、/me、@Audit 上下文、幂等种子。
- apps/web:按方案 B 落地 /users、/users/new、/users/:id、分屏登录与 403;
  usePermission/<Can>、路由守卫、401 单飞刷新、停用会话兜底、Semi + token。
- e2e:新增 A1-QA-01 六类端到端用例(A1_E2E 门控)。
- openapi 与前端生成类型同步;.env.example 登记认证变量;实施报告入库。

影响面:
- 新增运行时依赖 @nestjs/jwt、argon2(计划 A1-BE-02 已批准,版本精确锁定)。
- 迁移为 expand,不改既有表;本地需 prisma migrate 后重新 seed。
- 平台导航「用户与组织」由 /platform/users 调整为 /users。

回滚方式:
- 代码整体回滚本提交;迁移为 expand,回滚代码不需删表;
- 本地如需彻底回退按实施报告 §6 处理。

验证证据:
- pnpm format:check / lint / typecheck 通过;
- pnpm test:shared 95 / api 60 / web 55;
- pnpm test:int:7 个(真实 PG/Redis);
- A1_E2E=1 playwright test e2e/a1-identity.spec.ts:6/6;
- 报告:docs/开发计划/灵犀_A1身份认证与用户管理_实施报告_V0.1.md
2026-11-03 00:03:33 +08:00

95 lines
2.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable max-lines-per-function -- 列表筛选与游标分页状态集中在一个 Hook */
import type { UserSummary } from '@lingxi/shared';
import { useCallback, useMemo, useState } from 'react';
import { useUserListQuery } from '@/hooks/queries/use-users';
import { useDebouncedValue } from '@/hooks/use-debounced-value';
import {
DEFAULT_USER_LIST_FILTERS,
toUserListQueryInput,
type UserListFilters,
} from './user-filters';
export interface UserListController {
filters: UserListFilters;
users: UserSummary[];
loading: boolean;
error: Error | null;
hasNext: boolean;
hasPrev: boolean;
setFilters: (next: Partial<UserListFilters>) => void;
resetFilters: () => void;
onNext: () => void;
onPrev: () => void;
onRetry: () => void;
}
/** 列表筛选 + 游标分页(cursor 历史栈,前进/后退都复用后端返回的 next_cursor) */
export function useUserListController(): UserListController {
const [filters, setFiltersState] = useState<UserListFilters>(DEFAULT_USER_LIST_FILTERS);
const debouncedKeyword = useDebouncedValue(filters.keyword);
const [cursors, setCursors] = useState<(string | null)[]>([null]);
const [pageIndex, setPageIndex] = useState(0);
const queryInput = useMemo(
() =>
toUserListQueryInput(
{ ...filters, keyword: debouncedKeyword },
cursors[pageIndex] ?? undefined,
),
[filters, debouncedKeyword, cursors, pageIndex],
);
const query = useUserListQuery(queryInput);
const resetPaging = useCallback(() => {
setCursors([null]);
setPageIndex(0);
}, []);
const setFilters = useCallback(
(next: Partial<UserListFilters>) => {
setFiltersState((current) => ({ ...current, ...next }));
resetPaging();
},
[resetPaging],
);
const resetFilters = useCallback(() => {
setFiltersState(DEFAULT_USER_LIST_FILTERS);
resetPaging();
}, [resetPaging]);
const nextCursor = query.data?.next_cursor ?? null;
const onNext = useCallback(() => {
if (nextCursor === null) {
return;
}
setCursors((current) => [...current.slice(0, pageIndex + 1), nextCursor]);
setPageIndex((current) => current + 1);
}, [nextCursor, pageIndex]);
const onPrev = useCallback(() => {
setPageIndex((current) => Math.max(0, current - 1));
}, []);
const onRetry = useCallback(() => {
void query.refetch();
}, [query]);
return {
filters,
users: query.data?.items ?? [],
loading: query.isPending,
error: query.error,
hasNext: nextCursor !== null,
hasPrev: pageIndex > 0,
setFilters,
resetFilters,
onNext,
onPrev,
onRetry,
};
}