@kine-design/crud 0.0.1-beta.2 → 0.0.1-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.vlaude/last-session-id +1 -0
- package/components/crudPage/KCrudPage.tsx +178 -0
- package/components/crudPage/crudPage.css +64 -0
- package/components/crudPage/index.ts +10 -0
- package/components/editableTable/KEditableTable.tsx +281 -0
- package/components/editableTable/editableTable.css +268 -0
- package/components/editableTable/index.ts +10 -0
- package/components/formPage/KApprovalDialog.tsx +142 -0
- package/components/formPage/KFormCard.tsx +65 -0
- package/components/formPage/KFormPage.tsx +128 -0
- package/components/formPage/KMasterDetailPage.tsx +205 -0
- package/components/formPage/KStickyActionBar.tsx +33 -0
- package/components/formPage/formPage.css +629 -0
- package/components/formPage/index.ts +14 -0
- package/components/layout/KContent.tsx +20 -0
- package/components/layout/KHeader.tsx +37 -0
- package/components/layout/KLayout.tsx +82 -0
- package/components/layout/KSider.tsx +80 -0
- package/components/layout/index.ts +18 -0
- package/components/layout/layout.css +262 -0
- package/components/login/KLoginPage.tsx +129 -0
- package/components/login/index.ts +10 -0
- package/components/login/login.css +118 -0
- package/components/navMenu/KNavMenu.tsx +175 -0
- package/components/navMenu/index.ts +2 -0
- package/components/navMenu/navMenu.css +197 -0
- package/components/pageHeader/KPageHeader.tsx +85 -0
- package/components/pageHeader/index.ts +9 -0
- package/components/pageHeader/pageHeader.css +93 -0
- package/components/searchTable/KSearchTable.tsx +138 -0
- package/components/searchTable/index.ts +10 -0
- package/components/searchTable/searchTable.css +121 -0
- package/components/upload/KFileList.tsx +95 -0
- package/components/upload/KImageUpload.tsx +286 -0
- package/components/upload/KUpload.tsx +206 -0
- package/components/upload/index.ts +13 -0
- package/components/upload/types.ts +26 -0
- package/components/upload/upload.css +345 -0
- package/composables/auth/authGuard.ts +128 -0
- package/composables/auth/index.ts +23 -0
- package/composables/auth/types.ts +109 -0
- package/composables/auth/useAuth.ts +278 -0
- package/composables/auth/vCan.ts +95 -0
- package/composables/defineRepository.ts +224 -0
- package/composables/error/createErrorHandler.ts +46 -0
- package/composables/error/defaultFeedbackHandler.ts +76 -0
- package/composables/error/dispatchError.ts +70 -0
- package/composables/error/index.ts +32 -0
- package/composables/error/types.ts +57 -0
- package/composables/error/useErrorHandler.ts +41 -0
- package/composables/form/index.ts +18 -0
- package/composables/form/renderFormField.tsx +119 -0
- package/composables/form/types.ts +129 -0
- package/composables/form/useFormPage.ts +183 -0
- package/composables/index.ts +62 -0
- package/composables/page/index.ts +11 -0
- package/composables/page/types.ts +62 -0
- package/composables/page/useCrudPage.ts +88 -0
- package/composables/request/composables.ts +206 -0
- package/composables/request/controlGate.ts +143 -0
- package/composables/request/createRequest.ts +173 -0
- package/composables/request/index.ts +71 -0
- package/composables/request/orchestrator.ts +145 -0
- package/composables/request/requestBuilder.ts +418 -0
- package/composables/request/transport/fetchTransport.ts +79 -0
- package/composables/request/transport/xhrTransport.ts +100 -0
- package/composables/request/types.ts +226 -0
- package/composables/request/upload.ts +146 -0
- package/composables/router/createRouterGuard.ts +134 -0
- package/composables/router/defineCrudRoutes.ts +116 -0
- package/composables/router/index.ts +22 -0
- package/composables/router/types.ts +128 -0
- package/composables/router/useMenuFromRoutes.ts +109 -0
- package/composables/router/useTabStore.ts +183 -0
- package/composables/search/index.ts +11 -0
- package/composables/search/useAutoCompleteSearch.ts +161 -0
- package/composables/setupCrud.ts +43 -0
- package/composables/storage/createStorageAdapter.ts +72 -0
- package/composables/storage/index.ts +13 -0
- package/composables/storage/types.ts +30 -0
- package/composables/storage/useStorage.ts +108 -0
- package/composables/store/defineUserStore.ts +122 -0
- package/composables/store/index.ts +11 -0
- package/composables/types.ts +118 -0
- package/dist/components/crudPage/KCrudPage.d.ts +14 -0
- package/dist/components/crudPage/index.d.ts +9 -0
- package/dist/components/editableTable/KEditableTable.d.ts +146 -0
- package/dist/components/editableTable/index.d.ts +10 -0
- package/dist/components/formPage/KApprovalDialog.d.ts +99 -0
- package/dist/components/formPage/KFormCard.d.ts +49 -0
- package/dist/components/formPage/KFormPage.d.ts +14 -0
- package/dist/components/formPage/KMasterDetailPage.d.ts +14 -0
- package/dist/components/formPage/KStickyActionBar.d.ts +16 -0
- package/dist/components/formPage/index.d.ts +14 -0
- package/dist/components/layout/KLayout.d.ts +7 -4
- package/dist/composables/auth/useAuth.d.ts +5 -5
- package/dist/composables/error/types.d.ts +2 -1
- package/dist/composables/form/index.d.ts +12 -0
- package/dist/composables/form/renderFormField.d.ts +11 -0
- package/dist/composables/form/types.d.ts +104 -0
- package/dist/composables/form/useFormPage.d.ts +38 -0
- package/dist/composables/index.d.ts +2 -0
- package/dist/composables/page/index.d.ts +10 -0
- package/dist/composables/page/types.d.ts +61 -0
- package/dist/composables/page/useCrudPage.d.ts +14 -0
- package/dist/composables/request/createRequest.d.ts +2 -0
- package/dist/composables/request/requestBuilder.d.ts +2 -0
- package/dist/composables/search/index.d.ts +10 -0
- package/dist/composables/search/useAutoCompleteSearch.d.ts +50 -0
- package/dist/crud.css +2499 -663
- package/dist/crud.js +11512 -2910
- package/dist/index.d.ts +11 -0
- package/dist/setup.d.ts +2 -2
- package/index.ts +144 -0
- package/package.json +20 -19
- package/setup.ts +288 -0
- package/tsconfig.json +12 -0
- package/vite.config.build.ts +52 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @description useFormPage — 表单页数据管理 hook
|
|
3
|
+
* @author 阿怪
|
|
4
|
+
* @date 2026/3/22
|
|
5
|
+
* @version v0.0.1
|
|
6
|
+
*
|
|
7
|
+
* 江湖的业务千篇一律,复杂的代码好几百行。
|
|
8
|
+
*
|
|
9
|
+
* 管理表单页的数据加载、字段状态、校验、提交。
|
|
10
|
+
* KFormPage 和 KMasterDetailPage 共用。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { ref, reactive, computed, onMounted, type Ref } from 'vue';
|
|
14
|
+
import { useRoute, useRouter } from 'vue-router';
|
|
15
|
+
import { useRequestClient } from '../../setup';
|
|
16
|
+
import type { FormFieldConfig } from './types';
|
|
17
|
+
|
|
18
|
+
export interface UseFormPageOptions {
|
|
19
|
+
/** API 路径 */
|
|
20
|
+
api: string;
|
|
21
|
+
/** 字段配置 */
|
|
22
|
+
fields: FormFieldConfig[];
|
|
23
|
+
/** 主键字段 */
|
|
24
|
+
rowKey?: string;
|
|
25
|
+
/** 保存成功后跳转路径 */
|
|
26
|
+
redirectPath?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UseFormPageReturn {
|
|
30
|
+
/** 表单数据 */
|
|
31
|
+
formData: Record<string, unknown>;
|
|
32
|
+
/** 是否编辑模式(路由有 id) */
|
|
33
|
+
isEdit: Ref<boolean>;
|
|
34
|
+
/** 加载中 */
|
|
35
|
+
loading: Ref<boolean>;
|
|
36
|
+
/** 提交中 */
|
|
37
|
+
submitting: Ref<boolean>;
|
|
38
|
+
/** 字段错误信息 */
|
|
39
|
+
errors: Ref<Record<string, string>>;
|
|
40
|
+
/** 校验全部字段 */
|
|
41
|
+
validate: () => boolean;
|
|
42
|
+
/** 校验单个字段 */
|
|
43
|
+
validateField: (param: string) => string | undefined;
|
|
44
|
+
/** 提交表单 */
|
|
45
|
+
submit: (extraData?: Record<string, unknown>) => Promise<boolean>;
|
|
46
|
+
/** 保存草稿(不校验必填) */
|
|
47
|
+
saveDraft: (extraData?: Record<string, unknown>) => Promise<boolean>;
|
|
48
|
+
/** 返回上一页 */
|
|
49
|
+
goBack: () => void;
|
|
50
|
+
/** 路由实例 */
|
|
51
|
+
router: ReturnType<typeof useRouter>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function useFormPage(options: UseFormPageOptions): UseFormPageReturn {
|
|
55
|
+
const { api, fields, rowKey = 'id', redirectPath } = options;
|
|
56
|
+
|
|
57
|
+
const route = useRoute();
|
|
58
|
+
const router = useRouter();
|
|
59
|
+
const client = useRequestClient();
|
|
60
|
+
|
|
61
|
+
const id = computed(() => route.params[rowKey] as string | undefined);
|
|
62
|
+
const isEdit = computed(() => !!id.value);
|
|
63
|
+
|
|
64
|
+
const loading = ref(false);
|
|
65
|
+
const submitting = ref(false);
|
|
66
|
+
const errors = ref<Record<string, string>>({});
|
|
67
|
+
|
|
68
|
+
// 初始化表单数据
|
|
69
|
+
const formData: Record<string, unknown> = reactive(
|
|
70
|
+
Object.fromEntries(fields.map(f => [f.param, f.type === 'switch' ? false : '']))
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// ── 数据加载(编辑模式) ──────────────────────────
|
|
74
|
+
|
|
75
|
+
const loadData = async () => {
|
|
76
|
+
if (!id.value) return;
|
|
77
|
+
loading.value = true;
|
|
78
|
+
try {
|
|
79
|
+
const res = await client.get<any>(`${api}/${id.value}`);
|
|
80
|
+
const data = res?.data?.data ?? res?.data ?? res;
|
|
81
|
+
if (data && typeof data === 'object') {
|
|
82
|
+
for (const key of Object.keys(formData)) {
|
|
83
|
+
if (key in data) {
|
|
84
|
+
formData[key] = data[key];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} finally {
|
|
89
|
+
loading.value = false;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
onMounted(() => {
|
|
94
|
+
if (isEdit.value) loadData();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── 校验 ──────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
const validateField = (param: string): string | undefined => {
|
|
100
|
+
const field = fields.find(f => f.param === param);
|
|
101
|
+
if (!field) return;
|
|
102
|
+
|
|
103
|
+
const value = formData[param];
|
|
104
|
+
|
|
105
|
+
// 自定义校验
|
|
106
|
+
if (field.validator) {
|
|
107
|
+
const msg = field.validator(value, formData);
|
|
108
|
+
if (msg) {
|
|
109
|
+
errors.value[param] = msg;
|
|
110
|
+
return msg;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 必填校验
|
|
115
|
+
if (field.required) {
|
|
116
|
+
if (value === undefined || value === null || value === '') {
|
|
117
|
+
const msg = `请输入${field.label}`;
|
|
118
|
+
errors.value[param] = msg;
|
|
119
|
+
return msg;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 清除错误
|
|
124
|
+
delete errors.value[param];
|
|
125
|
+
return undefined;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const validate = (): boolean => {
|
|
129
|
+
let valid = true;
|
|
130
|
+
for (const field of fields) {
|
|
131
|
+
const msg = validateField(field.param);
|
|
132
|
+
if (msg) valid = false;
|
|
133
|
+
}
|
|
134
|
+
return valid;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// ── 提交 ──────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
const doSubmit = async (skipRequiredCheck: boolean, extraData?: Record<string, unknown>): Promise<boolean> => {
|
|
140
|
+
if (!skipRequiredCheck && !validate()) return false;
|
|
141
|
+
|
|
142
|
+
submitting.value = true;
|
|
143
|
+
try {
|
|
144
|
+
const payload = { ...formData, ...extraData };
|
|
145
|
+
|
|
146
|
+
if (isEdit.value) {
|
|
147
|
+
await client.put(`${api}/${id.value}`, payload);
|
|
148
|
+
} else {
|
|
149
|
+
await client.post(api, payload);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (redirectPath) {
|
|
153
|
+
router.push(redirectPath);
|
|
154
|
+
} else {
|
|
155
|
+
router.back();
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
} finally {
|
|
161
|
+
submitting.value = false;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const submit = (extraData?: Record<string, unknown>) => doSubmit(false, extraData);
|
|
166
|
+
const saveDraft = (extraData?: Record<string, unknown>) => doSubmit(true, { ...extraData, status: 'draft' });
|
|
167
|
+
|
|
168
|
+
const goBack = () => router.back();
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
formData,
|
|
172
|
+
isEdit,
|
|
173
|
+
loading,
|
|
174
|
+
submitting,
|
|
175
|
+
errors,
|
|
176
|
+
validate,
|
|
177
|
+
validateField,
|
|
178
|
+
submit,
|
|
179
|
+
saveDraft,
|
|
180
|
+
goBack,
|
|
181
|
+
router,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @description composables barrel export
|
|
3
|
+
* @author 阿怪
|
|
4
|
+
* @date 2026/2/26
|
|
5
|
+
* @version v0.0.1
|
|
6
|
+
*
|
|
7
|
+
* 江湖的业务千篇一律,复杂的代码好几百行。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { defineRepository } from './defineRepository';
|
|
11
|
+
|
|
12
|
+
export { useAutoCompleteSearch } from './search';
|
|
13
|
+
export type { AutoCompleteSearchOptions, AutoCompleteSearchReturn } from './search';
|
|
14
|
+
export { setupCrud } from './setupCrud';
|
|
15
|
+
export type {
|
|
16
|
+
EntityId,
|
|
17
|
+
ListParams,
|
|
18
|
+
MutationOptions,
|
|
19
|
+
Repository,
|
|
20
|
+
RepositoryEndpoints,
|
|
21
|
+
UseListReturn,
|
|
22
|
+
UseDetailReturn,
|
|
23
|
+
UseMutationReturn,
|
|
24
|
+
} from './types';
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
defineCrudRoutes,
|
|
28
|
+
createTabStore,
|
|
29
|
+
useTabStore,
|
|
30
|
+
useMenuFromRoutes,
|
|
31
|
+
createRouterGuard,
|
|
32
|
+
addTabFromRoute,
|
|
33
|
+
} from './router';
|
|
34
|
+
export type {
|
|
35
|
+
CrudRouteMeta,
|
|
36
|
+
CrudRouteConfig,
|
|
37
|
+
CrudRouteRecord,
|
|
38
|
+
TabItem,
|
|
39
|
+
TabStore,
|
|
40
|
+
RouteLocationLike,
|
|
41
|
+
RouterGuard,
|
|
42
|
+
RouterGuardOptions,
|
|
43
|
+
} from './router';
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
createAuth,
|
|
47
|
+
useAuth,
|
|
48
|
+
createAuthGuard,
|
|
49
|
+
createVCan,
|
|
50
|
+
} from './auth';
|
|
51
|
+
export type {
|
|
52
|
+
Permission,
|
|
53
|
+
PermissionScope,
|
|
54
|
+
UserInfo,
|
|
55
|
+
AuthState,
|
|
56
|
+
AuthOptions,
|
|
57
|
+
AuthReturn,
|
|
58
|
+
AuthGuardOptions,
|
|
59
|
+
BeforeEachGuard,
|
|
60
|
+
CanBinding,
|
|
61
|
+
CanBindingObject,
|
|
62
|
+
} from './auth';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @description CrudPage composable barrel export
|
|
3
|
+
* @author 阿怪
|
|
4
|
+
* @date 2026/3/22
|
|
5
|
+
* @version v0.0.1
|
|
6
|
+
*
|
|
7
|
+
* 江湖的业务千篇一律,复杂的代码好几百行。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { useCrudPage } from './useCrudPage';
|
|
11
|
+
export type { CrudPageConfig, CrudColumnConfig, CrudFilterConfig, StatusMapItem } from './types';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @description CrudPage 配置驱动页面类型定义
|
|
3
|
+
* @author 阿怪
|
|
4
|
+
* @date 2026/3/22
|
|
5
|
+
* @version v0.0.1
|
|
6
|
+
*
|
|
7
|
+
* 江湖的业务千篇一律,复杂的代码好几百行。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** 列配置 */
|
|
11
|
+
export interface CrudColumnConfig {
|
|
12
|
+
/** 字段名,对应数据对象的 key */
|
|
13
|
+
param: string;
|
|
14
|
+
/** 列标题 */
|
|
15
|
+
label: string;
|
|
16
|
+
/** 列宽 */
|
|
17
|
+
width?: string;
|
|
18
|
+
/** 列类型,影响渲染方式 */
|
|
19
|
+
type?: 'text' | 'status' | 'date' | 'datetime' | 'image';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 筛选项配置 */
|
|
23
|
+
export interface CrudFilterConfig {
|
|
24
|
+
/** 字段名,作为请求参数的 key */
|
|
25
|
+
param: string;
|
|
26
|
+
/** 筛选项标签 */
|
|
27
|
+
label: string;
|
|
28
|
+
/** 筛选项类型 */
|
|
29
|
+
type: 'input' | 'select';
|
|
30
|
+
/** select 类型的选项列表 */
|
|
31
|
+
options?: { label: string; value: string | number }[];
|
|
32
|
+
/** 占位文本 */
|
|
33
|
+
placeholder?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 状态映射:字段值 → KTag 的 label + type */
|
|
37
|
+
export type StatusMapItem = {
|
|
38
|
+
label: string;
|
|
39
|
+
type?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** CrudPage 完整配置 */
|
|
43
|
+
export interface CrudPageConfig {
|
|
44
|
+
/** 页面标题 */
|
|
45
|
+
title: string;
|
|
46
|
+
/** API 路径(基于 createRequest 的 baseURL) */
|
|
47
|
+
api: string;
|
|
48
|
+
/** 列定义 */
|
|
49
|
+
columns: CrudColumnConfig[];
|
|
50
|
+
/** 筛选项定义,为空则不显示搜索区 */
|
|
51
|
+
filters?: CrudFilterConfig[];
|
|
52
|
+
/** 状态字段的值 → 显示文本 + 标签颜色 映射 */
|
|
53
|
+
statusMap?: Record<string, StatusMapItem>;
|
|
54
|
+
/** 每页条数,默认 20 */
|
|
55
|
+
pageSize?: number;
|
|
56
|
+
/** 行主键字段,默认 'id' */
|
|
57
|
+
rowKey?: string;
|
|
58
|
+
/** 详情页路由前缀,用于构建查看/编辑路由:`${detailPath}/${row[rowKey]}` */
|
|
59
|
+
detailPath?: string;
|
|
60
|
+
/** 图片列 src 转换函数,将原始值转为可访问 URL */
|
|
61
|
+
imageResolver?: (raw: string) => string;
|
|
62
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @description CrudPage composable — 配置驱动的列表页数据层
|
|
3
|
+
* @author 阿怪
|
|
4
|
+
* @date 2026/3/22
|
|
5
|
+
* @version v0.0.1
|
|
6
|
+
*
|
|
7
|
+
* 江湖的业务千篇一律,复杂的代码好几百行。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ref, reactive, computed, onMounted } from 'vue';
|
|
11
|
+
import { useRequestClient } from '../../setup';
|
|
12
|
+
import type { CrudPageConfig } from './types';
|
|
13
|
+
|
|
14
|
+
export function useCrudPage(config: CrudPageConfig) {
|
|
15
|
+
const client = useRequestClient();
|
|
16
|
+
|
|
17
|
+
const page = ref(1);
|
|
18
|
+
const pageSize = ref(config.pageSize ?? 20);
|
|
19
|
+
const total = ref(0);
|
|
20
|
+
const list = ref<Record<string, unknown>[]>([]);
|
|
21
|
+
const loading = ref(false);
|
|
22
|
+
|
|
23
|
+
// 筛选表单状态
|
|
24
|
+
const filters = reactive<Record<string, unknown>>({});
|
|
25
|
+
if (config.filters) {
|
|
26
|
+
for (const f of config.filters) {
|
|
27
|
+
filters[f.param] = undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
|
|
32
|
+
|
|
33
|
+
async function fetchData() {
|
|
34
|
+
loading.value = true;
|
|
35
|
+
try {
|
|
36
|
+
const params: Record<string, unknown> = {
|
|
37
|
+
page: page.value,
|
|
38
|
+
pageSize: pageSize.value,
|
|
39
|
+
};
|
|
40
|
+
for (const [k, v] of Object.entries(filters)) {
|
|
41
|
+
if (v !== undefined && v !== '' && v !== null) {
|
|
42
|
+
params[k] = v;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const res = await client.get<any>(config.api, params);
|
|
46
|
+
// 兼容包装格式 { success, data: { total, list } } 和裸格式 { total, list }
|
|
47
|
+
const payload = (res && typeof res === 'object' && 'success' in res) ? res.data : res;
|
|
48
|
+
list.value = payload?.items ?? payload?.list ?? [];
|
|
49
|
+
total.value = payload?.total ?? 0;
|
|
50
|
+
} finally {
|
|
51
|
+
loading.value = false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function onPageChange(p: number) {
|
|
56
|
+
page.value = p;
|
|
57
|
+
fetchData();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function onSearch() {
|
|
61
|
+
page.value = 1;
|
|
62
|
+
fetchData();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function onReset() {
|
|
66
|
+
for (const key of Object.keys(filters)) {
|
|
67
|
+
filters[key] = undefined;
|
|
68
|
+
}
|
|
69
|
+
page.value = 1;
|
|
70
|
+
fetchData();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
onMounted(fetchData);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
page,
|
|
77
|
+
pageSize,
|
|
78
|
+
total,
|
|
79
|
+
totalPages,
|
|
80
|
+
list,
|
|
81
|
+
loading,
|
|
82
|
+
filters,
|
|
83
|
+
fetchData,
|
|
84
|
+
onPageChange,
|
|
85
|
+
onSearch,
|
|
86
|
+
onReset,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @description Vue Composables — 请求生命周期、轮询、批量加载
|
|
3
|
+
* @author 阿怪
|
|
4
|
+
* @date 2026/3/15
|
|
5
|
+
* @version v0.0.1
|
|
6
|
+
*
|
|
7
|
+
* 江湖的业务千篇一律,复杂的代码好几百行。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { onUnmounted, ref, type Ref } from 'vue';
|
|
11
|
+
import type { RequestClient } from './createRequest';
|
|
12
|
+
|
|
13
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
14
|
+
// useRequest — 组件内自动管理请求生命周期
|
|
15
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 在组件 setup 中使用,onUnmounted 时自动取消所有 pending 请求。
|
|
19
|
+
* 返回经过生命周期管理的 client 代理。
|
|
20
|
+
*/
|
|
21
|
+
export function useRequest(client: RequestClient): RequestClient {
|
|
22
|
+
const controllers: AbortController[] = [];
|
|
23
|
+
|
|
24
|
+
// 注册清理
|
|
25
|
+
onUnmounted(() => {
|
|
26
|
+
for (const controller of controllers) {
|
|
27
|
+
controller.abort('组件已卸载');
|
|
28
|
+
}
|
|
29
|
+
controllers.length = 0;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// 代理 registerAbort,拦截所有请求的 AbortController
|
|
33
|
+
const proxiedClient: RequestClient = {
|
|
34
|
+
...client,
|
|
35
|
+
registerAbortController: (controller: AbortController) => {
|
|
36
|
+
controllers.push(controller);
|
|
37
|
+
client.registerAbortController(controller);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return proxiedClient;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
// usePolling — 轮询
|
|
46
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export interface UsePollingReturn<T> {
|
|
49
|
+
/** 最新数据 */
|
|
50
|
+
data: Ref<T | undefined>;
|
|
51
|
+
/** 是否正在轮询 */
|
|
52
|
+
isPolling: Ref<boolean>;
|
|
53
|
+
/** 错误信息 */
|
|
54
|
+
error: Ref<Error | null>;
|
|
55
|
+
/** 启动轮询 */
|
|
56
|
+
start(): void;
|
|
57
|
+
/** 停止轮询 */
|
|
58
|
+
stop(): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 轮询 composable。
|
|
63
|
+
* @param fn 每次执行的异步函数
|
|
64
|
+
* @param interval 轮询间隔(毫秒)
|
|
65
|
+
*/
|
|
66
|
+
export function usePolling<T>(fn: () => Promise<T>, interval: number): UsePollingReturn<T> {
|
|
67
|
+
const data = ref<T | undefined>() as Ref<T | undefined>;
|
|
68
|
+
const isPolling = ref(false);
|
|
69
|
+
const error = ref<Error | null>(null);
|
|
70
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
71
|
+
|
|
72
|
+
async function tick() {
|
|
73
|
+
try {
|
|
74
|
+
const result = await fn();
|
|
75
|
+
data.value = result;
|
|
76
|
+
error.value = null;
|
|
77
|
+
} catch (e) {
|
|
78
|
+
error.value = e instanceof Error ? e : new Error(String(e));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (isPolling.value) {
|
|
82
|
+
timer = setTimeout(tick, interval);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function start() {
|
|
87
|
+
if (isPolling.value) return;
|
|
88
|
+
isPolling.value = true;
|
|
89
|
+
tick();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function stop() {
|
|
93
|
+
isPolling.value = false;
|
|
94
|
+
if (timer !== null) {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
timer = null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
onUnmounted(stop);
|
|
101
|
+
|
|
102
|
+
return { data, isPolling, error, start, stop };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
106
|
+
// useBatchLoader — 批量合并加载
|
|
107
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
export interface BatchLoaderOptions {
|
|
110
|
+
/** 合并窗口时间(毫秒),默认 16ms(一帧) */
|
|
111
|
+
windowMs?: number;
|
|
112
|
+
/** 单批最大数量 */
|
|
113
|
+
maxBatchSize?: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface UseBatchLoaderReturn<K, V> {
|
|
117
|
+
/** 加载单个 key,自动合并到批次 */
|
|
118
|
+
load(key: K): Promise<V>;
|
|
119
|
+
/** 清空待执行队列 */
|
|
120
|
+
flush(): void;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 批量加载 composable,收集短时间内的请求合并执行。
|
|
125
|
+
*
|
|
126
|
+
* @param batchFn 批量执行函数,接收 keys 数组,返回对应 values 数组
|
|
127
|
+
* @param options 配置项
|
|
128
|
+
*/
|
|
129
|
+
export function useBatchLoader<K, V>(
|
|
130
|
+
batchFn: (keys: K[]) => Promise<V[]>,
|
|
131
|
+
options: BatchLoaderOptions = {},
|
|
132
|
+
): UseBatchLoaderReturn<K, V> {
|
|
133
|
+
const windowMs = options.windowMs ?? 16;
|
|
134
|
+
const maxBatchSize = options.maxBatchSize ?? 100;
|
|
135
|
+
|
|
136
|
+
interface PendingEntry {
|
|
137
|
+
key: K;
|
|
138
|
+
resolve: (value: V) => void;
|
|
139
|
+
reject: (reason: unknown) => void;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let queue: PendingEntry[] = [];
|
|
143
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
144
|
+
|
|
145
|
+
async function executeBatch(batch: PendingEntry[]) {
|
|
146
|
+
try {
|
|
147
|
+
const keys = batch.map(e => e.key);
|
|
148
|
+
const values = await batchFn(keys);
|
|
149
|
+
|
|
150
|
+
if (values.length !== batch.length) {
|
|
151
|
+
const error = new Error(`[useBatchLoader] 批量函数返回 ${values.length} 个结果,期望 ${batch.length} 个`);
|
|
152
|
+
batch.forEach(e => e.reject(error));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
batch.forEach((entry, i) => entry.resolve(values[i]));
|
|
157
|
+
} catch (e) {
|
|
158
|
+
batch.forEach(entry => entry.reject(e));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function scheduleFlush() {
|
|
163
|
+
if (timer !== null) return;
|
|
164
|
+
|
|
165
|
+
timer = setTimeout(() => {
|
|
166
|
+
timer = null;
|
|
167
|
+
flush();
|
|
168
|
+
}, windowMs);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function flush() {
|
|
172
|
+
if (timer !== null) {
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
timer = null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
while (queue.length > 0) {
|
|
178
|
+
const batch = queue.splice(0, maxBatchSize);
|
|
179
|
+
executeBatch(batch);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function load(key: K): Promise<V> {
|
|
184
|
+
return new Promise<V>((resolve, reject) => {
|
|
185
|
+
queue.push({ key, resolve, reject });
|
|
186
|
+
|
|
187
|
+
if (queue.length >= maxBatchSize) {
|
|
188
|
+
flush();
|
|
189
|
+
} else {
|
|
190
|
+
scheduleFlush();
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
onUnmounted(() => {
|
|
196
|
+
if (timer !== null) {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
timer = null;
|
|
199
|
+
}
|
|
200
|
+
// 拒绝所有未执行的请求
|
|
201
|
+
queue.forEach(e => e.reject(new Error('组件已卸载')));
|
|
202
|
+
queue = [];
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
return { load, flush };
|
|
206
|
+
}
|