refactor(hooks): ♻️ sync refactor useTable and enhance type definitions.

This commit is contained in:
一寸灰
2025-08-31 20:44:19 +08:00
committed by GitHub
parent c420f37ab6
commit c4cdee3ce5
20 changed files with 588 additions and 557 deletions

View File

@@ -13,11 +13,12 @@ import type {
ResponseType
} from './type';
function createCommonRequest<ResponseData = any>(
axiosConfig?: CreateAxiosDefaults,
options?: Partial<RequestOption<ResponseData>>
) {
const opts = createDefaultOptions<ResponseData>(options);
function createCommonRequest<
ResponseData,
ApiData = ResponseData,
State extends Record<string, unknown> = Record<string, unknown>
>(axiosConfig?: CreateAxiosDefaults, options?: Partial<RequestOption<ResponseData, ApiData, State>>) {
const opts = createDefaultOptions<ResponseData, ApiData, State>(options);
const axiosConf = createAxiosConfig(axiosConfig);
const instance = axios.create(axiosConf);
@@ -80,14 +81,6 @@ function createCommonRequest<ResponseData = any>(
}
);
function cancelRequest(requestId: string) {
const abortController = abortControllerMap.get(requestId);
if (abortController) {
abortController.abort();
abortControllerMap.delete(requestId);
}
}
function cancelAllRequest() {
abortControllerMap.forEach(abortController => {
abortController.abort();
@@ -98,7 +91,6 @@ function createCommonRequest<ResponseData = any>(
return {
instance,
opts,
cancelRequest,
cancelAllRequest
};
}
@@ -109,27 +101,27 @@ function createCommonRequest<ResponseData = any>(
* @param axiosConfig axios config
* @param options request options
*/
export function createRequest<ResponseData = any, State = Record<string, unknown>>(
export function createRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
axiosConfig?: CreateAxiosDefaults,
options?: Partial<RequestOption<ResponseData>>
options?: Partial<RequestOption<ResponseData, ApiData, State>>
) {
const { instance, opts, cancelRequest, cancelAllRequest } = createCommonRequest<ResponseData>(axiosConfig, options);
const { instance, opts, cancelAllRequest } = createCommonRequest<ResponseData, ApiData, State>(axiosConfig, options);
const request: RequestInstance<State> = async function request<T = any, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig
) {
const request: RequestInstance<ApiData, State> = async function request<
T extends ApiData = ApiData,
R extends ResponseType = 'json'
>(config: CustomAxiosRequestConfig) {
const response: AxiosResponse<ResponseData> = await instance(config);
const responseType = response.config?.responseType || 'json';
if (responseType === 'json') {
return opts.transformBackendResponse(response);
return opts.transform(response);
}
return response.data as MappedType<R, T>;
} as RequestInstance<State>;
} as RequestInstance<ApiData, State>;
request.cancelRequest = cancelRequest;
request.cancelAllRequest = cancelAllRequest;
request.state = {} as State;
@@ -144,14 +136,14 @@ export function createRequest<ResponseData = any, State = Record<string, unknown
* @param axiosConfig axios config
* @param options request options
*/
export function createFlatRequest<ResponseData = any, State = Record<string, unknown>>(
export function createFlatRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
axiosConfig?: CreateAxiosDefaults,
options?: Partial<RequestOption<ResponseData>>
options?: Partial<RequestOption<ResponseData, ApiData, State>>
) {
const { instance, opts, cancelRequest, cancelAllRequest } = createCommonRequest<ResponseData>(axiosConfig, options);
const { instance, opts, cancelAllRequest } = createCommonRequest<ResponseData, ApiData, State>(axiosConfig, options);
const flatRequest: FlatRequestInstance<State, ResponseData> = async function flatRequest<
T = any,
const flatRequest: FlatRequestInstance<ResponseData, ApiData, State> = async function flatRequest<
T extends ApiData = ApiData,
R extends ResponseType = 'json'
>(config: CustomAxiosRequestConfig) {
try {
@@ -160,20 +152,21 @@ export function createFlatRequest<ResponseData = any, State = Record<string, unk
const responseType = response.config?.responseType || 'json';
if (responseType === 'json') {
const data = opts.transformBackendResponse(response);
const data = await opts.transform(response);
return { data, error: null, response };
}
return { data: response.data as MappedType<R, T>, error: null };
return { data: response.data as MappedType<R, T>, error: null, response };
} catch (error) {
return { data: null, error, response: (error as AxiosError<ResponseData>).response };
}
} as FlatRequestInstance<State, ResponseData>;
} as FlatRequestInstance<ResponseData, ApiData, State>;
flatRequest.cancelRequest = cancelRequest;
flatRequest.cancelAllRequest = cancelAllRequest;
flatRequest.state = {} as State;
flatRequest.state = {
...opts.defaultState
} as State;
return flatRequest;
}

View File

@@ -4,15 +4,27 @@ import { stringify } from 'qs';
import { isHttpSuccess } from './shared';
import type { RequestOption } from './type';
export function createDefaultOptions<ResponseData = any>(options?: Partial<RequestOption<ResponseData>>) {
const opts: RequestOption<ResponseData> = {
export function createDefaultOptions<
ResponseData,
ApiData = ResponseData,
State extends Record<string, unknown> = Record<string, unknown>
>(options?: Partial<RequestOption<ResponseData, ApiData, State>>) {
const opts: RequestOption<ResponseData, ApiData, State> = {
defaultState: {} as State,
transform: async response => response.data as unknown as ApiData,
transformBackendResponse: async response => response.data as unknown as ApiData,
onRequest: async config => config,
isBackendSuccess: _response => true,
onBackendFail: async () => {},
transformBackendResponse: async response => response.data,
onError: async () => {}
};
if (options?.transform) {
opts.transform = options.transform;
} else {
opts.transform = options?.transformBackendResponse || opts.transform;
}
Object.assign(opts, options);
return opts;

View File

@@ -8,7 +8,30 @@ export type ContentType =
| 'application/x-www-form-urlencoded'
| 'application/octet-stream';
export interface RequestOption<ResponseData = any> {
export type ResponseTransform<Input = any, Output = any> = (input: Input) => Output | Promise<Output>;
export interface RequestOption<
ResponseData,
ApiData = ResponseData,
State extends Record<string, unknown> = Record<string, unknown>
> {
/**
* The default state
*/
defaultState?: State;
/**
* transform the response data to the api data
*
* @param response Axios response
*/
transform: ResponseTransform<AxiosResponse<ResponseData>, ApiData>;
/**
* transform the response data to the api data
*
* @deprecated use `transform` instead, will be removed in the next major version v3
* @param response Axios response
*/
transformBackendResponse: ResponseTransform<AxiosResponse<ResponseData>, ApiData>;
/**
* The hook before request
*
@@ -35,12 +58,6 @@ export interface RequestOption<ResponseData = any> {
response: AxiosResponse<ResponseData>,
instance: AxiosInstance
) => Promise<AxiosResponse | null> | Promise<void>;
/**
* transform backend response when the responseType is json
*
* @param response Axios response
*/
transformBackendResponse(response: AxiosResponse<ResponseData>): any | Promise<any>;
/**
* The hook to handle error
*
@@ -68,15 +85,7 @@ export type CustomAxiosRequestConfig<R extends ResponseType = 'json'> = Omit<Axi
responseType?: R;
};
export interface RequestInstanceCommon<T> {
/**
* cancel the request by request id
*
* if the request provide abort controller sign from config, it will not collect in the abort controller map
*
* @param requestId
*/
cancelRequest: (requestId: string) => void;
export interface RequestInstanceCommon<State extends Record<string, unknown>> {
/**
* cancel all request
*
@@ -84,32 +93,35 @@ export interface RequestInstanceCommon<T> {
*/
cancelAllRequest: () => void;
/** you can set custom state in the request instance */
state: T;
state: State;
}
/** The request instance */
export interface RequestInstance<S = Record<string, unknown>> extends RequestInstanceCommon<S> {
<T = any, R extends ResponseType = 'json'>(config: CustomAxiosRequestConfig<R>): Promise<MappedType<R, T>>;
export interface RequestInstance<ApiData, State extends Record<string, unknown>> extends RequestInstanceCommon<State> {
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig<R>
): Promise<MappedType<R, T>>;
}
export type FlatResponseSuccessData<T = any, ResponseData = any> = {
data: T;
export type FlatResponseSuccessData<ResponseData, ApiData> = {
data: ApiData;
error: null;
response: AxiosResponse<ResponseData>;
};
export type FlatResponseFailData<ResponseData = any> = {
export type FlatResponseFailData<ResponseData> = {
data: null;
error: AxiosError<ResponseData>;
response: AxiosResponse<ResponseData>;
};
export type FlatResponseData<T = any, ResponseData = any> =
| FlatResponseSuccessData<T, ResponseData>
export type FlatResponseData<ResponseData, ApiData> =
| FlatResponseSuccessData<ResponseData, ApiData>
| FlatResponseFailData<ResponseData>;
export interface FlatRequestInstance<S = Record<string, unknown>, ResponseData = any> extends RequestInstanceCommon<S> {
<T = any, R extends ResponseType = 'json'>(
export interface FlatRequestInstance<ResponseData, ApiData, State extends Record<string, unknown>>
extends RequestInstanceCommon<State> {
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig<R>
): Promise<FlatResponseData<MappedType<R, T>, ResponseData>>;
): Promise<FlatResponseData<ResponseData, MappedType<R, T>>>;
}

View File

@@ -3,9 +3,9 @@ import useLoading from './use-loading';
import useCountDown from './use-count-down';
import useContext from './use-context';
import useSvgIconRender from './use-svg-icon-render';
import useHookTable from './use-table';
import useTable from './use-table';
export { useBoolean, useLoading, useCountDown, useContext, useSvgIconRender, useHookTable };
export { useBoolean, useLoading, useCountDown, useContext, useSvgIconRender, useTable };
export * from './use-signal';
export * from './use-table';
export type * from './use-table';

View File

@@ -6,31 +6,31 @@ import type {
CreateAxiosDefaults,
CustomAxiosRequestConfig,
MappedType,
RequestInstanceCommon,
RequestOption,
ResponseType
} from '@sa/axios';
import useLoading from './use-loading';
export type HookRequestInstanceResponseSuccessData<T = any> = {
data: Ref<T>;
export type HookRequestInstanceResponseSuccessData<ApiData> = {
data: Ref<ApiData>;
error: Ref<null>;
};
export type HookRequestInstanceResponseFailData<ResponseData = any> = {
export type HookRequestInstanceResponseFailData<ResponseData> = {
data: Ref<null>;
error: Ref<AxiosError<ResponseData>>;
};
export type HookRequestInstanceResponseData<T = any, ResponseData = any> = {
export type HookRequestInstanceResponseData<ResponseData, ApiData> = {
loading: Ref<boolean>;
} & (HookRequestInstanceResponseSuccessData<T> | HookRequestInstanceResponseFailData<ResponseData>);
} & (HookRequestInstanceResponseSuccessData<ApiData> | HookRequestInstanceResponseFailData<ResponseData>);
export interface HookRequestInstance<ResponseData = any> {
<T = any, R extends ResponseType = 'json'>(
export interface HookRequestInstance<ResponseData, ApiData, State extends Record<string, unknown>>
extends RequestInstanceCommon<State> {
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig
): HookRequestInstanceResponseData<MappedType<R, T>, ResponseData>;
cancelRequest: (requestId: string) => void;
cancelAllRequest: () => void;
): HookRequestInstanceResponseData<ResponseData, MappedType<R, T>>;
}
/**
@@ -39,25 +39,26 @@ export interface HookRequestInstance<ResponseData = any> {
* @param axiosConfig
* @param options
*/
export default function createHookRequest<ResponseData = any>(
export default function createHookRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
axiosConfig?: CreateAxiosDefaults,
options?: Partial<RequestOption<ResponseData>>
options?: Partial<RequestOption<ResponseData, ApiData, State>>
) {
const request = createFlatRequest<ResponseData>(axiosConfig, options);
const request = createFlatRequest<ResponseData, ApiData, State>(axiosConfig, options);
const hookRequest: HookRequestInstance<ResponseData> = function hookRequest<T = any, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig
) {
const hookRequest: HookRequestInstance<ResponseData, ApiData, State> = function hookRequest<
T extends ApiData = ApiData,
R extends ResponseType = 'json'
>(config: CustomAxiosRequestConfig) {
const { loading, startLoading, endLoading } = useLoading();
const data = ref<MappedType<R, T> | null>(null) as Ref<MappedType<R, T>>;
const error = ref<AxiosError<ResponseData> | null>(null) as Ref<AxiosError<ResponseData> | null>;
const data = ref(null) as Ref<MappedType<R, T>>;
const error = ref(null) as Ref<AxiosError<ResponseData> | null>;
startLoading();
request(config).then(res => {
if (res.data) {
data.value = res.data;
data.value = res.data as MappedType<R, T>;
} else {
error.value = res.error;
}
@@ -70,9 +71,8 @@ export default function createHookRequest<ResponseData = any>(
data,
error
};
} as HookRequestInstance<ResponseData>;
} as HookRequestInstance<ResponseData, ApiData, State>;
hookRequest.cancelRequest = request.cancelRequest;
hookRequest.cancelAllRequest = request.cancelAllRequest;
return hookRequest;

View File

@@ -1,87 +1,85 @@
import { computed, reactive, ref } from 'vue';
import type { Ref } from 'vue';
import { jsonClone } from '@sa/utils';
import { computed, ref } from 'vue';
import type { Ref, VNodeChild } from 'vue';
import useBoolean from './use-boolean';
import useLoading from './use-loading';
export type MaybePromise<T> = T | Promise<T>;
export type ApiFn = (args: any) => Promise<unknown>;
export type TableColumnCheck = {
prop: string;
label: string;
checked: boolean;
};
export type TableDataWithIndex<T> = T & { index: number };
export type TransformedData<T> = {
data: TableDataWithIndex<T>[];
export interface PaginationData<T> {
data: T[];
pageNum: number;
pageSize: number;
total: number;
}
type GetApiData<ApiData, Pagination extends boolean> = Pagination extends true ? PaginationData<ApiData> : ApiData[];
type Transform<ResponseData, ApiData, Pagination extends boolean> = (
response: ResponseData
) => GetApiData<ApiData, Pagination>;
export type TableColumnCheckTitle = string | ((...args: any) => VNodeChild);
export type TableColumnCheck = {
prop: string;
label: TableColumnCheckTitle;
checked: boolean;
visible: boolean;
};
export type Transformer<T, Response> = (response: Response) => TransformedData<T>;
export type TableConfig<A extends ApiFn, T, C> = {
/** api function to get table data */
apiFn: A;
/** api params */
apiParams?: Parameters<A>[0];
/** transform api response to table data */
transformer: Transformer<T, Awaited<ReturnType<A>>>;
/** columns factory */
columns: () => C[];
export interface UseTableOptions<ResponseData, ApiData, Column, Pagination extends boolean> {
/**
* api function to get table data
*/
api: () => Promise<ResponseData>;
/**
* whether to enable pagination
*/
pagination?: Pagination;
/**
* transform api response to table data
*/
transform: Transform<ResponseData, ApiData, Pagination>;
/**
* columns factory
*/
columns: () => Column[];
/**
* get column checks
*
* @param columns
*/
getColumnChecks: (columns: C[]) => TableColumnCheck[];
getColumnChecks: (columns: Column[]) => TableColumnCheck[];
/**
* get columns
*
* @param columns
*/
getColumns: (columns: C[], checks: TableColumnCheck[]) => C[];
getColumns: (columns: Column[], checks: TableColumnCheck[]) => Column[];
/**
* callback when response fetched
*
* @param transformed transformed data
*/
onFetched?: (transformed: TransformedData<T>) => MaybePromise<void>;
onFetched?: (data: GetApiData<ApiData, Pagination>) => void | Promise<void>;
/**
* whether to get data immediately
*
* @default true
*/
immediate?: boolean;
};
}
export default function useHookTable<A extends ApiFn, T, C>(config: TableConfig<A, T, C>) {
export default function useTable<ResponseData, ApiData, Column, Pagination extends boolean>(
options: UseTableOptions<ResponseData, ApiData, Column, Pagination>
) {
const { loading, startLoading, endLoading } = useLoading();
const { bool: empty, setBool: setEmpty } = useBoolean();
const { apiFn, apiParams, transformer, immediate = true, getColumnChecks, getColumns } = config;
const { api, pagination, transform, columns, getColumnChecks, getColumns, onFetched, immediate = true } = options;
const searchParams: NonNullable<Parameters<A>[0]> = reactive(jsonClone({ ...apiParams }));
const data = ref([]) as Ref<ApiData[]>;
const allColumns = ref(config.columns()) as Ref<C[]>;
const columnChecks = ref(getColumnChecks(columns())) as Ref<TableColumnCheck[]>;
const data: Ref<TableDataWithIndex<T>[]> = ref([]);
const columnChecks: Ref<TableColumnCheck[]> = ref(getColumnChecks(config.columns()));
const columns = computed(() => getColumns(allColumns.value, columnChecks.value));
const $columns = computed(() => getColumns(columns(), columnChecks.value));
function reloadColumns() {
allColumns.value = config.columns();
const checkMap = new Map(columnChecks.value.map(col => [col.prop, col.checked]));
const defaultChecks = getColumnChecks(allColumns.value);
const defaultChecks = getColumnChecks(columns());
columnChecks.value = defaultChecks.map(col => ({
...col,
@@ -90,47 +88,21 @@ export default function useHookTable<A extends ApiFn, T, C>(config: TableConfig<
}
async function getData() {
startLoading();
try {
startLoading();
const formattedParams = formatSearchParams(searchParams);
const response = await api();
const response = await apiFn(formattedParams);
const transformed = transform(response);
const transformed = transformer(response as Awaited<ReturnType<A>>);
data.value = getTableData(transformed, pagination);
data.value = transformed.data;
setEmpty(data.value.length === 0);
setEmpty(transformed.data.length === 0);
await config.onFetched?.(transformed);
endLoading();
}
function formatSearchParams(params: Record<string, unknown>) {
const formattedParams: Record<string, unknown> = {};
Object.entries(params).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
formattedParams[key] = value;
}
});
return formattedParams;
}
/**
* update search params
*
* @param params
*/
function updateSearchParams(params: Partial<Parameters<A>[0]>) {
Object.assign(searchParams, params);
}
/** reset search params */
function resetSearchParams() {
Object.assign(searchParams, jsonClone(apiParams));
await onFetched?.(transformed);
} finally {
endLoading();
}
}
if (immediate) {
@@ -141,12 +113,20 @@ export default function useHookTable<A extends ApiFn, T, C>(config: TableConfig<
loading,
empty,
data,
columns,
columns: $columns,
columnChecks,
reloadColumns,
getData,
searchParams,
updateSearchParams,
resetSearchParams
getData
};
}
function getTableData<ApiData, Pagination extends boolean>(
data: GetApiData<ApiData, Pagination>,
pagination?: Pagination
) {
if (pagination) {
return (data as PaginationData<ApiData>).data;
}
return data as ApiData[];
}

View File

@@ -1,8 +1,10 @@
import { computed, effectScope, onScopeDispose, reactive, ref, watch } from 'vue';
import { computed, effectScope, onScopeDispose, reactive, shallowRef, watch } from 'vue';
import type { Ref } from 'vue';
import type { PaginationEmits, PaginationProps } from 'element-plus';
import { useBoolean, useTable } from '@sa/hooks';
import type { PaginationData, TableColumnCheck, UseTableOptions } from '@sa/hooks';
import type { FlatResponseData } from '@sa/axios';
import { jsonClone } from '@sa/utils';
import { useBoolean, useHookTable } from '@sa/hooks';
import { useAppStore } from '@/store/modules/app';
import { $t } from '@/locales';
@@ -10,147 +12,101 @@ type RemoveReadonly<T> = {
-readonly [key in keyof T]: T[key];
};
type TableData = UI.TableData;
type GetTableData<A extends UI.TableApiFn> = UI.GetTableData<A>;
type TableColumn<T> = UI.TableColumn<T>;
export type UseUITableOptions<ResponseData, ApiData, Pagination extends boolean> = Omit<
UseTableOptions<ResponseData, ApiData, UI.TableColumn<ApiData>, Pagination>,
'pagination' | 'getColumnChecks' | 'getColumns'
> & {
/**
* get column visible
*
* @param column
*
* @default true
*
* @returns true if the column is visible, false otherwise
*/
getColumnVisible?: (column: UI.TableColumn<ApiData>) => boolean;
};
export function useTable<A extends UI.TableApiFn>(config: UI.NaiveTableConfig<A>) {
const SELECTION_KEY = '__selection__';
const EXPAND_KEY = '__expand__';
const INDEX_KEY = '__index__';
export function useUITable<ResponseData, ApiData>(options: UseUITableOptions<ResponseData, ApiData, false>) {
const scope = effectScope();
const appStore = useAppStore();
const result = useTable<ResponseData, ApiData, UI.TableColumn<ApiData>, false>({
...options,
getColumnChecks: cols => getColumnChecks(cols, options.getColumnVisible),
getColumns
});
// calculate the total width of the table this is used for horizontal scrolling
const scrollX = computed(() => {
return result.columns.value.reduce((acc, column) => {
return acc + Number(column.width ?? column.minWidth ?? 120);
}, 0);
});
scope.run(() => {
watch(
() => appStore.locale,
() => {
result.reloadColumns();
}
);
});
onScopeDispose(() => {
scope.stop();
});
return {
...result,
scrollX
};
}
type PaginationParams = Pick<PaginationProps, 'currentPage' | 'pageSize'>;
type UseUIPaginatedTableOptions<ResponseData, ApiData> = UseUITableOptions<ResponseData, ApiData, true> & {
paginationProps?: Omit<PaginationProps, 'currentPage' | 'pageSize' | 'total'>;
/**
* whether to show the total count of the table
*
* @default true
*/
showTotal?: boolean;
onPaginationParamsChange?: (params: PaginationParams) => void | Promise<void>;
};
export function useUIPaginatedTable<ResponseData, ApiData>(options: UseUIPaginatedTableOptions<ResponseData, ApiData>) {
const scope = effectScope();
const appStore = useAppStore();
const isMobile = computed(() => appStore.isMobile);
const { apiFn, apiParams, immediate } = config;
const SELECTION_KEY = '__selection__';
const EXPAND_KEY = '__expand__';
const INDEX_KEY = '__index__';
const {
loading,
empty,
data,
columns,
columnChecks,
reloadColumns,
getData,
searchParams,
updateSearchParams,
resetSearchParams
} = useHookTable<A, GetTableData<A>, TableColumn<UI.TableDataWithIndex<GetTableData<A>>>>({
apiFn,
apiParams,
columns: config.columns,
transformer: res => {
const { records = [], current = 1, size = 10, total = 0 } = res.data || {};
// Ensure that the size is greater than 0, If it is less than 0, it will cause paging calculation errors.
const pageSize = size <= 0 ? 10 : size;
const recordsWithIndex = records.map((item, index) => {
return {
...item,
index: (current - 1) * pageSize + index + 1
};
});
return {
data: recordsWithIndex,
pageNum: current,
pageSize,
total
};
},
getColumnChecks: cols => {
const checks: UI.TableColumnCheck[] = [];
cols.forEach(column => {
if (column.type === 'selection') {
checks.push({
prop: SELECTION_KEY,
label: $t('common.check'),
checked: true
});
} else if (column.type === 'expand') {
checks.push({
prop: EXPAND_KEY,
label: $t('common.expandColumn'),
checked: true
});
} else if (column.type === 'index') {
checks.push({
prop: INDEX_KEY,
label: $t('common.index'),
checked: true
});
} else {
checks.push({
prop: column.prop as string,
label: column.label as string,
checked: true
});
}
});
return checks;
},
getColumns: (cols, checks) => {
const columnMap = new Map<string, TableColumn<GetTableData<A>>>();
cols.forEach(column => {
if (column.type === 'selection') {
columnMap.set(SELECTION_KEY, column);
} else if (column.type === 'expand') {
columnMap.set(EXPAND_KEY, column);
} else if (column.type === 'index') {
columnMap.set(INDEX_KEY, column);
} else {
columnMap.set(column.prop as string, column);
}
});
const filteredColumns = checks
.filter(item => item.checked)
.map(check => columnMap.get(check.prop) as TableColumn<GetTableData<A>>);
return filteredColumns;
},
onFetched: async transformed => {
const { pageNum, pageSize, total } = transformed;
updatePagination({
currentPage: pageNum,
pageSize,
total
});
},
immediate
});
const pagination: Partial<RemoveReadonly<PaginationProps & PaginationEmits>> = reactive({
currentPage: 1,
pageSize: 10,
total: 0,
pageSizes: [10, 15, 20, 25, 30],
'current-change': (page: number) => {
pagination.currentPage = page;
updateSearchParams({ current: page, size: pagination.pageSize! });
getData();
return true;
},
'size-change': (pageSize: number) => {
pagination.currentPage = 1;
pagination.pageSize = pageSize;
updateSearchParams({ current: pagination.currentPage, size: pageSize });
getData();
return true;
}
});
},
...options.paginationProps
}) as PaginationProps;
// this is for mobile, if the system does not support mobile, you can use `pagination` directly
const mobilePagination = computed(() => {
@@ -162,35 +118,48 @@ export function useTable<A extends UI.TableApiFn>(config: UI.NaiveTableConfig<A>
return p;
});
function updatePagination(update: Partial<PaginationProps>) {
Object.assign(pagination, update);
}
const paginationParams = computed(() => {
const { currentPage, pageSize } = pagination;
/**
* get data by page number
*
* @param pageNum the page number. default is 1
*/
async function getDataByPage(pageNum: number = 1) {
updatePagination({
currentPage: pageNum
});
return {
currentPage,
pageSize
};
});
updateSearchParams({
current: pageNum,
size: pagination.pageSize!
});
const result = useTable<ResponseData, ApiData, UI.TableColumn<ApiData>, true>({
...options,
pagination: true,
getColumnChecks: cols => getColumnChecks(cols, options.getColumnVisible),
getColumns,
onFetched: data => {
pagination.total = data.total;
}
});
await getData();
async function getDataByPage(page: number = 1) {
if (page !== pagination.currentPage) {
pagination.currentPage = page;
return;
}
await result.getData();
}
scope.run(() => {
watch(
() => appStore.locale,
() => {
reloadColumns();
result.reloadColumns();
}
);
watch(paginationParams, async newVal => {
await options.onPaginationParamsChange?.(newVal);
await result.getData();
});
});
onScopeDispose(() => {
@@ -198,27 +167,21 @@ export function useTable<A extends UI.TableApiFn>(config: UI.NaiveTableConfig<A>
});
return {
loading,
empty,
data,
columns,
columnChecks,
reloadColumns,
pagination,
mobilePagination,
updatePagination,
getData,
...result,
getDataByPage,
searchParams,
updateSearchParams,
resetSearchParams
pagination,
mobilePagination
};
}
export function useTableOperate<T extends TableData = TableData>(data: Ref<T[]>, getData: () => Promise<void>) {
export function useTableOperate<TableData>(
data: Ref<TableData[]>,
idKey: keyof TableData,
getData: () => Promise<void>
) {
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean();
const operateType = ref<UI.TableOperateType>('add');
const operateType = shallowRef<UI.TableOperateType>('add');
function handleAdd() {
operateType.value = 'add';
@@ -226,18 +189,18 @@ export function useTableOperate<T extends TableData = TableData>(data: Ref<T[]>,
}
/** the editing row data */
const editingData: Ref<T | null> = ref(null);
const editingData = shallowRef<TableData | null>(null);
function handleEdit(id: T['id']) {
function handleEdit(id: TableData[keyof TableData]) {
operateType.value = 'edit';
const findItem = data.value.find(item => item.id === id) || null;
const findItem = data.value.find(item => item[idKey] === id) || null;
editingData.value = jsonClone(findItem);
openDrawer();
}
/** the checked row keys of table */
const checkedRowKeys = ref<string[]>([]);
const checkedRowKeys = shallowRef<string[]>([]);
/** the hook after the batch delete operation is completed */
async function onBatchDeleted() {
@@ -268,3 +231,88 @@ export function useTableOperate<T extends TableData = TableData>(data: Ref<T[]>,
onDeleted
};
}
export function defaultTransform<ApiData>(
response: FlatResponseData<any, Api.Common.PaginatingQueryRecord<ApiData>>
): PaginationData<ApiData> {
const { data, error } = response;
if (!error) {
const { records, current, size, total } = data;
return {
data: records,
pageNum: current,
pageSize: size,
total
};
}
return {
data: [],
pageNum: 1,
pageSize: 10,
total: 0
};
}
function getColumnChecks<Column extends UI.TableColumn<any>>(
cols: Column[],
getColumnVisible?: (column: Column) => boolean
) {
const checks: TableColumnCheck[] = [];
cols.forEach(column => {
if (column.type === 'selection') {
checks.push({
prop: SELECTION_KEY,
label: $t('common.check'),
checked: true,
visible: getColumnVisible?.(column) ?? false
});
} else if (column.type === 'expand') {
checks.push({
prop: EXPAND_KEY,
label: $t('common.expandColumn'),
checked: true,
visible: getColumnVisible?.(column) ?? false
});
} else if (column.type === 'index') {
checks.push({
prop: INDEX_KEY,
label: $t('common.index'),
checked: true,
visible: getColumnVisible?.(column) ?? false
});
} else {
checks.push({
prop: column.prop as string,
label: column.label as string,
checked: true,
visible: getColumnVisible?.(column) ?? true
});
}
});
return checks;
}
function getColumns<Column extends UI.TableColumn<any>>(cols: Column[], checks: TableColumnCheck[]) {
const columnMap = new Map<string, Column>();
cols.forEach(column => {
if (column.type === 'selection') {
columnMap.set(SELECTION_KEY, column);
} else if (column.type === 'expand') {
columnMap.set(EXPAND_KEY, column);
} else if (column.type === 'index') {
columnMap.set(INDEX_KEY, column);
} else {
columnMap.set(column.prop as string, column);
}
});
const filteredColumns = checks.filter(item => item.checked).map(check => columnMap.get(check.prop) as Column);
return filteredColumns;
}

View File

@@ -10,7 +10,7 @@ import type { RequestInstanceState } from './type';
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
export const request = createFlatRequest<App.Service.Response, RequestInstanceState>(
export const request = createFlatRequest(
{
baseURL,
headers: {
@@ -18,6 +18,13 @@ export const request = createFlatRequest<App.Service.Response, RequestInstanceSt
}
},
{
defaultState: {
errMsgStack: [],
refreshTokenPromise: null
} as RequestInstanceState,
transform(response: AxiosResponse<App.Service.Response<any>>) {
return response.data.data;
},
async onRequest(config) {
const Authorization = getAuthorization();
Object.assign(config.headers, { Authorization });
@@ -70,19 +77,6 @@ export const request = createFlatRequest<App.Service.Response, RequestInstanceSt
.then(() => {
logoutAndCleanup();
});
// window.$dialog?.error({
// title: $t('common.error'),
// content: response.data.msg,
// positiveText: $t('common.confirm'),
// maskClosable: false,
// closeOnEsc: false,
// onPositiveClick() {
// logoutAndCleanup();
// },
// onClose() {
// logoutAndCleanup();
// }
// });
return null;
}
@@ -102,9 +96,6 @@ export const request = createFlatRequest<App.Service.Response, RequestInstanceSt
return null;
},
transformBackendResponse(response) {
return response.data.data;
},
onError(error) {
// when the request is fail, you can show error message
@@ -134,11 +125,14 @@ export const request = createFlatRequest<App.Service.Response, RequestInstanceSt
}
);
export const demoRequest = createRequest<App.Service.DemoResponse>(
export const demoRequest = createRequest(
{
baseURL: otherBaseURL.demo
},
{
transform(response: AxiosResponse<App.Service.DemoResponse>) {
return response.data.result;
},
async onRequest(config) {
const { headers } = config;
@@ -158,9 +152,6 @@ export const demoRequest = createRequest<App.Service.DemoResponse>(
// when the backend response code is not "200", it means the request is fail
// for example: the token is expired, refresh token and retry request
},
transformBackendResponse(response) {
return response.data.result;
},
onError(error) {
// when the request is fail, you can show error message

View File

@@ -1,6 +1,7 @@
export interface RequestInstanceState {
/** whether the request is refreshing token */
refreshTokenFn: Promise<boolean> | null;
/** the promise of refreshing token */
refreshTokenPromise: Promise<boolean> | null;
/** the request error message stack */
errMsgStack: string[];
[key: string]: unknown;
}

20
src/typings/api/auth.d.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
declare namespace Api {
/**
* namespace Auth
*
* backend api module: "auth"
*/
namespace Auth {
interface LoginToken {
token: string;
refreshToken: string;
}
interface UserInfo {
userId: string;
userName: string;
roles: string[];
buttons: string[];
}
}
}

50
src/typings/api/common.d.ts vendored Normal file
View File

@@ -0,0 +1,50 @@
/**
* Namespace Api
*
* All backend api type
*/
declare namespace Api {
namespace Common {
/** common params of paginating */
interface PaginatingCommonParams {
/** current page number */
current: number;
/** page size */
size: number;
/** total count */
total: number;
}
/** common params of paginating query list data */
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
records: T[];
}
/** common search params of table */
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'current' | 'size'>;
/**
* enable status
*
* - "1": enabled
* - "2": disabled
*/
type EnableStatus = '1' | '2';
/** common record */
type CommonRecord<T = any> = {
/** record id */
id: number;
/** record creator */
createBy: string;
/** record create time */
createTime: string;
/** record updater */
updateBy: string;
/** record update time */
updateTime: string;
/** record status */
status: EnableStatus | undefined;
} & T;
}
}

19
src/typings/api/route.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
declare namespace Api {
/**
* namespace Route
*
* backend api module: "route"
*/
namespace Route {
type ElegantConstRoute = import('@elegant-router/types').ElegantConstRoute;
interface MenuRoute extends ElegantConstRoute {
id: string;
}
interface UserRoute {
routes: MenuRoute[];
home: import('@elegant-router/types').LastLevelRouteKey;
}
}
}

View File

@@ -1,90 +1,4 @@
/**
* Namespace Api
*
* All backend api type
*/
declare namespace Api {
namespace Common {
/** common params of paginating */
interface PaginatingCommonParams {
/** current page number */
current: number;
/** page size */
size: number;
/** total count */
total: number;
}
/** common params of paginating query list data */
interface PaginatingQueryRecord<T = any> extends PaginatingCommonParams {
records: T[];
}
/** common search params of table */
type CommonSearchParams = Pick<Common.PaginatingCommonParams, 'current' | 'size'>;
/**
* enable status
*
* - "1": enabled
* - "2": disabled
*/
type EnableStatus = '1' | '2';
/** common record */
type CommonRecord<T = any> = {
/** record id */
id: number;
/** record creator */
createBy: string;
/** record create time */
createTime: string;
/** record updater */
updateBy: string;
/** record update time */
updateTime: string;
/** record status */
status: EnableStatus | undefined;
} & T;
}
/**
* namespace Auth
*
* backend api module: "auth"
*/
namespace Auth {
interface LoginToken {
token: string;
refreshToken: string;
}
interface UserInfo {
userId: string;
userName: string;
roles: string[];
buttons: string[];
}
}
/**
* namespace Route
*
* backend api module: "route"
*/
namespace Route {
type ElegantConstRoute = import('@elegant-router/types').ElegantConstRoute;
interface MenuRoute extends ElegantConstRoute {
id: string;
}
interface UserRoute {
routes: MenuRoute[];
home: import('@elegant-router/types').LastLevelRouteKey;
}
}
/**
* namespace SystemManage
*

37
src/typings/ui.d.ts vendored
View File

@@ -1,28 +1,15 @@
declare namespace UI {
type ThemeColor = 'danger' | 'primary' | 'info' | 'success' | 'warning';
type DataTableBaseColumn<T> = Partial<import('element-plus').TableColumnCtx<T extends object ? T : never>>;
type TableColumnCheck = import('@sa/hooks').TableColumnCheck;
type TableDataWithIndex<T> = import('@sa/hooks').TableDataWithIndex<T>;
type FlatResponseData<T> = import('@sa/axios').FlatResponseData<T>;
/**
* the custom column key
*
* if you want to add a custom column, you should add a key to this type
*/
type CustomColumnKey = 'operate';
type SetTableColumnKey<C, T> = Omit<C, 'prop'> & { prop?: keyof T | (string & {}) };
type SetTableColumnKey<C, T> = Omit<C, 'key'> & { key: keyof T | `CustomColumnKey` };
type TableColumnWithKey<T> = SetTableColumnKey<DataTableBaseColumn<T>, T>;
type TableData = Api.Common.CommonRecord<object>;
type TableColumnWithKey<T> = Partial<import('element-plus').TableColumnCtx<T>>;
type TableColumn<T> = TableColumnWithKey<T>;
type TableApiFn<T = any, R = Api.Common.CommonSearchParams> = (
params: R
) => Promise<FlatResponseData<Api.Common.PaginatingQueryRecord<T>>>;
type TableColumn<T> = DataTableBaseColumn<T>;
/**
* the type of table operation
@@ -31,20 +18,6 @@ declare namespace UI {
* - edit: edit table item
*/
type TableOperateType = 'add' | 'edit';
type GetTableData<A extends TableApiFn> = A extends TableApiFn<infer T> ? T : never;
type NaiveTableConfig<A extends TableApiFn> = Pick<
import('@sa/hooks').TableConfig<A, GetTableData<A>, TableColumn<TableDataWithIndex<GetTableData<A>>>>,
'apiFn' | 'apiParams' | 'columns' | 'immediate'
> & {
/**
* whether to display the total items count
*
* @default false
*/
showTotal?: boolean;
};
}
// ======================================== element-plus ========================================

View File

@@ -28,25 +28,29 @@ export default function useCheckedColumns<
checks.push({
prop: SELECTION_KEY,
label: $t('common.check'),
checked: true
checked: true,
visible: true
});
} else if (column.type === 'expand') {
checks.push({
prop: EXPAND_KEY,
label: $t('common.expandColumn'),
checked: true
checked: true,
visible: true
});
} else if (column.type === 'index') {
checks.push({
prop: INDEX_KEY,
label: $t('common.index'),
checked: true
checked: true,
visible: true
});
} else {
checks.push({
prop: column.prop as string,
label: column.label as string,
checked: true
checked: true,
visible: true
});
}
});

View File

@@ -4,7 +4,7 @@ import { useBoolean } from '@sa/hooks';
import { jsonClone } from '@sa/utils';
import { $t } from '@/locales';
type TableData = UI.TableData;
type TableData = Api.Common.CommonRecord<object>;
interface Operations<T> {
delete?: (row: T) => Promise<void>;
batchDelete?: (rows: T[]) => Promise<void>;

View File

@@ -6,7 +6,7 @@ import { useBoolean } from '@sa/hooks';
import { yesOrNoRecord } from '@/constants/common';
import { enableStatusRecord, menuTypeRecord } from '@/constants/business';
import { fetchGetAllPages, fetchGetMenuList } from '@/service/api';
import { useTable, useTableOperate } from '@/hooks/common/table';
import { defaultTransform, useTableOperate, useUIPaginatedTable } from '@/hooks/common/table';
import { $t } from '@/locales';
import SvgIcon from '@/components/custom/svg-icon.vue';
import MenuOperateModal, { type OperateType } from './modules/menu-operate-modal.vue';
@@ -15,8 +15,9 @@ const { bool: visible, setTrue: openModal } = useBoolean();
const wrapperRef = ref<HTMLElement | null>(null);
const { columns, columnChecks, data, loading, pagination, getData, getDataByPage } = useTable({
apiFn: fetchGetMenuList,
const { columns, columnChecks, data, loading, pagination, getData, getDataByPage } = useUIPaginatedTable({
api: () => fetchGetMenuList(),
transform: response => defaultTransform(response),
columns: () => [
{ type: 'selection', width: 48 },
{ prop: 'id', label: $t('page.manage.menu.id') },
@@ -132,7 +133,7 @@ const { columns, columnChecks, data, loading, pagination, getData, getDataByPage
]
});
const { checkedRowKeys, onBatchDeleted, onDeleted } = useTableOperate(data, getData);
const { checkedRowKeys, onBatchDeleted, onDeleted } = useTableOperate(data, 'id', getData);
const operateType = ref<OperateType>('add');

View File

@@ -1,34 +1,31 @@
<script setup lang="tsx">
import { reactive } from 'vue';
import { ElButton, ElPopconfirm, ElTag } from 'element-plus';
import { enableStatusRecord } from '@/constants/business';
import { fetchGetRoleList } from '@/service/api';
import { useTable, useTableOperate } from '@/hooks/common/table';
import { defaultTransform, useTableOperate, useUIPaginatedTable } from '@/hooks/common/table';
import { $t } from '@/locales';
import RoleOperateDrawer from './modules/role-operate-drawer.vue';
import RoleSearch from './modules/role-search.vue';
const {
columns,
columnChecks,
data,
loading,
getData,
getDataByPage,
mobilePagination,
searchParams,
resetSearchParams
} = useTable({
apiFn: fetchGetRoleList,
apiParams: {
current: 1,
size: 10,
status: undefined,
roleName: undefined,
roleCode: undefined
const searchParams: Api.SystemManage.RoleSearchParams = reactive({
current: 1,
size: 10,
status: undefined,
roleName: undefined,
roleCode: undefined
});
const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable({
api: () => fetchGetRoleList(searchParams),
transform: response => defaultTransform(response),
onPaginationParamsChange: params => {
searchParams.current = params.currentPage;
searchParams.size = params.pageSize;
},
columns: () => [
{ type: 'selection', width: 48 },
{ prop: 'index', label: $t('common.index'), width: 64 },
{ type: 'index', label: $t('common.index'), width: 64 },
{ prop: 'roleName', label: $t('page.manage.role.roleName'), minWidth: 120 },
{ prop: 'roleCode', label: $t('page.manage.role.roleCode'), minWidth: 120 },
{ prop: 'roleDesc', label: $t('page.manage.role.roleDesc'), minWidth: 120 },
@@ -85,7 +82,7 @@ const {
onBatchDeleted,
onDeleted
// closeDrawer
} = useTableOperate(data, getData);
} = useTableOperate(data, 'id', getData);
async function handleBatchDelete() {
// eslint-disable-next-line no-console
@@ -104,6 +101,14 @@ function handleDelete(id: number) {
onDeleted();
}
function resetSearchParams() {
searchParams.current = 1;
searchParams.size = 10;
searchParams.status = undefined;
searchParams.roleName = undefined;
searchParams.roleCode = undefined;
}
function edit(id: number) {
handleEdit(id);
}

View File

@@ -1,40 +1,36 @@
<script setup lang="tsx">
import { reactive } from 'vue';
import { ElButton, ElPopconfirm, ElTag } from 'element-plus';
import { enableStatusRecord, userGenderRecord } from '@/constants/business';
import { fetchGetUserList } from '@/service/api';
import { useTable, useTableOperate } from '@/hooks/common/table';
import { defaultTransform, useTableOperate, useUIPaginatedTable } from '@/hooks/common/table';
import { $t } from '@/locales';
import UserOperateDrawer from './modules/user-operate-drawer.vue';
import UserSearch from './modules/user-search.vue';
defineOptions({ name: 'UserManage' });
const {
columns,
columnChecks,
data,
getData,
getDataByPage,
loading,
mobilePagination,
searchParams,
resetSearchParams
} = useTable({
apiFn: fetchGetUserList,
showTotal: true,
apiParams: {
current: 1,
size: 10,
status: undefined,
userName: undefined,
userGender: undefined,
nickName: undefined,
userPhone: undefined,
userEmail: undefined
const searchParams: Api.SystemManage.UserSearchParams = reactive({
current: 1,
size: 10,
status: undefined,
userName: undefined,
userGender: undefined,
nickName: undefined,
userPhone: undefined,
userEmail: undefined
});
const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination } = useUIPaginatedTable({
api: () => fetchGetUserList(searchParams),
transform: response => defaultTransform(response),
onPaginationParamsChange: params => {
searchParams.current = params.currentPage;
searchParams.size = params.pageSize;
},
columns: () => [
{ type: 'selection', width: 48 },
{ prop: 'index', label: $t('common.index'), width: 64 },
{ type: 'index', label: $t('common.index'), width: 64 },
{ prop: 'userName', label: $t('page.manage.user.userName'), minWidth: 100 },
{
prop: 'userGender',
@@ -111,7 +107,7 @@ const {
onBatchDeleted,
onDeleted
// closeDrawer
} = useTableOperate(data, getData);
} = useTableOperate(data, 'id', getData);
async function handleBatchDelete() {
// eslint-disable-next-line no-console
@@ -129,6 +125,17 @@ function handleDelete(id: number) {
onDeleted();
}
function resetSearchParams() {
searchParams.current = 1;
searchParams.size = 10;
searchParams.status = undefined;
searchParams.userName = undefined;
searchParams.userGender = undefined;
searchParams.nickName = undefined;
searchParams.userPhone = undefined;
searchParams.userEmail = undefined;
}
function edit(id: number) {
handleEdit(id);
}

View File

@@ -1,29 +1,35 @@
<script setup lang="tsx">
import { reactive } from 'vue';
import { ElButton, ElTag } from 'element-plus';
import { utils, writeFile } from 'xlsx';
import { enableStatusRecord, userGenderRecord } from '@/constants/business';
import { fetchGetUserList } from '@/service/api';
import { useTable } from '@/hooks/common/table';
import { defaultTransform, useUIPaginatedTable } from '@/hooks/common/table';
import { $t } from '@/locales';
defineOptions({ name: 'ExcelPage' });
const { columns, data, loading } = useTable({
apiFn: fetchGetUserList,
showTotal: true,
apiParams: {
current: 1,
size: 999,
status: undefined,
userName: undefined,
userGender: undefined,
nickName: undefined,
userPhone: undefined,
userEmail: undefined
const searchParams: Api.SystemManage.UserSearchParams = reactive({
current: 1,
size: 10,
status: undefined,
userName: undefined,
userGender: undefined,
nickName: undefined,
userPhone: undefined,
userEmail: undefined
});
const { columns, data, loading } = useUIPaginatedTable({
api: () => fetchGetUserList(searchParams),
transform: response => defaultTransform(response),
onPaginationParamsChange: params => {
searchParams.current = params.currentPage;
searchParams.size = params.pageSize;
},
columns: () => [
{ type: 'selection', width: 48 },
{ prop: 'index', label: $t('common.index'), width: 64 },
{ type: 'index', label: $t('common.index'), width: 64 },
{ prop: 'userName', label: $t('page.manage.user.userName'), minWidth: 100 },
{
prop: 'userGender',
@@ -91,10 +97,7 @@ function exportExcel() {
writeFile(workBook, '用户数据.xlsx');
}
function getTableValue(
col: UI.TableColumn<UI.TableDataWithIndex<Api.SystemManage.User>>,
item: UI.TableDataWithIndex<Api.SystemManage.User>
) {
function getTableValue(col: UI.TableColumn<Api.SystemManage.User>, item: Api.SystemManage.User) {
if (!isTableColumnHasKey(col)) {
return '';
}
@@ -118,19 +121,17 @@ function getTableValue(
}
if (prop in item) {
return item[prop as keyof UI.TableDataWithIndex<Api.SystemManage.User>];
return item[prop as keyof Api.SystemManage.User];
}
return '';
}
function isTableColumnHasKey<T>(column: UI.TableColumn<T>): column is UI.TableColumnWithKey<T> {
function isTableColumnHasKey<T>(column: UI.TableColumn<T>): boolean {
return Boolean((column as UI.TableColumnWithKey<T>).prop);
}
function isTableColumnHasTitle<T>(column: UI.TableColumn<T>): column is UI.TableColumnWithKey<T> & {
label: string;
} {
function isTableColumnHasTitle<T>(column: UI.TableColumn<T>): boolean {
return Boolean((column as UI.TableColumnWithKey<T>).label);
}
</script>