useUserDetail.ts 8.68 KB
// REQ-USR-001 / REQ-USR-002: 用户单据 hook(状态机 initialLoading/editing/submitting/submitError/submitSuccess/loadError)
import { useCallback, useEffect, useRef, useState } from 'react';
import { App as AntdApp } from 'antd';
import {
  createUser,
  updateUser,
  listEmployees,
  listPermissions,
} from '../../../api/usrApi';
import { ApiError } from '../../../api/request';
import type {
  EmployeeOption,
  PermissionItem,
  UserVO,
  UserDetailMode,
} from '../../../api/types';
import {
  CREATE_DEFAULTS,
  ERR_USERNAME_EXISTS,
  ERR_USER_NOT_FOUND,
  ERR_NO_PERMISSION,
  ERR_VALIDATION,
  MSG_ERR_USERNAME_EXISTS,
  MSG_ERR_USER_NOT_FOUND,
  MSG_ERR_NO_PERMISSION,
  MSG_ERR_VALIDATION,
  MSG_ERR_NETWORK,
  MSG_ERR_LOAD_EMPLOYEES,
  MSG_ERR_LOAD_PERMISSIONS,
  MSG_LOAD_DETAIL_FAIL,
  toCreateReq,
  toUpdateReq,
  userVoToFormValues,
  type UserFormValues,
} from './constants';

export interface UseUserDetailArgs {
  mode: UserDetailMode;
  userId?: number;
  presetUser?: UserVO | null;
}

export interface SubmitFieldError {
  field: keyof UserFormValues;
  message: string;
}

export interface SubmitResult {
  ok: boolean;
  id?: number;
  fieldError?: SubmitFieldError;
}

export interface UseUserDetailReturn {
  mode: UserDetailMode;
  formValues: UserFormValues;
  employees: EmployeeOption[];
  permissions: PermissionItem[];
  checkedPermissionIds: number[];
  readonlyCreator: string;
  readonlyCreateTime: string;
  loading: boolean;
  submitting: boolean;
  error: ApiError | null;
  loadFailed: boolean;
  setField(name: keyof UserFormValues, value: unknown): void;
  selectEmployee(value: number | null): void;
  togglePermission(id: number, checked: boolean): void;
  toggleAll(checked: boolean): void;
  submit(values: UserFormValues): Promise<SubmitResult>;
  reload(): void;
}

