@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,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @description 权限核心 composable,基于 provide/inject 实现全局单例
|
|
3
|
+
* @author 阿怪
|
|
4
|
+
* @date 2026/2/26
|
|
5
|
+
* @version v0.0.1
|
|
6
|
+
*
|
|
7
|
+
* 江湖的业务千篇一律,复杂的代码好几百行。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { computed, inject, reactive, type App, type InjectionKey } from 'vue';
|
|
11
|
+
import type { AuthOptions, AuthReturn, AuthState, Permission, PermissionScope, UserInfo } from './types';
|
|
12
|
+
import { createStorageAdapter, type StorageAdapter } from '../storage/createStorageAdapter';
|
|
13
|
+
|
|
14
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
// Inject key
|
|
16
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/** 内部 provide 结构,包含 auth 实例与 onUnauthorized 回调 */
|
|
19
|
+
interface AuthProvide {
|
|
20
|
+
auth: AuthReturn;
|
|
21
|
+
onUnauthorized: (() => void) | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** provide/inject 使用的唯一 symbol key,避免命名冲突 */
|
|
25
|
+
const AUTH_INJECT_KEY: InjectionKey<AuthProvide> = Symbol('kine-design:auth');
|
|
26
|
+
|
|
27
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
// scope 层级映射(数值越大权限越宽)
|
|
29
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const SCOPE_LEVEL: Record<string, number> = {
|
|
32
|
+
own: 1,
|
|
33
|
+
department: 2,
|
|
34
|
+
all: 3,
|
|
35
|
+
'*': Infinity,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 检查权限持有的 scope 是否满足所需 scope。
|
|
40
|
+
* 层级:all > department > own,'*' 匹配所有。
|
|
41
|
+
*/
|
|
42
|
+
function isScopeSatisfied(
|
|
43
|
+
permScope: PermissionScope | undefined,
|
|
44
|
+
requiredScope: PermissionScope,
|
|
45
|
+
): boolean {
|
|
46
|
+
// 权限 scope 未声明时视为 'all'(无范围限制)
|
|
47
|
+
const permLevel = SCOPE_LEVEL[permScope ?? 'all'] ?? 0;
|
|
48
|
+
const requiredLevel = SCOPE_LEVEL[requiredScope] ?? 0;
|
|
49
|
+
return permLevel >= requiredLevel;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
// 内部:判断单条权限是否命中
|
|
54
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
function matchesPermission(
|
|
57
|
+
perm: Permission,
|
|
58
|
+
resource: string,
|
|
59
|
+
action: string,
|
|
60
|
+
scope: PermissionScope | undefined,
|
|
61
|
+
): boolean {
|
|
62
|
+
// 资源不匹配直接跳过
|
|
63
|
+
if (perm.resource !== resource) return false;
|
|
64
|
+
|
|
65
|
+
// action: '*' 的权限匹配所有操作
|
|
66
|
+
const actionMatched = perm.action === '*' || perm.action === action;
|
|
67
|
+
if (!actionMatched) return false;
|
|
68
|
+
|
|
69
|
+
// 不需要检查 scope
|
|
70
|
+
if (scope === undefined) return true;
|
|
71
|
+
|
|
72
|
+
return isScopeSatisfied(perm.scope, scope);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
76
|
+
// Storage key 常量
|
|
77
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
const STORAGE_KEY_TOKEN = 'kine_auth_token';
|
|
80
|
+
const STORAGE_KEY_USER = 'kine_auth_user';
|
|
81
|
+
|
|
82
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
83
|
+
// 内部:构建 AuthReturn
|
|
84
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
function buildAuthReturn(
|
|
87
|
+
state: AuthState,
|
|
88
|
+
options: AuthOptions,
|
|
89
|
+
adapter: StorageAdapter | null,
|
|
90
|
+
): AuthReturn {
|
|
91
|
+
const user = computed(() => state.user);
|
|
92
|
+
const permissions = computed(() => state.permissions);
|
|
93
|
+
const token = computed(() => state.token);
|
|
94
|
+
const isAuthenticated = computed(() => state.user !== null && state.token !== null);
|
|
95
|
+
|
|
96
|
+
function can(resource: string, action: string, scope?: PermissionScope): boolean {
|
|
97
|
+
if (!state.permissions) return false;
|
|
98
|
+
return state.permissions.some(p => matchesPermission(p, resource, action, scope));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 将当前状态写入 storage */
|
|
102
|
+
function persistToStorage(): void {
|
|
103
|
+
if (!adapter) return;
|
|
104
|
+
adapter.set(STORAGE_KEY_TOKEN, state.token);
|
|
105
|
+
adapter.set(STORAGE_KEY_USER, state.user);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 清除 storage 中的认证数据 */
|
|
109
|
+
function clearStorage(): void {
|
|
110
|
+
if (!adapter) return;
|
|
111
|
+
adapter.remove(STORAGE_KEY_TOKEN);
|
|
112
|
+
adapter.remove(STORAGE_KEY_USER);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function login(): Promise<void> {
|
|
116
|
+
const result = await options.fetchUser();
|
|
117
|
+
state.user = result.user;
|
|
118
|
+
state.permissions = result.permissions;
|
|
119
|
+
state.token = result.token;
|
|
120
|
+
persistToStorage();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function loginWith(result: { user: UserInfo; permissions: Permission[]; token: string }): void {
|
|
124
|
+
state.user = result.user;
|
|
125
|
+
state.permissions = result.permissions;
|
|
126
|
+
state.token = result.token;
|
|
127
|
+
persistToStorage();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function logout(): void {
|
|
131
|
+
state.user = null;
|
|
132
|
+
state.permissions = [];
|
|
133
|
+
state.token = null;
|
|
134
|
+
clearStorage();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let restoring: Promise<void> | null = null;
|
|
138
|
+
|
|
139
|
+
async function restore(): Promise<void> {
|
|
140
|
+
if (!adapter) return;
|
|
141
|
+
// 防止并发调用(如 mount + tab 激活同时触发)
|
|
142
|
+
if (restoring) return restoring;
|
|
143
|
+
|
|
144
|
+
restoring = doRestore();
|
|
145
|
+
try {
|
|
146
|
+
await restoring;
|
|
147
|
+
} finally {
|
|
148
|
+
restoring = null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function doRestore(): Promise<void> {
|
|
153
|
+
const storedToken = adapter!.get<string>(STORAGE_KEY_TOKEN);
|
|
154
|
+
const storedUser = adapter!.get<AuthState['user']>(STORAGE_KEY_USER);
|
|
155
|
+
|
|
156
|
+
// storage 中没有有效数据,跳过
|
|
157
|
+
if (!storedToken || !storedUser) return;
|
|
158
|
+
|
|
159
|
+
// 先恢复到 reactive state(让 UI 能立即渲染)
|
|
160
|
+
state.token = storedToken;
|
|
161
|
+
state.user = storedUser;
|
|
162
|
+
|
|
163
|
+
// 调用 fetchUser 验证 token 是否仍然有效
|
|
164
|
+
try {
|
|
165
|
+
const result = await options.fetchUser();
|
|
166
|
+
state.user = result.user;
|
|
167
|
+
state.permissions = result.permissions;
|
|
168
|
+
state.token = result.token;
|
|
169
|
+
persistToStorage();
|
|
170
|
+
} catch {
|
|
171
|
+
// 验证失败,清除所有状态
|
|
172
|
+
state.user = null;
|
|
173
|
+
state.permissions = [];
|
|
174
|
+
state.token = null;
|
|
175
|
+
clearStorage();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { user, permissions, token, isAuthenticated, can, login, loginWith, logout, restore };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
183
|
+
// createAuth — 应用初始化时调用一次
|
|
184
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 创建全局 auth 实例并安装到 Vue App。
|
|
188
|
+
* 应在 main.ts 中调用一次,之后所有组件可通过 useAuth() 获取。
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* ```ts
|
|
192
|
+
* // main.ts
|
|
193
|
+
* import { createApp } from 'vue';
|
|
194
|
+
* import { createAuth } from '@kine-design/crud';
|
|
195
|
+
*
|
|
196
|
+
* const app = createApp(App);
|
|
197
|
+
* createAuth(app, {
|
|
198
|
+
* fetchUser: () => http.get('/api/me'),
|
|
199
|
+
* onUnauthorized: () => router.push('/login'),
|
|
200
|
+
* });
|
|
201
|
+
* app.mount('#app');
|
|
202
|
+
* ```
|
|
203
|
+
*/
|
|
204
|
+
export function createAuth(app: App, options: AuthOptions): AuthReturn {
|
|
205
|
+
const state = reactive<AuthState>({
|
|
206
|
+
user: null,
|
|
207
|
+
permissions: [],
|
|
208
|
+
token: null,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// 根据配置创建 storage adapter(false 或不传时为 null,纯内存模式)
|
|
212
|
+
const storageType = options.storage ?? false;
|
|
213
|
+
const adapter = storageType !== false ? createStorageAdapter(storageType) : null;
|
|
214
|
+
|
|
215
|
+
const auth = buildAuthReturn(state, options, adapter);
|
|
216
|
+
|
|
217
|
+
// ── 跨 tab 同步(仅 localStorage 有效)──────────────────────────────────
|
|
218
|
+
if (storageType === 'local' && typeof window !== 'undefined') {
|
|
219
|
+
const onStorageChange = (e: StorageEvent) => {
|
|
220
|
+
if (e.key !== STORAGE_KEY_TOKEN) return;
|
|
221
|
+
// 其他 tab 清除了 token → 本 tab 也登出
|
|
222
|
+
if (e.newValue === null) {
|
|
223
|
+
state.user = null;
|
|
224
|
+
state.permissions = [];
|
|
225
|
+
state.token = null;
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
window.addEventListener('storage', onStorageChange);
|
|
229
|
+
|
|
230
|
+
// app.unmount 时清理监听器,防止泄漏
|
|
231
|
+
const origUnmount = app.unmount.bind(app);
|
|
232
|
+
app.unmount = () => {
|
|
233
|
+
window.removeEventListener('storage', onStorageChange);
|
|
234
|
+
origUnmount();
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 将 auth 实例注入 Vue 组件树,同时挂载 onUnauthorized 回调供 authGuard 使用
|
|
239
|
+
app.provide(AUTH_INJECT_KEY, { auth, onUnauthorized: options.onUnauthorized });
|
|
240
|
+
|
|
241
|
+
return auth;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
245
|
+
// useAuth — 任意组件中使用
|
|
246
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 在组件中获取全局 auth 实例。
|
|
250
|
+
* 必须在 createAuth() 已安装的 App 子树内调用。
|
|
251
|
+
*
|
|
252
|
+
* @throws 若 createAuth() 尚未调用,抛出清晰的错误提示。
|
|
253
|
+
*
|
|
254
|
+
* @example
|
|
255
|
+
* ```ts
|
|
256
|
+
* const { isAuthenticated, can, login } = useAuth();
|
|
257
|
+
* if (!can('order', 'delete')) { ... }
|
|
258
|
+
* ```
|
|
259
|
+
*/
|
|
260
|
+
export function useAuth(): AuthReturn {
|
|
261
|
+
const provided = inject(AUTH_INJECT_KEY);
|
|
262
|
+
if (!provided) {
|
|
263
|
+
throw new Error('[useAuth] 未找到 auth 实例,请先在根 App 调用 createAuth()。');
|
|
264
|
+
}
|
|
265
|
+
return provided.auth;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 内部使用:获取完整 provide 对象(含 onUnauthorized 回调),供 authGuard 使用。
|
|
270
|
+
* 不作为公开 API 导出。
|
|
271
|
+
*/
|
|
272
|
+
export function useAuthProvide(): AuthProvide {
|
|
273
|
+
const provided = inject(AUTH_INJECT_KEY);
|
|
274
|
+
if (!provided) {
|
|
275
|
+
throw new Error('[useAuthProvide] 未找到 auth 实例,请先在根 App 调用 createAuth()。');
|
|
276
|
+
}
|
|
277
|
+
return provided;
|
|
278
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @description v-can 自定义指令,无权限时隐藏元素
|
|
3
|
+
* @author 阿怪
|
|
4
|
+
* @date 2026/2/26
|
|
5
|
+
* @version v0.0.1
|
|
6
|
+
*
|
|
7
|
+
* 江湖的业务千篇一律,复杂的代码好几百行。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Directive, DirectiveBinding } from 'vue';
|
|
11
|
+
import type { AuthReturn, PermissionScope } from './types';
|
|
12
|
+
|
|
13
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
14
|
+
// 指令绑定值类型
|
|
15
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/** 对象写法:v-can="{ resource: 'order', action: 'delete', scope: 'all' }" */
|
|
18
|
+
export interface CanBindingObject {
|
|
19
|
+
resource: string;
|
|
20
|
+
action: string;
|
|
21
|
+
scope?: PermissionScope;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 字符串写法:v-can="'order:delete'" 或 v-can="'order:delete:all'"
|
|
26
|
+
* 格式:`resource:action` 或 `resource:action:scope`
|
|
27
|
+
*/
|
|
28
|
+
export type CanBinding = CanBindingObject | string;
|
|
29
|
+
|
|
30
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
// 内部辅助
|
|
32
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/** 将字符串简写解析为标准对象 */
|
|
35
|
+
function parseBinding(value: CanBinding): CanBindingObject {
|
|
36
|
+
if (typeof value === 'object') return value;
|
|
37
|
+
|
|
38
|
+
const parts = value.split(':');
|
|
39
|
+
const resource = parts[0] ?? '';
|
|
40
|
+
const action = parts[1] ?? '';
|
|
41
|
+
const scope = parts[2] as PermissionScope | undefined;
|
|
42
|
+
return { resource, action, scope };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 根据权限检查结果更新元素可见性 */
|
|
46
|
+
function applyVisibility(el: HTMLElement, auth: AuthReturn, binding: CanBinding): void {
|
|
47
|
+
const { resource, action, scope } = parseBinding(binding);
|
|
48
|
+
const allowed = auth.can(resource, action, scope);
|
|
49
|
+
el.style.display = allowed ? '' : 'none';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
// createVCan — 工厂函数,需绑定 auth 实例
|
|
54
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 创建 v-can 自定义指令。
|
|
58
|
+
* 需要传入 useAuth() 返回的 auth 实例。
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* // main.ts
|
|
63
|
+
* import { useAuth, createVCan } from '@kine-design/crud';
|
|
64
|
+
*
|
|
65
|
+
* const app = createApp(App);
|
|
66
|
+
* createAuth(app, { fetchUser });
|
|
67
|
+
*
|
|
68
|
+
* // 需在 createAuth 之后调用(此时 inject 上下文已就绪)
|
|
69
|
+
* // 推荐在根组件 setup 中注册,或通过 auth 实例直接传入
|
|
70
|
+
* const auth = createAuth(app, { fetchUser });
|
|
71
|
+
* app.directive('can', createVCan(auth));
|
|
72
|
+
* ```
|
|
73
|
+
*
|
|
74
|
+
* 用法:
|
|
75
|
+
* ```html
|
|
76
|
+
* <!-- 对象写法 -->
|
|
77
|
+
* <button v-can="{ resource: 'order', action: 'delete' }">删除</button>
|
|
78
|
+
*
|
|
79
|
+
* <!-- 字符串简写 -->
|
|
80
|
+
* <button v-can="'order:delete'">删除</button>
|
|
81
|
+
*
|
|
82
|
+
* <!-- 带 scope -->
|
|
83
|
+
* <button v-can="'order:delete:all'">删除(全量权限)</button>
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
export function createVCan(auth: AuthReturn): Directive<HTMLElement, CanBinding> {
|
|
87
|
+
return {
|
|
88
|
+
mounted(el: HTMLElement, binding: DirectiveBinding<CanBinding>): void {
|
|
89
|
+
applyVisibility(el, auth, binding.value);
|
|
90
|
+
},
|
|
91
|
+
updated(el: HTMLElement, binding: DirectiveBinding<CanBinding>): void {
|
|
92
|
+
applyVisibility(el, auth, binding.value);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @description 数据层核心 — 封装 TanStack Query Vue,业务代码不直接碰 TanStack Query
|
|
3
|
+
* @author 阿怪
|
|
4
|
+
* @date 2026/2/26
|
|
5
|
+
* @version v0.0.1
|
|
6
|
+
*
|
|
7
|
+
* 江湖的业务千篇一律,复杂的代码好几百行。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { computed, isRef, toRef, type MaybeRef } from 'vue';
|
|
11
|
+
import {
|
|
12
|
+
useQuery,
|
|
13
|
+
useMutation,
|
|
14
|
+
useQueryClient,
|
|
15
|
+
} from '@tanstack/vue-query';
|
|
16
|
+
import type {
|
|
17
|
+
EntityId,
|
|
18
|
+
ListParams,
|
|
19
|
+
MutationOptions,
|
|
20
|
+
Repository,
|
|
21
|
+
RepositoryEndpoints,
|
|
22
|
+
UseDetailReturn,
|
|
23
|
+
UseListReturn,
|
|
24
|
+
UseMutationReturn,
|
|
25
|
+
} from './types';
|
|
26
|
+
|
|
27
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
// 内部辅助
|
|
29
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 将 MaybeRef 统一转为 Ref,保证 query key 的响应性。
|
|
33
|
+
* Vue 3.3+ toRef() 支持直接传入原始值。
|
|
34
|
+
*/
|
|
35
|
+
function normalizeRef<T>(value: MaybeRef<T>) {
|
|
36
|
+
return isRef(value) ? value : toRef(() => value as T);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
// defineRepository
|
|
41
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 定义一个领域数据仓库。
|
|
45
|
+
*
|
|
46
|
+
* @param domain 领域 key,用于 query key 命名空间与缓存失效边界。
|
|
47
|
+
* @param endpoints 原始 API 函数集合。
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```ts
|
|
51
|
+
* const orderRepo = defineRepository('order', {
|
|
52
|
+
* list: (params) => http.get('/api/orders', params),
|
|
53
|
+
* detail: (id) => http.get(`/api/orders/${id}`),
|
|
54
|
+
* create: (data) => http.post('/api/orders', data),
|
|
55
|
+
* update: (id, data) => http.put(`/api/orders/${id}`, data),
|
|
56
|
+
* remove: (id) => http.delete(`/api/orders/${id}`),
|
|
57
|
+
* });
|
|
58
|
+
*
|
|
59
|
+
* // 列表
|
|
60
|
+
* const { data, isLoading } = orderRepo.useList(searchParams);
|
|
61
|
+
*
|
|
62
|
+
* // 跨领域联动
|
|
63
|
+
* const { mutate } = orderRepo.useUpdate({ invalidates: ['customer'] });
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export function defineRepository<
|
|
67
|
+
TList = unknown,
|
|
68
|
+
TDetail = unknown,
|
|
69
|
+
TCreateInput = unknown,
|
|
70
|
+
TCreateOutput = unknown,
|
|
71
|
+
TUpdateInput = unknown,
|
|
72
|
+
TUpdateOutput = unknown,
|
|
73
|
+
>(
|
|
74
|
+
domain: string,
|
|
75
|
+
endpoints: RepositoryEndpoints<TList, TDetail, TCreateInput, TCreateOutput, TUpdateInput, TUpdateOutput>,
|
|
76
|
+
): Repository<TList, TDetail, TCreateInput, TCreateOutput, TUpdateInput, TUpdateOutput> {
|
|
77
|
+
|
|
78
|
+
// ── useList ───────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
const useList = (params?: MaybeRef<ListParams>): UseListReturn<TList> => {
|
|
81
|
+
const paramsRef = normalizeRef<ListParams>(params ?? {});
|
|
82
|
+
|
|
83
|
+
const query = useQuery({
|
|
84
|
+
// query key 包含 params,params 变化时自动重新请求
|
|
85
|
+
queryKey: computed(() => [domain, 'list', paramsRef.value]),
|
|
86
|
+
queryFn: () => {
|
|
87
|
+
if (!endpoints.list) {
|
|
88
|
+
return Promise.reject(new Error(`[defineRepository:${domain}] list endpoint 未定义`));
|
|
89
|
+
}
|
|
90
|
+
return endpoints.list(paramsRef.value);
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
data: computed(() => query.data.value),
|
|
96
|
+
isLoading: computed(() => query.isLoading.value),
|
|
97
|
+
isFetching: computed(() => query.isFetching.value),
|
|
98
|
+
isError: computed(() => query.isError.value),
|
|
99
|
+
error: computed(() => query.error.value),
|
|
100
|
+
refetch: () => { query.refetch(); },
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// ── useDetail ─────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
const useDetail = (id: MaybeRef<EntityId>): UseDetailReturn<TDetail> => {
|
|
107
|
+
const idRef = normalizeRef(id);
|
|
108
|
+
|
|
109
|
+
const query = useQuery({
|
|
110
|
+
queryKey: computed(() => [domain, 'detail', idRef.value]),
|
|
111
|
+
queryFn: () => {
|
|
112
|
+
if (!endpoints.detail) {
|
|
113
|
+
return Promise.reject(new Error(`[defineRepository:${domain}] detail endpoint 未定义`));
|
|
114
|
+
}
|
|
115
|
+
return endpoints.detail(idRef.value);
|
|
116
|
+
},
|
|
117
|
+
// id 为空时不发请求
|
|
118
|
+
enabled: computed(() => idRef.value !== undefined && idRef.value !== null && idRef.value !== ''),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
data: computed(() => query.data.value),
|
|
123
|
+
isLoading: computed(() => query.isLoading.value),
|
|
124
|
+
isFetching: computed(() => query.isFetching.value),
|
|
125
|
+
isError: computed(() => query.isError.value),
|
|
126
|
+
error: computed(() => query.error.value),
|
|
127
|
+
refetch: () => { query.refetch(); },
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// ── 内部:失效当前领域 + 跨领域联动 ──────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
function buildInvalidateHandler(options?: MutationOptions) {
|
|
134
|
+
return async (queryClient: ReturnType<typeof useQueryClient>) => {
|
|
135
|
+
// 失效当前领域所有 query(list + detail + 任意子 key)
|
|
136
|
+
await queryClient.invalidateQueries({ queryKey: [domain] });
|
|
137
|
+
|
|
138
|
+
// 跨领域联动:失效显式声明的其他领域
|
|
139
|
+
if (options?.invalidates?.length) {
|
|
140
|
+
await Promise.all(
|
|
141
|
+
options.invalidates.map(d =>
|
|
142
|
+
queryClient.invalidateQueries({ queryKey: [d] }),
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── useCreate ─────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
const useCreate = (options?: MutationOptions): UseMutationReturn<TCreateInput, TCreateOutput> => {
|
|
152
|
+
const queryClient = useQueryClient();
|
|
153
|
+
const invalidate = buildInvalidateHandler(options);
|
|
154
|
+
|
|
155
|
+
const mutation = useMutation<TCreateOutput, Error, TCreateInput>({
|
|
156
|
+
mutationFn: (data: TCreateInput) => {
|
|
157
|
+
if (!endpoints.create) {
|
|
158
|
+
return Promise.reject(new Error(`[defineRepository:${domain}] create endpoint 未定义`));
|
|
159
|
+
}
|
|
160
|
+
return endpoints.create(data);
|
|
161
|
+
},
|
|
162
|
+
onSuccess: () => invalidate(queryClient),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
// 类型断言:确保签名与泛型约束一致
|
|
167
|
+
mutate: ((data: TCreateInput) => mutation.mutateAsync(data)) as UseMutationReturn<TCreateInput, TCreateOutput>['mutate'],
|
|
168
|
+
isPending: computed(() => mutation.isPending.value),
|
|
169
|
+
isError: computed(() => mutation.isError.value),
|
|
170
|
+
error: computed(() => mutation.error.value),
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// ── useUpdate ─────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
const useUpdate = (options?: MutationOptions): UseMutationReturn<{ id: EntityId; data: TUpdateInput }, TUpdateOutput> => {
|
|
177
|
+
const queryClient = useQueryClient();
|
|
178
|
+
const invalidate = buildInvalidateHandler(options);
|
|
179
|
+
|
|
180
|
+
const mutation = useMutation<TUpdateOutput, Error, { id: EntityId; data: TUpdateInput }>({
|
|
181
|
+
mutationFn: ({ id, data }) => {
|
|
182
|
+
if (!endpoints.update) {
|
|
183
|
+
return Promise.reject(new Error(`[defineRepository:${domain}] update endpoint 未定义`));
|
|
184
|
+
}
|
|
185
|
+
return endpoints.update(id, data);
|
|
186
|
+
},
|
|
187
|
+
onSuccess: () => invalidate(queryClient),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
mutate: ((variables: { id: EntityId; data: TUpdateInput }) =>
|
|
192
|
+
mutation.mutateAsync(variables)) as UseMutationReturn<{ id: EntityId; data: TUpdateInput }, TUpdateOutput>['mutate'],
|
|
193
|
+
isPending: computed(() => mutation.isPending.value),
|
|
194
|
+
isError: computed(() => mutation.isError.value),
|
|
195
|
+
error: computed(() => mutation.error.value),
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// ── useRemove ─────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
const useRemove = (options?: MutationOptions): UseMutationReturn<EntityId, void> => {
|
|
202
|
+
const queryClient = useQueryClient();
|
|
203
|
+
const invalidate = buildInvalidateHandler(options);
|
|
204
|
+
|
|
205
|
+
const mutation = useMutation<void, Error, EntityId>({
|
|
206
|
+
mutationFn: (id: EntityId) => {
|
|
207
|
+
if (!endpoints.remove) {
|
|
208
|
+
return Promise.reject(new Error(`[defineRepository:${domain}] remove endpoint 未定义`));
|
|
209
|
+
}
|
|
210
|
+
return endpoints.remove(id);
|
|
211
|
+
},
|
|
212
|
+
onSuccess: () => invalidate(queryClient),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
mutate: ((id: EntityId) => mutation.mutateAsync(id)) as UseMutationReturn<EntityId, void>['mutate'],
|
|
217
|
+
isPending: computed(() => mutation.isPending.value),
|
|
218
|
+
isError: computed(() => mutation.isError.value),
|
|
219
|
+
error: computed(() => mutation.error.value),
|
|
220
|
+
};
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
return { useList, useDetail, useCreate, useUpdate, useRemove };
|
|
224
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @description 全局错误处理器,注册 Vue app.config.errorHandler 统一捕获未处理错误
|
|
3
|
+
* @author 阿怪
|
|
4
|
+
* @date 2026/3/15
|
|
5
|
+
* @version v0.0.1
|
|
6
|
+
*
|
|
7
|
+
* 江湖的业务千篇一律,复杂的代码好几百行。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { App } from 'vue';
|
|
11
|
+
import type { ErrorHandlerOptions } from './types';
|
|
12
|
+
import { ERROR_HANDLER_INJECT_KEY } from './types';
|
|
13
|
+
import { dispatchError } from './dispatchError';
|
|
14
|
+
|
|
15
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
// createErrorHandler — 应用初始化时调用
|
|
17
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 创建全局错误处理器并安装到 Vue App。
|
|
21
|
+
* 注册 app.config.errorHandler 统一捕获未处理错误,
|
|
22
|
+
* 并通过 provide 将配置注入组件树供 useErrorHandler() 使用。
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* // main.ts
|
|
27
|
+
* import { createApp } from 'vue';
|
|
28
|
+
* import { createErrorHandler } from '@kine-design/crud';
|
|
29
|
+
*
|
|
30
|
+
* const app = createApp(App);
|
|
31
|
+
* createErrorHandler(app, {
|
|
32
|
+
* feedbackHandler: myFeedbackHandler,
|
|
33
|
+
* unauthorizedStrategy: 'redirect',
|
|
34
|
+
* });
|
|
35
|
+
* app.mount('#app');
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export function createErrorHandler(app: App, options: ErrorHandlerOptions = {}): void {
|
|
39
|
+
// 注册 Vue 全局错误处理
|
|
40
|
+
app.config.errorHandler = (err) => {
|
|
41
|
+
dispatchError(err, options);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// 将配置注入组件树
|
|
45
|
+
app.provide(ERROR_HANDLER_INJECT_KEY, options);
|
|
46
|
+
}
|