@svadmin/core 0.30.0 → 0.32.0
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/package.json +5 -5
- package/src/auth-hooks.svelte.ts +58 -24
- package/src/context.svelte.ts +185 -46
- package/src/data-transfer.svelte.ts +51 -41
- package/src/export-format.ts +115 -0
- package/src/form-hooks.svelte.ts +322 -58
- package/src/helpers.ts +11 -7
- package/src/hook-utils.svelte.ts +13 -12
- package/src/hooks.svelte.ts +29 -23
- package/src/http.ts +114 -33
- package/src/i18n.svelte.ts +423 -21
- package/src/index.ts +11 -8
- package/src/mutation-hooks.svelte.ts +27 -16
- package/src/permissions.svelte.ts +4 -3
- package/src/query-hooks.svelte.ts +35 -15
- package/src/router.ts +28 -8
- package/src/routing-hooks.svelte.ts +22 -18
- package/src/table-hooks.svelte.ts +5 -3
- package/src/table-state.svelte.ts +6 -3
- package/src/task-hooks.svelte.ts +27 -14
- package/src/theme.svelte.ts +248 -154
- package/src/toast.svelte.ts +1 -2
- package/src/types.ts +29 -4
- package/src/unsaved-changes.svelte.ts +4 -38
- package/src/url-sync.ts +47 -8
- package/src/useCan.ts +3 -2
- package/src/useParsed.svelte.ts +39 -15
- package/src/utility-hooks.svelte.ts +119 -7
package/src/types.ts
CHANGED
|
@@ -1,18 +1,40 @@
|
|
|
1
1
|
// Core type definitions — DataProvider + AuthProvider + Providers
|
|
2
2
|
|
|
3
|
+
import type { Component } from 'svelte';
|
|
4
|
+
|
|
3
5
|
// ─── HttpError ─────────────────────────────────────────────────
|
|
4
6
|
|
|
5
7
|
export type ValidationErrors = Record<string, string | string[]>;
|
|
6
8
|
|
|
9
|
+
export interface HttpErrorOptions {
|
|
10
|
+
code?: string;
|
|
11
|
+
details?: unknown;
|
|
12
|
+
body?: unknown;
|
|
13
|
+
cause?: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
7
16
|
export class HttpError extends Error {
|
|
8
17
|
statusCode: number;
|
|
9
18
|
errors?: ValidationErrors;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
19
|
+
code?: string;
|
|
20
|
+
details?: unknown;
|
|
21
|
+
body?: unknown;
|
|
22
|
+
override cause?: unknown;
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
message: string,
|
|
26
|
+
statusCode: number,
|
|
27
|
+
errors?: ValidationErrors,
|
|
28
|
+
options: HttpErrorOptions = {},
|
|
29
|
+
) {
|
|
30
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
13
31
|
this.name = 'HttpError';
|
|
14
32
|
this.statusCode = statusCode;
|
|
15
33
|
this.errors = errors;
|
|
34
|
+
this.code = options.code;
|
|
35
|
+
this.details = options.details;
|
|
36
|
+
this.body = options.body;
|
|
37
|
+
this.cause = options.cause;
|
|
16
38
|
}
|
|
17
39
|
}
|
|
18
40
|
|
|
@@ -72,6 +94,7 @@ export interface GetListParams {
|
|
|
72
94
|
export interface GetListResult<TData extends BaseRecord = BaseRecord> {
|
|
73
95
|
data: TData[];
|
|
74
96
|
total: number;
|
|
97
|
+
[key: string]: unknown;
|
|
75
98
|
}
|
|
76
99
|
|
|
77
100
|
export interface GetOneParams {
|
|
@@ -315,6 +338,8 @@ export interface AuthProvider {
|
|
|
315
338
|
register?: (params: Record<string, unknown>) => Promise<AuthActionResult>;
|
|
316
339
|
forgotPassword?: (params: Record<string, unknown>) => Promise<AuthActionResult>;
|
|
317
340
|
updatePassword?: (params: Record<string, unknown>) => Promise<AuthActionResult>;
|
|
341
|
+
/** Update current user's identity fields such as name or email. */
|
|
342
|
+
updateIdentity?: (params: Partial<Identity> & Record<string, unknown>) => Promise<AuthActionResult>;
|
|
318
343
|
/** Update user profile (name, avatar, etc.) */
|
|
319
344
|
updateProfile?: (params: { name?: string; avatar?: string | File; [key: string]: unknown }) => Promise<AuthActionResult>;
|
|
320
345
|
/** Get list of system roles for RBAC mapping */
|
|
@@ -382,7 +407,7 @@ export interface MenuItem {
|
|
|
382
407
|
/** Display label. Falls back to `t('menu.${name}')` if omitted */
|
|
383
408
|
label?: string;
|
|
384
409
|
/** Icon name string (maps to lucide icon set) or Svelte component */
|
|
385
|
-
icon?: string |
|
|
410
|
+
icon?: string | Component;
|
|
386
411
|
/** Navigation path — omit for parent-only menu nodes */
|
|
387
412
|
href?: string;
|
|
388
413
|
/** Open in new tab (useful for external links) */
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
/* eslint-disable svelte/prefer-svelte-reactivity */
|
|
2
1
|
// UnsavedChangesNotifier — guards against accidental navigation with dirty forms
|
|
3
2
|
// Supports: browser tab close, hash-based SPA routing, and framework router interception
|
|
4
3
|
|
|
5
|
-
import {
|
|
4
|
+
import { useTranslation } from './i18n.svelte';
|
|
6
5
|
import { syncGlobalPath } from './useParsed.svelte';
|
|
7
6
|
|
|
8
7
|
let dirty = $state(false);
|
|
@@ -15,46 +14,12 @@ export function resetUnsavedChanges(): void {
|
|
|
15
14
|
dirty = false;
|
|
16
15
|
_unsavedChangesCleanup?.();
|
|
17
16
|
_unsavedChangesCleanup = null;
|
|
18
|
-
__navigationGuard = null;
|
|
19
17
|
}
|
|
20
18
|
|
|
21
19
|
export function getUnsavedChanges(): boolean {
|
|
22
20
|
return dirty;
|
|
23
21
|
}
|
|
24
22
|
|
|
25
|
-
/**
|
|
26
|
-
* Navigation interceptor callback type.
|
|
27
|
-
* Frameworks like SvelteKit can register a cancel function here.
|
|
28
|
-
* Return `true` to allow navigation, `false` to block it.
|
|
29
|
-
*/
|
|
30
|
-
interface NavigationGuard {
|
|
31
|
-
(opts: { cancel: () => void }): void;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
let __navigationGuard: NavigationGuard | null = null;
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Register a framework-level navigation guard.
|
|
38
|
-
*
|
|
39
|
-
* In SvelteKit, call this from your root layout:
|
|
40
|
-
* ```ts
|
|
41
|
-
* import { beforeNavigate } from '$app/navigation';
|
|
42
|
-
* import { registerNavigationGuard } from '@svadmin/core';
|
|
43
|
-
*
|
|
44
|
-
* registerNavigationGuard(({ cancel }) => {
|
|
45
|
-
* beforeNavigate(({ cancel: kitCancel }) => {
|
|
46
|
-
* cancel = kitCancel;
|
|
47
|
-
* });
|
|
48
|
-
* });
|
|
49
|
-
* ```
|
|
50
|
-
*
|
|
51
|
-
* Or more idiomatically, use the built-in `initUnsavedChangesNotifier`
|
|
52
|
-
* and pass a `beforeNavigate` callback.
|
|
53
|
-
*/
|
|
54
|
-
export function registerNavigationGuard(guard: NavigationGuard): void {
|
|
55
|
-
__navigationGuard = guard;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
23
|
/**
|
|
59
24
|
* Initialize the UnsavedChangesNotifier.
|
|
60
25
|
*
|
|
@@ -82,6 +47,7 @@ export function initUnsavedChangesNotifier(options?: {
|
|
|
82
47
|
beforeNavigate?: (callback: (nav: { cancel: () => void }) => void) => void;
|
|
83
48
|
}) {
|
|
84
49
|
if (typeof window === 'undefined') return;
|
|
50
|
+
const i18n = useTranslation();
|
|
85
51
|
_unsavedChangesCleanup?.();
|
|
86
52
|
|
|
87
53
|
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
@@ -94,7 +60,7 @@ export function initUnsavedChangesNotifier(options?: {
|
|
|
94
60
|
if (options?.beforeNavigate) {
|
|
95
61
|
options.beforeNavigate(({ cancel }) => {
|
|
96
62
|
if (dirty) {
|
|
97
|
-
if (!confirm(t('common.unsavedChanges'))) {
|
|
63
|
+
if (!confirm(i18n.t('common.unsavedChanges'))) {
|
|
98
64
|
cancel();
|
|
99
65
|
} else {
|
|
100
66
|
dirty = false;
|
|
@@ -105,7 +71,7 @@ export function initUnsavedChangesNotifier(options?: {
|
|
|
105
71
|
|
|
106
72
|
const onHashChange = (e: HashChangeEvent) => {
|
|
107
73
|
if (!dirty) return;
|
|
108
|
-
if (!confirm(t('common.unsavedChanges'))) {
|
|
74
|
+
if (!confirm(i18n.t('common.unsavedChanges'))) {
|
|
109
75
|
if (e.oldURL) {
|
|
110
76
|
history.replaceState(null, '', new URL(e.oldURL).hash || '#');
|
|
111
77
|
syncGlobalPath();
|
package/src/url-sync.ts
CHANGED
|
@@ -1,7 +1,44 @@
|
|
|
1
1
|
import type { Filter } from './types';
|
|
2
|
-
import {
|
|
2
|
+
import { captureAdminContext } from './context.svelte';
|
|
3
|
+
import type { AdminContextAccessor } from './context.svelte';
|
|
3
4
|
import { getAdminOptions } from './options.svelte';
|
|
4
5
|
|
|
6
|
+
const CRUD_OPERATORS = new Set([
|
|
7
|
+
'eq', 'ne', 'lt', 'gt', 'lte', 'gte',
|
|
8
|
+
'contains', 'ncontains', 'startswith', 'endswith',
|
|
9
|
+
'in', 'nin', 'null', 'nnull', 'between', 'nbetween',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
13
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isFilter(value: unknown, depth: number, budget: { remaining: number }): value is Filter {
|
|
17
|
+
if (depth > 20 || budget.remaining-- <= 0 || !isRecord(value)) return false;
|
|
18
|
+
|
|
19
|
+
if (typeof value.field === 'string') {
|
|
20
|
+
return value.field.trim().length > 0
|
|
21
|
+
&& typeof value.operator === 'string'
|
|
22
|
+
&& CRUD_OPERATORS.has(value.operator)
|
|
23
|
+
&& 'value' in value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (value.operator === 'and' || value.operator === 'or')
|
|
27
|
+
&& Array.isArray(value.value)
|
|
28
|
+
&& value.value.every((entry) => isFilter(entry, depth + 1, budget));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseFilters(value: string): Filter[] | undefined {
|
|
32
|
+
try {
|
|
33
|
+
const parsed: unknown = JSON.parse(value);
|
|
34
|
+
if (!Array.isArray(parsed)) return undefined;
|
|
35
|
+
const budget = { remaining: 200 };
|
|
36
|
+
return parsed.every((entry) => isFilter(entry, 0, budget)) ? parsed : undefined;
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
5
42
|
export interface URLState {
|
|
6
43
|
page?: number;
|
|
7
44
|
pageSize?: number;
|
|
@@ -11,9 +48,9 @@ export interface URLState {
|
|
|
11
48
|
filters?: Filter[];
|
|
12
49
|
}
|
|
13
50
|
|
|
14
|
-
export function readURLState(): URLState {
|
|
51
|
+
export function readURLState(adminContext: AdminContextAccessor = captureAdminContext()): URLState {
|
|
15
52
|
if (typeof window === 'undefined') return {};
|
|
16
|
-
const rp =
|
|
53
|
+
const rp = adminContext.routerProvider;
|
|
17
54
|
if (!rp) return {};
|
|
18
55
|
|
|
19
56
|
const { params } = rp.parse();
|
|
@@ -36,17 +73,19 @@ export function readURLState(): URLState {
|
|
|
36
73
|
|
|
37
74
|
const filtersRaw = params['filters'];
|
|
38
75
|
if (filtersRaw) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
} catch { /* ignore invalid json */ }
|
|
76
|
+
const filters = parseFilters(filtersRaw);
|
|
77
|
+
if (filters) state.filters = filters;
|
|
42
78
|
}
|
|
43
79
|
|
|
44
80
|
return state;
|
|
45
81
|
}
|
|
46
82
|
|
|
47
|
-
export function writeURLState(
|
|
83
|
+
export function writeURLState(
|
|
84
|
+
state: URLState,
|
|
85
|
+
adminContext: AdminContextAccessor = captureAdminContext(),
|
|
86
|
+
): void {
|
|
48
87
|
if (typeof window === 'undefined') return;
|
|
49
|
-
const rp =
|
|
88
|
+
const rp = adminContext.routerProvider;
|
|
50
89
|
if (!rp) return;
|
|
51
90
|
|
|
52
91
|
const current = rp.parse();
|
package/src/useCan.ts
CHANGED
|
@@ -8,6 +8,7 @@ export interface UseCanOptions {
|
|
|
8
8
|
resource: string;
|
|
9
9
|
action: Action;
|
|
10
10
|
params?: Record<string, unknown>;
|
|
11
|
+
meta?: Record<string, unknown>;
|
|
11
12
|
queryOptions?: {
|
|
12
13
|
enabled?: boolean;
|
|
13
14
|
staleTime?: number;
|
|
@@ -34,8 +35,8 @@ export function useCan(options: () => UseCanOptions): UseCanResult {
|
|
|
34
35
|
const query = createQuery<CanResult>(() => {
|
|
35
36
|
const opts = options();
|
|
36
37
|
return {
|
|
37
|
-
queryKey: ['useCan', opts.resource, opts.action, opts.params] as const,
|
|
38
|
-
queryFn: () => canAccessAsync(opts.resource, opts.action, opts.params),
|
|
38
|
+
queryKey: ['useCan', opts.resource, opts.action, opts.params, opts.meta] as const,
|
|
39
|
+
queryFn: () => canAccessAsync(opts.resource, opts.action, opts.params, opts.meta),
|
|
39
40
|
enabled: opts.queryOptions?.enabled ?? true,
|
|
40
41
|
staleTime: opts.queryOptions?.staleTime ?? 5 * 60 * 1000,
|
|
41
42
|
};
|
package/src/useParsed.svelte.ts
CHANGED
|
@@ -1,34 +1,45 @@
|
|
|
1
|
-
/* eslint-disable svelte/prefer-svelte-reactivity */
|
|
2
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
3
2
|
// useParsed — parse current URL hash into structured route info
|
|
4
3
|
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { registerRouterSync } from './router';
|
|
5
|
+
import { captureAdminContext } from './context.svelte';
|
|
7
6
|
|
|
8
7
|
interface ParsedRoute {
|
|
9
8
|
resource?: string;
|
|
10
9
|
resourcePath?: string;
|
|
11
10
|
action?: 'list' | 'create' | 'edit' | 'show' | 'clone';
|
|
12
11
|
id?: string;
|
|
12
|
+
/** Query params parsed from the URL search string (e.g. ?page=2). */
|
|
13
13
|
params: Record<string, string>;
|
|
14
|
+
/** Parent path params derived from nested routes (e.g. /teams/123/users -> teamId). */
|
|
15
|
+
parentParams: Record<string, string>;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
let
|
|
18
|
+
let routeVersion = $state(0);
|
|
17
19
|
|
|
18
20
|
export function resetGlobalPath(): void {
|
|
19
|
-
|
|
21
|
+
routeVersion++;
|
|
20
22
|
}
|
|
21
23
|
export function syncGlobalPath(): void {
|
|
22
|
-
|
|
24
|
+
routeVersion++;
|
|
23
25
|
}
|
|
26
|
+
|
|
27
|
+
/** 简单的英文名词单数化,处理常见复数模式(-ies, -ses, -es, -s) */
|
|
28
|
+
function singularize(word: string): string {
|
|
29
|
+
if (word.endsWith('ies') && word.length > 3) return word.slice(0, -3) + 'y';
|
|
30
|
+
if (word.endsWith('ses') && word.length > 3) return word.slice(0, -2);
|
|
31
|
+
if (word.endsWith('es') && word.length > 2) return word.slice(0, -2);
|
|
32
|
+
if (word.endsWith('s') && word.length > 1) return word.slice(0, -1);
|
|
33
|
+
return word;
|
|
34
|
+
}
|
|
35
|
+
|
|
24
36
|
registerRouterSync(syncGlobalPath);
|
|
25
37
|
|
|
26
38
|
if (typeof window !== 'undefined') {
|
|
27
|
-
globalPath = currentPath();
|
|
28
39
|
if (!(window as any).__svadminParsedInit) {
|
|
29
40
|
(window as any).__svadminParsedInit = true;
|
|
30
|
-
window.addEventListener('hashchange',
|
|
31
|
-
window.addEventListener('popstate',
|
|
41
|
+
window.addEventListener('hashchange', syncGlobalPath);
|
|
42
|
+
window.addEventListener('popstate', syncGlobalPath);
|
|
32
43
|
}
|
|
33
44
|
}
|
|
34
45
|
|
|
@@ -41,13 +52,22 @@ if (typeof window !== 'undefined') {
|
|
|
41
52
|
* // parsed.resource === 'posts'
|
|
42
53
|
* // parsed.action === 'edit'
|
|
43
54
|
* // parsed.id === '123'
|
|
55
|
+
* // parsed.params contains query params only
|
|
56
|
+
* // parsed.parentParams contains nested route params such as teamId
|
|
44
57
|
*/
|
|
45
58
|
export function useParsed(): ParsedRoute {
|
|
46
|
-
const
|
|
59
|
+
const adminContext = captureAdminContext();
|
|
60
|
+
const resources = $derived(adminContext.resources);
|
|
61
|
+
const activePath = $derived.by(() => {
|
|
62
|
+
// Any router navigation increments routeVersion. Each tree then asks its
|
|
63
|
+
// captured provider for its own path instead of consuming a global router.
|
|
64
|
+
void routeVersion;
|
|
65
|
+
return adminContext.currentPath();
|
|
66
|
+
});
|
|
47
67
|
|
|
48
68
|
const parsed = $derived.by(() => {
|
|
49
|
-
const p =
|
|
50
|
-
const result: ParsedRoute = { params: {} };
|
|
69
|
+
const p = activePath;
|
|
70
|
+
const result: ParsedRoute = { params: {}, parentParams: {} };
|
|
51
71
|
|
|
52
72
|
// Parse query params from hash
|
|
53
73
|
const [pathname, queryString] = p.split('?');
|
|
@@ -75,12 +95,15 @@ export function useParsed(): ParsedRoute {
|
|
|
75
95
|
result.resource = segments[resourceIndex];
|
|
76
96
|
result.resourcePath = segments.slice(0, resourceIndex + 1).join('/');
|
|
77
97
|
|
|
78
|
-
// Add parent path params based on `<parentResource>/<parentId>` structure
|
|
98
|
+
// Add parent path params based on `<parentResource>/<parentId>` structure.
|
|
99
|
+
// These are kept separate from query params so that downstream filter
|
|
100
|
+
// injection only applies to path-derived parent params, never to
|
|
101
|
+
// arbitrary query params like ?tenantId=1.
|
|
79
102
|
for (let i = 0; i < resourceIndex; i += 2) {
|
|
80
103
|
if (segments[i] && segments[i + 1]) {
|
|
81
104
|
const parentName = segments[i];
|
|
82
|
-
const singular =
|
|
83
|
-
result.
|
|
105
|
+
const singular = singularize(parentName);
|
|
106
|
+
result.parentParams[`${singular}Id`] = segments[i + 1];
|
|
84
107
|
}
|
|
85
108
|
}
|
|
86
109
|
|
|
@@ -136,5 +159,6 @@ export function useParsed(): ParsedRoute {
|
|
|
136
159
|
get action() { return parsed.action; },
|
|
137
160
|
get id() { return parsed.id; },
|
|
138
161
|
get params() { return parsed.params; },
|
|
162
|
+
get parentParams() { return parsed.parentParams; },
|
|
139
163
|
};
|
|
140
164
|
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
import { useForm, type UseFormOptions, type UseFormReturn } from './form-hooks.svelte';
|
|
1
|
+
import { useForm, type UseFormOptions } from './form-hooks.svelte';
|
|
3
2
|
import { useQueryClient } from '@tanstack/svelte-query';
|
|
4
3
|
import { useParsed } from './useParsed.svelte';
|
|
5
|
-
import {
|
|
4
|
+
import { captureAdminContext } from './context.svelte';
|
|
6
5
|
import { getAdminOptions } from './options.svelte';
|
|
7
6
|
import { t } from './i18n.svelte';
|
|
8
7
|
import type { BaseRecord, HttpError, Filter, KnownResources, ResourceDefinition } from './types';
|
|
9
|
-
import { useList
|
|
8
|
+
import { useList } from './query-hooks.svelte';
|
|
10
9
|
import { toast } from './toast.svelte';
|
|
10
|
+
import type { UseSelectOptions } from './hooks.svelte';
|
|
11
|
+
import { useSelect as useSelectImpl } from './hooks.svelte';
|
|
11
12
|
|
|
12
13
|
// ─── useModal ─────────────────────────────────────────────────
|
|
13
14
|
export function useModal(options?: { defaultVisible?: boolean }) {
|
|
@@ -87,11 +88,12 @@ export interface MenuConfig {
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
export function useMenu() {
|
|
91
|
+
const adminContext = captureAdminContext();
|
|
90
92
|
const parsed = useParsed();
|
|
91
93
|
|
|
92
94
|
const menuItems = $derived.by<MenuConfig[]>(() => {
|
|
93
95
|
const adminOptions = getAdminOptions();
|
|
94
|
-
const resources =
|
|
96
|
+
const resources = adminContext.resources;
|
|
95
97
|
let items: MenuConfig[] = resources
|
|
96
98
|
.filter((r: ResourceDefinition) => r.showInMenu !== false)
|
|
97
99
|
.map((r: ResourceDefinition): MenuConfig => ({
|
|
@@ -123,6 +125,7 @@ export interface BreadcrumbItem {
|
|
|
123
125
|
}
|
|
124
126
|
|
|
125
127
|
export function useBreadcrumb() {
|
|
128
|
+
const adminContext = captureAdminContext();
|
|
126
129
|
const parsed = useParsed();
|
|
127
130
|
|
|
128
131
|
const items = $derived.by<BreadcrumbItem[]>(() => {
|
|
@@ -130,7 +133,7 @@ export function useBreadcrumb() {
|
|
|
130
133
|
if (!parsed.resource) return list;
|
|
131
134
|
|
|
132
135
|
try {
|
|
133
|
-
const res = getResource(parsed.resource);
|
|
136
|
+
const res = adminContext.getResource(parsed.resource);
|
|
134
137
|
const chain: { label: string; link: string; icon?: string }[] = [];
|
|
135
138
|
let current: typeof res | undefined = res;
|
|
136
139
|
const visited = new Set<string>();
|
|
@@ -138,7 +141,7 @@ export function useBreadcrumb() {
|
|
|
138
141
|
if (visited.has(current.name)) break;
|
|
139
142
|
visited.add(current.name);
|
|
140
143
|
chain.unshift({ label: current.label || current.name, link: `/${current.name}`, icon: current.icon });
|
|
141
|
-
current = current.parentName ? getResource(current.parentName) : undefined;
|
|
144
|
+
current = current.parentName ? adminContext.getResource(current.parentName) : undefined;
|
|
142
145
|
}
|
|
143
146
|
list.push(...chain);
|
|
144
147
|
|
|
@@ -181,3 +184,112 @@ export function useRelation<TData extends BaseRecord = BaseRecord, TError = Http
|
|
|
181
184
|
export function useNotification() {
|
|
182
185
|
return { open: toast.info, success: toast.success, error: toast.error, warning: toast.warning };
|
|
183
186
|
}
|
|
187
|
+
|
|
188
|
+
// ─── Field-specific hooks (refine-compatible) ─────────────────────
|
|
189
|
+
// useCheckboxGroup / useRadioGroup / useAutocomplete are convenience
|
|
190
|
+
// wrappers around useSelect for common form field patterns.
|
|
191
|
+
|
|
192
|
+
export interface UseCheckboxGroupOptions<TData extends BaseRecord = BaseRecord> extends Omit<UseSelectOptions<TData>, 'optionLabel' | 'optionValue' | 'defaultValue'> {
|
|
193
|
+
/** Field on the record used as the option label (default: 'title') */
|
|
194
|
+
optionLabel?: string | ((item: TData) => string);
|
|
195
|
+
/** Field on the record used as the checkbox value (default: 'id') */
|
|
196
|
+
optionValue?: string | ((item: TData) => string | number);
|
|
197
|
+
/** Currently selected values — controlled mode */
|
|
198
|
+
defaultValue?: (string | number)[];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* useCheckboxGroup — multi-select field state backed by a resource query.
|
|
203
|
+
* Provides a list of checkbox options plus helpers to toggle individual values.
|
|
204
|
+
*/
|
|
205
|
+
export function useCheckboxGroup<TData extends BaseRecord = BaseRecord>(options: UseCheckboxGroupOptions<TData>) {
|
|
206
|
+
const select = useSelectImpl<TData>(options);
|
|
207
|
+
let selected = $state<(string | number)[]>(options.defaultValue ?? []);
|
|
208
|
+
|
|
209
|
+
function toggle(value: string | number) {
|
|
210
|
+
const idx = selected.findIndex(v => String(v) === String(value));
|
|
211
|
+
if (idx >= 0) {
|
|
212
|
+
selected = selected.filter(v => String(v) !== String(value));
|
|
213
|
+
} else {
|
|
214
|
+
selected = [...selected, value];
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function isSelected(value: string | number): boolean {
|
|
219
|
+
return selected.some(v => String(v) === String(value));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
query: select.query,
|
|
224
|
+
get options() { return select.options; },
|
|
225
|
+
onSearchChange: select.onSearchChange,
|
|
226
|
+
get selected() { return selected; },
|
|
227
|
+
toggle,
|
|
228
|
+
isSelected,
|
|
229
|
+
setSelected: (values: (string | number)[]) => { selected = values; },
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface UseRadioGroupOptions<TData extends BaseRecord = BaseRecord> extends Omit<UseSelectOptions<TData>, 'optionLabel' | 'optionValue' | 'defaultValue'> {
|
|
234
|
+
optionLabel?: string | ((item: TData) => string);
|
|
235
|
+
optionValue?: string | ((item: TData) => string | number);
|
|
236
|
+
defaultValue?: string | number;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* useRadioGroup — single-select field state backed by a resource query.
|
|
241
|
+
* Provides a list of radio options plus a setter for the currently selected value.
|
|
242
|
+
*/
|
|
243
|
+
export function useRadioGroup<TData extends BaseRecord = BaseRecord>(options: UseRadioGroupOptions<TData>) {
|
|
244
|
+
const { defaultValue: _dv, ...selectOpts } = options;
|
|
245
|
+
// useSelect 的 defaultValue 是数组类型;透传单值以便远程选项回填当前已选值
|
|
246
|
+
const select = useSelectImpl<TData>({
|
|
247
|
+
...selectOpts,
|
|
248
|
+
defaultValue: _dv !== undefined ? [_dv] : [],
|
|
249
|
+
});
|
|
250
|
+
let value = $state<string | number | undefined>(_dv);
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
query: select.query,
|
|
254
|
+
get options() { return select.options; },
|
|
255
|
+
onSearchChange: select.onSearchChange,
|
|
256
|
+
get value() { return value; },
|
|
257
|
+
setValue: (v: string | number) => { value = v; },
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export interface UseAutocompleteOptions<TData extends BaseRecord = BaseRecord> extends Omit<UseSelectOptions<TData>, 'onSearch' | 'defaultValue'> {
|
|
262
|
+
optionLabel?: string | ((item: TData) => string);
|
|
263
|
+
optionValue?: string | ((item: TData) => string | number);
|
|
264
|
+
/** Field name to search against (default: optionLabel) */
|
|
265
|
+
searchField?: string;
|
|
266
|
+
defaultValue?: string | number;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* useAutocomplete — typeahead search field backed by a resource query.
|
|
271
|
+
* Builds a 'contains' filter on the search field and debounces input.
|
|
272
|
+
*/
|
|
273
|
+
export function useAutocomplete<TData extends BaseRecord = BaseRecord>(options: UseAutocompleteOptions<TData>) {
|
|
274
|
+
const { searchField, defaultValue: _dv, ...selectOpts } = options;
|
|
275
|
+
const labelField = typeof options.optionLabel === 'string' ? options.optionLabel : 'title';
|
|
276
|
+
|
|
277
|
+
const select = useSelectImpl<TData>({
|
|
278
|
+
...selectOpts,
|
|
279
|
+
defaultValue: _dv !== undefined ? [_dv] : [],
|
|
280
|
+
onSearch: (term) => [{
|
|
281
|
+
field: searchField ?? labelField,
|
|
282
|
+
operator: 'contains' as const,
|
|
283
|
+
value: term,
|
|
284
|
+
}],
|
|
285
|
+
});
|
|
286
|
+
let value = $state<string | number | undefined>(_dv);
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
query: select.query,
|
|
290
|
+
get options() { return select.options; },
|
|
291
|
+
onSearchChange: select.onSearchChange,
|
|
292
|
+
get value() { return value; },
|
|
293
|
+
setValue: (v: string | number) => { value = v; },
|
|
294
|
+
};
|
|
295
|
+
}
|