export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn {
  const { mode, userId, presetUser } = args;
  const { message } = AntdApp.useApp();

  const [formValues, setFormValues] = useState<UserFormValues>({ ...CREATE_DEFAULTS });
  const [employees, setEmployees] = useState<EmployeeOption[]>([]);
  const [permissions, setPermissions] = useState<PermissionItem[]>([]);
  const [checkedPermissionIds, setCheckedPermissionIds] = useState<number[]>([]);
  const [readonlyCreator, setReadonlyCreator] = useState('');
  const [readonlyCreateTime, setReadonlyCreateTime] = useState('');
  const [loading, setLoading] = useState(true); // initialLoading
  const [submitting, setSubmitting] = useState(false);
  const [error, setError] = useState<ApiError | null>(null);
  const [loadFailed, setLoadFailed] = useState(false);

  const employeesRef = useRef<EmployeeOption[]>(employees);
  employeesRef.current = employees;
  const checkedRef = useRef<number[]>(checkedPermissionIds);
  checkedRef.current = checkedPermissionIds;
  const messageRef = useRef(message);
  messageRef.current = message;
  const mountedRef = useRef(true);

  /** 把后端权限分类回勾:UserVO 不暴露已授权 id,故 edit 预填仅用 detail 的权限字段(无则空集) */
  const initFromVo = useCallback((vo: UserVO) => {
    setFormValues(userVoToFormValues(vo));
    setReadonlyCreator(vo.sCreator ?? '');
    setReadonlyCreateTime(vo.tCreateDate ?? '');
    // UserVO 不含已授权权限 id(FE-03 列表 VO),按空集初始化;后端补详情端点后可回勾
    setCheckedPermissionIds([]);
  }, []);

  /** 挂载预取(员工/权限)+ edit 详情回填(initialLoading→editing / loadError) */
  const runLoad = useCallback(async () => {
    setLoading(true);
    setLoadFailed(false);
    try {
      const [emps, perms] = await Promise.all([listEmployees(), listPermissions()]);
      if (!mountedRef.current) return;
      setEmployees(emps);
      setPermissions(perms);

      if (mode === 'edit') {
        if (presetUser) {
          initFromVo(presetUser);
        } else {
          // FE-04: edit 预填只能来自 FE-03 经 navigate state 透传的列表行(presetUser)。
          // 路由 :id 是用户主键,而唯一读端点 GET /api/usr/users 的 queryField 无主键选项
          // (docs/05 REQ-USR-003),故不能按主键去查「用户号」列——在真实后端必然返回空、
          // 误报 40401,无法满足编辑预填(BR17 / REQ-USR-002 验收)。
          // 缺 presetUser(如直接访问 URL / 刷新丢失 state)时按 loadError 处理,
          // 由页面给出「返回列表」重试入口,重新经列表双击携带 state 进入。
          if (!mountedRef.current) return;
          setLoading(false);
          setLoadFailed(true);
          messageRef.current.error(MSG_LOAD_DETAIL_FAIL);
          return;
        }
      } else {
        setFormValues({ ...CREATE_DEFAULTS });
        setCheckedPermissionIds([]);
      }
      setLoading(false);
    } catch (err) {
      if (!mountedRef.current) return;
      setLoading(false);
      setLoadFailed(true);
      // 区分员工/权限/详情失败文案:以 reject 顺序无法精确分辨,按权限优先(最常见空源)
      const apiErr = err instanceof ApiError ? err : new ApiError(-1, MSG_ERR_NETWORK);
      if (mode === 'edit' && employeesRef.current.length === 0 && permissions.length === 0) {
        messageRef.current.error(MSG_LOAD_DETAIL_FAIL);
      } else {
        messageRef.current.error(
          apiErr.message === MSG_ERR_LOAD_EMPLOYEES
            ? MSG_ERR_LOAD_EMPLOYEES
            : MSG_ERR_LOAD_PERMISSIONS,
        );
      }
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [mode, userId, presetUser, initFromVo]);

  useEffect(() => {
    mountedRef.current = true;
    void runLoad();
    return () => {
      mountedRef.current = false;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const setField = useCallback((name: keyof UserFormValues, value: unknown) => {
    setFormValues((prev) => ({ ...prev, [name]: value }));
  }, []);

  /** 选择员工:带出用户名(create)/ 用户号(BR5,用户仍可改) */
  const selectEmployee = useCallback((value: number | null) => {
    setFormValues((prev) => {
      const emp = employeesRef.current.find((e) => e.value === value);
      if (!emp) return { ...prev, iEmployeeId: value };
      return {
        ...prev,
        iEmployeeId: value,
        sUserName: emp.label,
        sUserNo: emp.sEmployeeNo ?? prev.sUserNo,
      };
    });
  }, []);

  const togglePermission = useCallback((id: number, checked: boolean) => {
    setCheckedPermissionIds((prev) => {
      if (checked) return prev.includes(id) ? prev : [...prev, id];
      return prev.filter((p) => p !== id);
    });
  }, []);

  const toggleAll = useCallback((checked: boolean) => {
    setCheckedPermissionIds(checked ? permissions.map((p) => p.id) : []);
  }, [permissions]);

  /** 提交:create→POST / edit→PUT;错误码分流(spec § 4) */
  const submit = useCallback(
    async (values: UserFormValues): Promise<SubmitResult> => {
      setSubmitting(true);
      setError(null);
      try {
        const ids = checkedRef.current;
        let id: number;
        if (mode === 'edit' && userId != null) {
          const ret = await updateUser(userId, toUpdateReq(values, ids));
          id = ret.id;
        } else {
          const ret = await createUser(toCreateReq(values, ids));
          id = ret.id;
        }
        if (mountedRef.current) setSubmitting(false);
        return { ok: true, id };
      } catch (err) {
        const apiErr = err instanceof ApiError ? err : new ApiError(-1, MSG_ERR_NETWORK);
        if (mountedRef.current) {
          setSubmitting(false);
          setError(apiErr);
        }
        if (apiErr.code === ERR_USERNAME_EXISTS) {
          messageRef.current.error(MSG_ERR_USERNAME_EXISTS);
          return {
            ok: false,
            fieldError: { field: 'sUserName', message: MSG_ERR_USERNAME_EXISTS },
          };
        }
        if (apiErr.code === ERR_USER_NOT_FOUND) {
          messageRef.current.error(MSG_ERR_USER_NOT_FOUND);
        } else if (apiErr.code === ERR_NO_PERMISSION) {
          messageRef.current.error(MSG_ERR_NO_PERMISSION);
        } else if (apiErr.code === ERR_VALIDATION) {
          messageRef.current.error(MSG_ERR_VALIDATION);
        } else {
          messageRef.current.error(MSG_ERR_NETWORK);
        }
        return { ok: false };
      }
    },
    [mode, userId],
  );

  const reload = useCallback(() => {
    void runLoad();
  }, [runLoad]);

  return {
    mode,
    formValues,
    employees,
    permissions,
    checkedPermissionIds,
    readonlyCreator,
    readonlyCreateTime,
    loading,
    submitting,
    error,
    loadFailed,
    setField,
    selectEmployee,
    togglePermission,
    toggleAll,
    submit,
    reload,
  };
}