@svadmin/core 0.30.0 → 0.31.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 +3 -3
- package/src/auth-hooks.svelte.ts +18 -1
- package/src/data-transfer.svelte.ts +46 -38
- package/src/export-format.ts +115 -0
- package/src/i18n.svelte.ts +258 -2
- package/src/index.ts +6 -4
- package/src/permissions.svelte.ts +4 -3
- package/src/query-hooks.svelte.ts +19 -2
- package/src/routing-hooks.svelte.ts +9 -0
- package/src/types.ts +3 -0
- package/src/useCan.ts +3 -2
- package/src/useParsed.svelte.ts +23 -4
- package/src/utility-hooks.svelte.ts +111 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"author": "zuohuadong",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"default": "./src/i18n.svelte.ts"
|
|
39
39
|
},
|
|
40
40
|
"./permissions": {
|
|
41
|
-
"types": "./src/permissions.ts",
|
|
42
|
-
"default": "./src/permissions.ts"
|
|
41
|
+
"types": "./src/permissions.svelte.ts",
|
|
42
|
+
"default": "./src/permissions.svelte.ts"
|
|
43
43
|
},
|
|
44
44
|
"./toast": {
|
|
45
45
|
"types": "./src/toast.svelte.ts",
|
package/src/auth-hooks.svelte.ts
CHANGED
|
@@ -114,6 +114,24 @@ export function useUpdatePassword() {
|
|
|
114
114
|
});
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
// ─── useUpdateIdentity / useUpdateProfile ────────────────────
|
|
118
|
+
|
|
119
|
+
export function useUpdateIdentity() {
|
|
120
|
+
return createAuthMutation({
|
|
121
|
+
method: 'updateIdentity',
|
|
122
|
+
successMessage: t('common.operationSuccess'),
|
|
123
|
+
onSuccess: (result) => { if (result.redirectTo) navigate(result.redirectTo); }
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function useUpdateProfile() {
|
|
128
|
+
return createAuthMutation({
|
|
129
|
+
method: 'updateProfile',
|
|
130
|
+
successMessage: t('common.operationSuccess'),
|
|
131
|
+
onSuccess: (result) => { if (result.redirectTo) navigate(result.redirectTo); }
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
117
135
|
// ─── useGetIdentity ──────────────────────────────────────────
|
|
118
136
|
|
|
119
137
|
export function useGetIdentity() {
|
|
@@ -297,4 +315,3 @@ export function usePermissions<T = unknown>() {
|
|
|
297
315
|
can: (resource: string, action: string): boolean => hasFn(`${resource}:${action}`),
|
|
298
316
|
};
|
|
299
317
|
}
|
|
300
|
-
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { getDataProviderForResource } from './context.svelte';
|
|
2
2
|
import { useParsed } from './useParsed.svelte';
|
|
3
3
|
import type { Sort, Filter, BaseRecord } from './types';
|
|
4
|
+
import { downloadData } from './export-format';
|
|
5
|
+
import type { ExportFormat } from './export-format';
|
|
6
|
+
export { downloadData, toCsv, toJson, toXlsx, escapeCsvField } from './export-format';
|
|
7
|
+
export type { ExportFormat } from './export-format';
|
|
4
8
|
|
|
5
9
|
export interface UseExportOptions<TData extends BaseRecord = BaseRecord> {
|
|
6
10
|
resource?: string;
|
|
@@ -10,13 +14,15 @@ export interface UseExportOptions<TData extends BaseRecord = BaseRecord> {
|
|
|
10
14
|
maxItemCount?: number;
|
|
11
15
|
pageSize?: number;
|
|
12
16
|
download?: boolean;
|
|
17
|
+
/** 导出格式,默认 csv(兼容旧版) */
|
|
18
|
+
format?: ExportFormat;
|
|
13
19
|
meta?: Record<string, unknown>;
|
|
14
20
|
dataProviderName?: string;
|
|
15
21
|
onError?: (error: Error) => void;
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
/**
|
|
19
|
-
* useExport — export records from a resource as CSV download
|
|
25
|
+
* useExport — export records from a resource as CSV/JSON/XLSX download
|
|
20
26
|
* Supports mapData, sorters, filters, maxItemCount, pageSize
|
|
21
27
|
*/
|
|
22
28
|
export function useExport<TData extends BaseRecord = BaseRecord>(options: UseExportOptions<TData> = {}) {
|
|
@@ -57,29 +63,7 @@ export function useExport<TData extends BaseRecord = BaseRecord>(options: UseExp
|
|
|
57
63
|
: allRecords.map(r => r as unknown as Record<string, unknown>);
|
|
58
64
|
|
|
59
65
|
if (options.download !== false && typeof document !== 'undefined') {
|
|
60
|
-
|
|
61
|
-
const escapeCsvField = (s: string) => {
|
|
62
|
-
let val = s;
|
|
63
|
-
if (/^[=+@\t\r]/.test(val)) val = "'" + val;
|
|
64
|
-
return val.includes(',') || val.includes('"') || val.includes('\n') || val.includes('\r') ? `"${val.replace(/"/g, '""')}"` : val;
|
|
65
|
-
};
|
|
66
|
-
const header = fields.map(escapeCsvField).join(',');
|
|
67
|
-
const rows = mapped.map(record =>
|
|
68
|
-
fields.map(f => {
|
|
69
|
-
const val = record[f];
|
|
70
|
-
const str = val === null || val === undefined ? '' : typeof val === 'object' ? JSON.stringify(val) : String(val);
|
|
71
|
-
return escapeCsvField(str);
|
|
72
|
-
}).join(',')
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
const csv = [header, ...rows].join('\n');
|
|
76
|
-
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
|
77
|
-
const url = URL.createObjectURL(blob);
|
|
78
|
-
const a = document.createElement('a');
|
|
79
|
-
a.href = url;
|
|
80
|
-
a.download = `${exportResource}_export.csv`;
|
|
81
|
-
a.click();
|
|
82
|
-
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
|
66
|
+
downloadData(mapped, exportResource, options.format ?? 'csv');
|
|
83
67
|
}
|
|
84
68
|
|
|
85
69
|
return allRecords;
|
|
@@ -99,6 +83,8 @@ export function useExport<TData extends BaseRecord = BaseRecord>(options: UseExp
|
|
|
99
83
|
|
|
100
84
|
export interface UseImportOptions<TData = Record<string, unknown>> {
|
|
101
85
|
resource?: string;
|
|
86
|
+
/** 指定导入文件格式;默认 auto = 根据文件扩展名推断。注意:import 仅支持 csv 和 json,不支持 xlsx 解析 */
|
|
87
|
+
format?: 'auto' | 'csv' | 'json';
|
|
102
88
|
mapData?: (item: Record<string, unknown>) => TData;
|
|
103
89
|
batchSize?: number;
|
|
104
90
|
onFinish?: (result: { succeeded: unknown[]; errored: { request: unknown; error: unknown }[] }) => void;
|
|
@@ -131,21 +117,40 @@ export function useImport<TData = Record<string, unknown>>(options: UseImportOpt
|
|
|
131
117
|
text = text.slice(1);
|
|
132
118
|
}
|
|
133
119
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
options.onFinish?.({ succeeded: [], errored: [] });
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
120
|
+
// 自动检测或使用指定格式
|
|
121
|
+
const format = options.format ?? 'auto';
|
|
122
|
+
const isJson = format === 'json' || (format === 'auto' && info.file.name.toLowerCase().endsWith('.json'));
|
|
140
123
|
|
|
141
|
-
|
|
142
|
-
const records: Record<string, unknown>[] = [];
|
|
124
|
+
let records: Record<string, unknown>[];
|
|
143
125
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
126
|
+
if (isJson) {
|
|
127
|
+
// JSON 格式:期望数组对象
|
|
128
|
+
const parsed = JSON.parse(text);
|
|
129
|
+
if (!Array.isArray(parsed)) {
|
|
130
|
+
mutationResult = { succeeded: [], errored: [{ request: null, error: new Error('JSON import requires an array of objects') }] };
|
|
131
|
+
options.onFinish?.(mutationResult);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
records = parsed.map((item: unknown) =>
|
|
135
|
+
options.mapData ? (options.mapData(item as Record<string, unknown>) as unknown as Record<string, unknown>) : (item as Record<string, unknown>)
|
|
136
|
+
);
|
|
137
|
+
} else {
|
|
138
|
+
// CSV 格式
|
|
139
|
+
const rows = parseCSV(text);
|
|
140
|
+
if (rows.length < 2) {
|
|
141
|
+
mutationResult = { succeeded: [], errored: [] };
|
|
142
|
+
options.onFinish?.({ succeeded: [], errored: [] });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const headers = rows[0];
|
|
147
|
+
records = [];
|
|
148
|
+
for (let i = 1; i < rows.length; i++) {
|
|
149
|
+
const values = rows[i];
|
|
150
|
+
const record: Record<string, unknown> = {};
|
|
151
|
+
headers.forEach((h, idx) => { record[h] = values[idx] ?? ''; });
|
|
152
|
+
records.push(options.mapData ? (options.mapData(record) as unknown as Record<string, unknown>) : record);
|
|
153
|
+
}
|
|
149
154
|
}
|
|
150
155
|
|
|
151
156
|
const batchSize = options.batchSize ?? 1;
|
|
@@ -198,13 +203,16 @@ export function useImport<TData = Record<string, unknown>>(options: UseImportOpt
|
|
|
198
203
|
}
|
|
199
204
|
|
|
200
205
|
return {
|
|
201
|
-
inputProps: { type: 'file' as const, accept: '.csv' },
|
|
206
|
+
inputProps: { type: 'file' as const, accept: '.csv,.json' },
|
|
202
207
|
handleChange,
|
|
203
208
|
get isLoading() { return isLoading; },
|
|
204
209
|
get mutationResult() { return mutationResult; },
|
|
205
210
|
};
|
|
206
211
|
}
|
|
207
212
|
|
|
213
|
+
|
|
214
|
+
// ─── 解析工具 ────────────────────────────────────────────────────
|
|
215
|
+
|
|
208
216
|
/** Parse full CSV text respecting quoted multi-line fields */
|
|
209
217
|
function parseCSV(text: string): string[][] {
|
|
210
218
|
const rows: string[][] = [];
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// 纯函数导出格式化工具 — 不依赖 Svelte runes 或浏览器 API(downloadData 除外)
|
|
2
|
+
// 可被 bun:test 直接测试
|
|
3
|
+
|
|
4
|
+
/** 支持的导出/导入格式 */
|
|
5
|
+
export type ExportFormat = 'csv' | 'json' | 'xlsx';
|
|
6
|
+
|
|
7
|
+
/** CSV 转义,含公式注入防护 */
|
|
8
|
+
export function escapeCsvField(s: string): string {
|
|
9
|
+
let val = s;
|
|
10
|
+
if (/^[=+@\t\r]/.test(val)) val = "'" + val;
|
|
11
|
+
return val.includes(',') || val.includes('"') || val.includes('\n') || val.includes('\r')
|
|
12
|
+
? `"${val.replace(/"/g, '""')}"`
|
|
13
|
+
: val;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 将记录数组转为 CSV 字符串 */
|
|
17
|
+
export function toCsv(records: Record<string, unknown>[]): string {
|
|
18
|
+
if (records.length === 0) return '';
|
|
19
|
+
const fields = Object.keys(records[0]);
|
|
20
|
+
const header = fields.map(escapeCsvField).join(',');
|
|
21
|
+
const rows = records.map(record =>
|
|
22
|
+
fields.map(f => {
|
|
23
|
+
const val = record[f];
|
|
24
|
+
const str = val === null || val === undefined ? '' : typeof val === 'object' ? JSON.stringify(val) : String(val);
|
|
25
|
+
return escapeCsvField(str);
|
|
26
|
+
}).join(',')
|
|
27
|
+
);
|
|
28
|
+
return [header, ...rows].join('\n');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 将记录数组转为格式化的 JSON 字符串 */
|
|
32
|
+
export function toJson(records: Record<string, unknown>[]): string {
|
|
33
|
+
return JSON.stringify(records, null, 2);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 转义 XML 特殊字符 */
|
|
37
|
+
export function escapeXml(s: string): string {
|
|
38
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 生成最小可用的 XLSX(SpreadsheetML)——零依赖。
|
|
43
|
+
*/
|
|
44
|
+
export function toXlsx(records: Record<string, unknown>[]): string {
|
|
45
|
+
if (records.length === 0) return '';
|
|
46
|
+
const fields = Object.keys(records[0]);
|
|
47
|
+
|
|
48
|
+
const headerCells = fields.map(f => `<Cell><Data ss:Type="String">${escapeXml(f)}</Data></Cell>`).join('');
|
|
49
|
+
const dataRows = records.map(record =>
|
|
50
|
+
`<Row>${fields.map(f => {
|
|
51
|
+
const val = record[f];
|
|
52
|
+
if (val === null || val === undefined) return '<Cell><Data ss:Type="String"></Data></Cell>';
|
|
53
|
+
if (typeof val === 'number') return `<Cell><Data ss:Type="Number">${val}</Data></Cell>`;
|
|
54
|
+
if (typeof val === 'boolean') return `<Cell><Data ss:Type="Boolean">${val ? 1 : 0}</Data></Cell>`;
|
|
55
|
+
const str = typeof val === 'object' ? JSON.stringify(val) : String(val);
|
|
56
|
+
return `<Cell><Data ss:Type="String">${escapeXml(str)}</Data></Cell>`;
|
|
57
|
+
}).join('')}</Row>`
|
|
58
|
+
).join('\n');
|
|
59
|
+
|
|
60
|
+
return `<?xml version="1.0"?>
|
|
61
|
+
<?mso-application progid="Excel.Sheet"?>
|
|
62
|
+
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
|
|
63
|
+
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
|
|
64
|
+
<Worksheet ss:Name="Export">
|
|
65
|
+
<Table>
|
|
66
|
+
<Row>${headerCells}</Row>
|
|
67
|
+
${dataRows}
|
|
68
|
+
</Table>
|
|
69
|
+
</Worksheet>
|
|
70
|
+
</Workbook>`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 将记录以指定格式下载为文件。
|
|
75
|
+
* 依赖浏览器 document API;在 Node/bun:test 中需 mock。
|
|
76
|
+
*/
|
|
77
|
+
export function downloadData(
|
|
78
|
+
records: Record<string, unknown>[],
|
|
79
|
+
resource: string,
|
|
80
|
+
format: ExportFormat = 'csv',
|
|
81
|
+
): void {
|
|
82
|
+
if (records.length === 0) return;
|
|
83
|
+
if (typeof document === 'undefined') return;
|
|
84
|
+
|
|
85
|
+
let content: string;
|
|
86
|
+
let mimeType: string;
|
|
87
|
+
let ext: string;
|
|
88
|
+
|
|
89
|
+
switch (format) {
|
|
90
|
+
case 'json':
|
|
91
|
+
content = toJson(records);
|
|
92
|
+
mimeType = 'application/json;charset=utf-8;';
|
|
93
|
+
ext = 'json';
|
|
94
|
+
break;
|
|
95
|
+
case 'xlsx':
|
|
96
|
+
content = toXlsx(records);
|
|
97
|
+
mimeType = 'application/vnd.ms-excel;charset=utf-8;';
|
|
98
|
+
ext = 'xls';
|
|
99
|
+
break;
|
|
100
|
+
case 'csv':
|
|
101
|
+
default:
|
|
102
|
+
content = toCsv(records);
|
|
103
|
+
mimeType = 'text/csv;charset=utf-8;';
|
|
104
|
+
ext = 'csv';
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const blob = new Blob(['\uFEFF' + content], { type: mimeType });
|
|
109
|
+
const url = URL.createObjectURL(blob);
|
|
110
|
+
const a = document.createElement('a');
|
|
111
|
+
a.href = url;
|
|
112
|
+
a.download = `${resource}_export.${ext}`;
|
|
113
|
+
a.click();
|
|
114
|
+
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
|
115
|
+
}
|
package/src/i18n.svelte.ts
CHANGED
|
@@ -122,6 +122,7 @@ const baseLocales: Record<string, Record<string, string>> = {
|
|
|
122
122
|
'auth.hasAccount': '已有账号?',
|
|
123
123
|
'auth.forgotPasswordLink': '忘记密码?',
|
|
124
124
|
'auth.forgotPasswordDescription': '输入您的邮箱地址,我们将发送重置密码的链接。',
|
|
125
|
+
'auth.resetPasswordDescription': '设置一个新的登录密码。',
|
|
125
126
|
'auth.resetLinkSent': '重置链接已发送,请查看您的邮箱。',
|
|
126
127
|
'auth.passwordMismatch': '两次输入的密码不一致',
|
|
127
128
|
'auth.emailRequired': '请输入邮箱',
|
|
@@ -309,6 +310,114 @@ const baseLocales: Record<string, Record<string, string>> = {
|
|
|
309
310
|
'profile.currentPassword': '当前密码',
|
|
310
311
|
'profile.passwordChanged': '密码已修改',
|
|
311
312
|
'profile.updatePassword': '更新密码',
|
|
313
|
+
'profile.newPassword': '新密码',
|
|
314
|
+
'profile.company': '企业',
|
|
315
|
+
'profile.gamer': '玩家',
|
|
316
|
+
'profile.companyName': '公司名称',
|
|
317
|
+
'profile.industry': '行业',
|
|
318
|
+
'profile.website': '网站',
|
|
319
|
+
'profile.employees': '员工',
|
|
320
|
+
'profile.founded': '成立于',
|
|
321
|
+
'profile.companyDescription': '公司介绍',
|
|
322
|
+
'profile.level': '等级',
|
|
323
|
+
'profile.gamesPlayed': '游戏场次',
|
|
324
|
+
'profile.winRate': '胜率',
|
|
325
|
+
'profile.rank': '段位',
|
|
326
|
+
// Public profile
|
|
327
|
+
'publicProfile.projects': '项目',
|
|
328
|
+
'publicProfile.projects2Col': '两栏项目',
|
|
329
|
+
'publicProfile.projects3Col': '三栏项目',
|
|
330
|
+
'publicProfile.activity': '活动',
|
|
331
|
+
'publicProfile.teams': '团队',
|
|
332
|
+
'publicProfile.followers': '关注者',
|
|
333
|
+
'publicProfile.following': '正在关注',
|
|
334
|
+
'publicProfile.editProfile': '编辑资料',
|
|
335
|
+
'publicProfile.sendMessage': '发送消息',
|
|
336
|
+
'publicProfile.joinedDate': '{date} 加入',
|
|
337
|
+
'publicProfile.noProjects': '暂无项目',
|
|
338
|
+
'publicProfile.noActivity': '暂无活动',
|
|
339
|
+
'publicProfile.noTeams': '暂无团队',
|
|
340
|
+
'publicProfile.projectMembers': '{count} 名成员',
|
|
341
|
+
'publicProfile.projectTasks': '{count} 个任务',
|
|
342
|
+
'publicProfile.viewProfile': '查看资料',
|
|
343
|
+
'publicProfile.activityPosted': '发布了动态',
|
|
344
|
+
'publicProfile.activityCommented': '评论了',
|
|
345
|
+
'publicProfile.activityJoined': '加入了',
|
|
346
|
+
'publicProfile.activityCreated': '创建了',
|
|
347
|
+
// Account demo pages
|
|
348
|
+
'account.getStarted': '开始使用',
|
|
349
|
+
'account.getStartedDescription': '按照步骤补齐资料、连接服务并邀请团队成员。',
|
|
350
|
+
'account.uploadAvatar': '上传头像',
|
|
351
|
+
'account.setupProfile': '完善个人资料',
|
|
352
|
+
'account.connectProvider': '连接身份提供方',
|
|
353
|
+
'account.setupTeam': '创建团队空间',
|
|
354
|
+
'account.completeSetup': '完成设置',
|
|
355
|
+
'account.skipSetup': '跳过',
|
|
356
|
+
'account.step': '第 {current} / {total} 步',
|
|
357
|
+
'account.companyProfile': '公司资料',
|
|
358
|
+
'account.companyProfileDescription': '维护组织基础信息、行业属性和公开展示内容。',
|
|
359
|
+
'account.settingsPlain': '纯净设置',
|
|
360
|
+
'account.settingsPlainDescription': '以单页表单方式管理账号、安全与通知偏好。',
|
|
361
|
+
'account.settingsSidebar': '侧边栏设置',
|
|
362
|
+
'account.settingsSidebarDescription': '调整导航密度、默认折叠和菜单显示偏好。',
|
|
363
|
+
'account.settingsEnterprise': '企业设置',
|
|
364
|
+
'account.settingsEnterpriseDescription': '配置 SSO、会话策略、审计日志和合规保留周期。',
|
|
365
|
+
'account.membersStarter': '成员入门',
|
|
366
|
+
'account.membersStarterDescription': '快速邀请核心成员并检查初始角色配置。',
|
|
367
|
+
'account.teamMembers': '团队成员',
|
|
368
|
+
'account.teamMembersDescription': '按部门、角色、状态和最近活跃时间管理团队名册。',
|
|
369
|
+
'account.inviteMember': '邀请成员',
|
|
370
|
+
'account.active': '活跃',
|
|
371
|
+
'account.invited': '已邀请',
|
|
372
|
+
'account.inactive': '未活跃',
|
|
373
|
+
'account.member': '成员',
|
|
374
|
+
'account.role': '角色',
|
|
375
|
+
'account.status': '状态',
|
|
376
|
+
'account.joined': '加入时间',
|
|
377
|
+
'account.importMembers': '导入成员',
|
|
378
|
+
'account.importMembersDescription': '上传 CSV 或 Excel 文件,批量创建成员并预分配角色。',
|
|
379
|
+
'account.importComplete': '导入完成',
|
|
380
|
+
'account.imported': '已成功导入 {count} 位成员。',
|
|
381
|
+
'account.dragOrClick': '拖拽文件到这里,或点击选择文件',
|
|
382
|
+
'account.supportedFormats': '支持 CSV、XLSX,建议不超过 5MB。',
|
|
383
|
+
'account.importProgress': '导入中',
|
|
384
|
+
'account.startImport': '开始导入',
|
|
385
|
+
'account.downloadTemplate': '下载模板',
|
|
386
|
+
'account.securityLog': '安全日志',
|
|
387
|
+
'account.securityLogDescription': '查看登录、密钥、2FA 和会话变更记录。',
|
|
388
|
+
// Security log
|
|
389
|
+
'securityLog.event': '事件',
|
|
390
|
+
'securityLog.ip': 'IP 地址',
|
|
391
|
+
'securityLog.device': '设备',
|
|
392
|
+
'securityLog.location': '位置',
|
|
393
|
+
'securityLog.time': '时间',
|
|
394
|
+
'securityLog.loginSuccess': '登录成功',
|
|
395
|
+
'securityLog.loginFailed': '登录失败',
|
|
396
|
+
'securityLog.apiKeyCreated': '创建 API 密钥',
|
|
397
|
+
'securityLog.apiKeyRevoked': '撤销 API 密钥',
|
|
398
|
+
'securityLog.twoFactorEnabled': '启用两步验证',
|
|
399
|
+
'securityLog.passwordChanged': '修改密码',
|
|
400
|
+
'securityLog.sessionRevoked': '撤销会话',
|
|
401
|
+
// Network
|
|
402
|
+
'network.userCards': '用户卡片',
|
|
403
|
+
'network.userCardsDescription': '以收藏卡片方式展示社区、资产和连接状态。',
|
|
404
|
+
'network.teamCrew': '团队成员表',
|
|
405
|
+
'network.teamCrewDescription': '用表格方式查看跨部门成员、在线状态和项目投入。',
|
|
406
|
+
'network.nftCollection': '藏品',
|
|
407
|
+
'network.floorPrice': '底价',
|
|
408
|
+
'network.volume': '交易量',
|
|
409
|
+
'network.connected': '已连接',
|
|
410
|
+
'network.connect': '连接',
|
|
411
|
+
// Two-factor authentication
|
|
412
|
+
'auth.twoFactorSetup': '两步验证设置',
|
|
413
|
+
'auth.twoFactorScanQR': '扫描二维码',
|
|
414
|
+
'auth.twoFactorEnterCode': '输入验证码',
|
|
415
|
+
'auth.twoFactorSaveRecovery': '保存恢复码',
|
|
416
|
+
'auth.twoFactorRecoveryHint': '请把恢复码保存在安全位置,手机不可用时可以用它登录。',
|
|
417
|
+
'auth.twoFactorVerify': '验证代码',
|
|
418
|
+
'auth.twoFactorRecovery': '恢复码',
|
|
419
|
+
'auth.twoFactorVerifySuccess': '两步验证已启用',
|
|
420
|
+
'auth.twoFactorDisable': '停用两步验证',
|
|
312
421
|
// Tasks
|
|
313
422
|
'task.drawerTitle': '任务中心',
|
|
314
423
|
'task.listTitle': '任务列表',
|
|
@@ -492,6 +601,7 @@ const baseLocales: Record<string, Record<string, string>> = {
|
|
|
492
601
|
'auth.hasAccount': 'Already have an account?',
|
|
493
602
|
'auth.forgotPasswordLink': 'Forgot password?',
|
|
494
603
|
'auth.forgotPasswordDescription': 'Enter your email address and we will send you a reset link.',
|
|
604
|
+
'auth.resetPasswordDescription': 'Set a new sign-in password.',
|
|
495
605
|
'auth.resetLinkSent': 'Reset link sent! Check your email.',
|
|
496
606
|
'auth.passwordMismatch': 'Passwords do not match',
|
|
497
607
|
'auth.emailRequired': 'Email is required',
|
|
@@ -679,6 +789,114 @@ const baseLocales: Record<string, Record<string, string>> = {
|
|
|
679
789
|
'profile.currentPassword': 'Current Password',
|
|
680
790
|
'profile.passwordChanged': 'Password updated successfully',
|
|
681
791
|
'profile.updatePassword': 'Update Password',
|
|
792
|
+
'profile.newPassword': 'New Password',
|
|
793
|
+
'profile.company': 'Company',
|
|
794
|
+
'profile.gamer': 'Gamer',
|
|
795
|
+
'profile.companyName': 'Company Name',
|
|
796
|
+
'profile.industry': 'Industry',
|
|
797
|
+
'profile.website': 'Website',
|
|
798
|
+
'profile.employees': 'Employees',
|
|
799
|
+
'profile.founded': 'Founded',
|
|
800
|
+
'profile.companyDescription': 'Company Description',
|
|
801
|
+
'profile.level': 'Level',
|
|
802
|
+
'profile.gamesPlayed': 'Games Played',
|
|
803
|
+
'profile.winRate': 'Win Rate',
|
|
804
|
+
'profile.rank': 'Rank',
|
|
805
|
+
// Public profile
|
|
806
|
+
'publicProfile.projects': 'Projects',
|
|
807
|
+
'publicProfile.projects2Col': 'Projects - 2 Columns',
|
|
808
|
+
'publicProfile.projects3Col': 'Projects - 3 Columns',
|
|
809
|
+
'publicProfile.activity': 'Activity',
|
|
810
|
+
'publicProfile.teams': 'Teams',
|
|
811
|
+
'publicProfile.followers': 'Followers',
|
|
812
|
+
'publicProfile.following': 'Following',
|
|
813
|
+
'publicProfile.editProfile': 'Edit Profile',
|
|
814
|
+
'publicProfile.sendMessage': 'Send Message',
|
|
815
|
+
'publicProfile.joinedDate': 'Joined {date}',
|
|
816
|
+
'publicProfile.noProjects': 'No projects yet',
|
|
817
|
+
'publicProfile.noActivity': 'No activity yet',
|
|
818
|
+
'publicProfile.noTeams': 'No teams yet',
|
|
819
|
+
'publicProfile.projectMembers': '{count} members',
|
|
820
|
+
'publicProfile.projectTasks': '{count} tasks',
|
|
821
|
+
'publicProfile.viewProfile': 'View Profile',
|
|
822
|
+
'publicProfile.activityPosted': 'posted',
|
|
823
|
+
'publicProfile.activityCommented': 'commented on',
|
|
824
|
+
'publicProfile.activityJoined': 'joined',
|
|
825
|
+
'publicProfile.activityCreated': 'created',
|
|
826
|
+
// Account demo pages
|
|
827
|
+
'account.getStarted': 'Get Started',
|
|
828
|
+
'account.getStartedDescription': 'Complete profile basics, connect services, and invite your team.',
|
|
829
|
+
'account.uploadAvatar': 'Upload Avatar',
|
|
830
|
+
'account.setupProfile': 'Set Up Profile',
|
|
831
|
+
'account.connectProvider': 'Connect Identity Provider',
|
|
832
|
+
'account.setupTeam': 'Set Up Team',
|
|
833
|
+
'account.completeSetup': 'Complete Setup',
|
|
834
|
+
'account.skipSetup': 'Skip',
|
|
835
|
+
'account.step': 'Step {current} of {total}',
|
|
836
|
+
'account.companyProfile': 'Company Profile',
|
|
837
|
+
'account.companyProfileDescription': 'Maintain organization details, industry fields, and public profile content.',
|
|
838
|
+
'account.settingsPlain': 'Settings - Plain',
|
|
839
|
+
'account.settingsPlainDescription': 'Manage account, security, and notification preferences in one page.',
|
|
840
|
+
'account.settingsSidebar': 'Settings - Sidebar',
|
|
841
|
+
'account.settingsSidebarDescription': 'Adjust navigation density, default collapse, and menu display preferences.',
|
|
842
|
+
'account.settingsEnterprise': 'Enterprise Settings',
|
|
843
|
+
'account.settingsEnterpriseDescription': 'Configure SSO, session policy, audit logs, and compliance retention.',
|
|
844
|
+
'account.membersStarter': 'Members Starter',
|
|
845
|
+
'account.membersStarterDescription': 'Invite core members and check initial role setup.',
|
|
846
|
+
'account.teamMembers': 'Team Members',
|
|
847
|
+
'account.teamMembersDescription': 'Manage the team roster by department, role, status, and recent activity.',
|
|
848
|
+
'account.inviteMember': 'Invite Member',
|
|
849
|
+
'account.active': 'Active',
|
|
850
|
+
'account.invited': 'Invited',
|
|
851
|
+
'account.inactive': 'Inactive',
|
|
852
|
+
'account.member': 'Member',
|
|
853
|
+
'account.role': 'Role',
|
|
854
|
+
'account.status': 'Status',
|
|
855
|
+
'account.joined': 'Joined',
|
|
856
|
+
'account.importMembers': 'Import Members',
|
|
857
|
+
'account.importMembersDescription': 'Upload a CSV or Excel file to create members and pre-assign roles in bulk.',
|
|
858
|
+
'account.importComplete': 'Import Complete',
|
|
859
|
+
'account.imported': 'Imported {count} members successfully.',
|
|
860
|
+
'account.dragOrClick': 'Drag a file here, or click to select one',
|
|
861
|
+
'account.supportedFormats': 'CSV and XLSX supported, up to 5MB recommended.',
|
|
862
|
+
'account.importProgress': 'Importing',
|
|
863
|
+
'account.startImport': 'Start Import',
|
|
864
|
+
'account.downloadTemplate': 'Download Template',
|
|
865
|
+
'account.securityLog': 'Security Log',
|
|
866
|
+
'account.securityLogDescription': 'Review login, key, 2FA, and session change events.',
|
|
867
|
+
// Security log
|
|
868
|
+
'securityLog.event': 'Event',
|
|
869
|
+
'securityLog.ip': 'IP Address',
|
|
870
|
+
'securityLog.device': 'Device',
|
|
871
|
+
'securityLog.location': 'Location',
|
|
872
|
+
'securityLog.time': 'Time',
|
|
873
|
+
'securityLog.loginSuccess': 'Login successful',
|
|
874
|
+
'securityLog.loginFailed': 'Login failed',
|
|
875
|
+
'securityLog.apiKeyCreated': 'API key created',
|
|
876
|
+
'securityLog.apiKeyRevoked': 'API key revoked',
|
|
877
|
+
'securityLog.twoFactorEnabled': 'Two-factor authentication enabled',
|
|
878
|
+
'securityLog.passwordChanged': 'Password changed',
|
|
879
|
+
'securityLog.sessionRevoked': 'Session revoked',
|
|
880
|
+
// Network
|
|
881
|
+
'network.userCards': 'User Cards',
|
|
882
|
+
'network.userCardsDescription': 'Showcase community profiles, asset collections, and connection status.',
|
|
883
|
+
'network.teamCrew': 'Team Crew',
|
|
884
|
+
'network.teamCrewDescription': 'Review cross-functional members, presence, and project commitment in a table.',
|
|
885
|
+
'network.nftCollection': 'Collection',
|
|
886
|
+
'network.floorPrice': 'Floor Price',
|
|
887
|
+
'network.volume': 'Volume',
|
|
888
|
+
'network.connected': 'Connected',
|
|
889
|
+
'network.connect': 'Connect',
|
|
890
|
+
// Two-factor authentication
|
|
891
|
+
'auth.twoFactorSetup': 'Two-Factor Authentication Setup',
|
|
892
|
+
'auth.twoFactorScanQR': 'Scan QR Code',
|
|
893
|
+
'auth.twoFactorEnterCode': 'Enter Verification Code',
|
|
894
|
+
'auth.twoFactorSaveRecovery': 'Save Recovery Codes',
|
|
895
|
+
'auth.twoFactorRecoveryHint': 'Keep recovery codes somewhere safe so you can sign in if your device is unavailable.',
|
|
896
|
+
'auth.twoFactorVerify': 'Verify Code',
|
|
897
|
+
'auth.twoFactorRecovery': 'Recovery Codes',
|
|
898
|
+
'auth.twoFactorVerifySuccess': 'Two-factor authentication is enabled',
|
|
899
|
+
'auth.twoFactorDisable': 'Disable Two-Factor Authentication',
|
|
682
900
|
// Tasks
|
|
683
901
|
'task.drawerTitle': 'Task Center',
|
|
684
902
|
'task.listTitle': 'Tasks',
|
|
@@ -763,19 +981,56 @@ function detectLocale(): string {
|
|
|
763
981
|
|
|
764
982
|
let currentLocale = $state(detectLocale());
|
|
765
983
|
|
|
984
|
+
/**
|
|
985
|
+
* I18nProvider — pluggable translation boundary.
|
|
986
|
+
* When set, all t/getLocale/getAvailableLocales delegates to it first,
|
|
987
|
+
* falling back to the built-in locale tables. This lets apps integrate
|
|
988
|
+
* existing i18n systems (svelte-i18n, paraglide, etc.) without rewriting callsites.
|
|
989
|
+
*/
|
|
990
|
+
export interface I18nProvider {
|
|
991
|
+
translate: (key: string, params?: Record<string, string | number>) => string;
|
|
992
|
+
getLocale: () => string;
|
|
993
|
+
setLocale: (locale: string) => void;
|
|
994
|
+
getAvailableLocales?: () => string[];
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
let i18nProvider: I18nProvider | undefined;
|
|
998
|
+
|
|
999
|
+
export function setI18nProvider(provider: I18nProvider | undefined): void {
|
|
1000
|
+
i18nProvider = provider;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
export function getI18nProvider(): I18nProvider | undefined {
|
|
1004
|
+
return i18nProvider;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
766
1007
|
export function setLocale(locale: string): void {
|
|
1008
|
+
if (i18nProvider) {
|
|
1009
|
+
i18nProvider.setLocale(locale);
|
|
1010
|
+
}
|
|
1011
|
+
// 同步更新 local state,确保 Svelte $state 依赖追踪触发模板刷新
|
|
767
1012
|
currentLocale = locale;
|
|
768
1013
|
}
|
|
769
1014
|
|
|
770
1015
|
export function getLocale(): string {
|
|
1016
|
+
// 读取 currentLocale 以保持 Svelte $state 响应式追踪
|
|
1017
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
1018
|
+
currentLocale;
|
|
1019
|
+
if (i18nProvider) return i18nProvider.getLocale();
|
|
771
1020
|
return currentLocale;
|
|
772
1021
|
}
|
|
773
1022
|
|
|
774
1023
|
export function getAvailableLocales(): string[] {
|
|
1024
|
+
if (i18nProvider?.getAvailableLocales) return i18nProvider.getAvailableLocales();
|
|
775
1025
|
return Object.keys(locales);
|
|
776
1026
|
}
|
|
777
1027
|
|
|
778
1028
|
export function t(key: string, params?: Record<string, string | number>): string {
|
|
1029
|
+
// 读取 currentLocale 以建立 Svelte $state 依赖追踪,
|
|
1030
|
+
// 确保无论是否使用 provider,语言切换都能触发模板刷新。
|
|
1031
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
1032
|
+
currentLocale;
|
|
1033
|
+
if (i18nProvider) return i18nProvider.translate(key, params);
|
|
779
1034
|
const locale = locales[currentLocale];
|
|
780
1035
|
let text = locale?.[key] ?? locales['en']?.[key] ?? key;
|
|
781
1036
|
|
|
@@ -794,6 +1049,7 @@ export function addTranslations(locale: string, translations: Record<string, str
|
|
|
794
1049
|
export function resetI18n(): void {
|
|
795
1050
|
currentLocale = detectLocale();
|
|
796
1051
|
locales = initialLocales;
|
|
1052
|
+
i18nProvider = undefined;
|
|
797
1053
|
}
|
|
798
1054
|
|
|
799
1055
|
/**
|
|
@@ -805,9 +1061,9 @@ export function useTranslation() {
|
|
|
805
1061
|
return t(key, params);
|
|
806
1062
|
};
|
|
807
1063
|
|
|
808
|
-
return {
|
|
1064
|
+
return {
|
|
809
1065
|
get t() { return translate; },
|
|
810
|
-
get locale() { return
|
|
1066
|
+
get locale() { return getLocale(); },
|
|
811
1067
|
setLocale,
|
|
812
1068
|
getAvailableLocales
|
|
813
1069
|
};
|
package/src/index.ts
CHANGED
|
@@ -23,7 +23,7 @@ export {
|
|
|
23
23
|
useNavigation, useGo, useBack,
|
|
24
24
|
useGetToPath, useLink,
|
|
25
25
|
useResource,
|
|
26
|
-
useModalForm, useDrawerForm, useModal,
|
|
26
|
+
useModalForm, useDrawerForm, useModal, useCheckboxGroup, useRadioGroup, useAutocomplete,
|
|
27
27
|
useOvertime, useRelation,
|
|
28
28
|
useNotification, useDataProvider,
|
|
29
29
|
useMenu, useBreadcrumb, useThemedLayoutContext,
|
|
@@ -36,7 +36,8 @@ export { setAccessControlProvider, getAccessControlProvider, getAccessControlOpt
|
|
|
36
36
|
export { useLive, useSubscription, usePublish } from './live.svelte';
|
|
37
37
|
export { toast, getToastQueue, consumeToastQueue, getPromiseQueue, consumePromiseQueue, resetToast } from './toast.svelte';
|
|
38
38
|
export { notify, closeNotification, setNotificationProvider, getNotificationProvider } from './notification.svelte';
|
|
39
|
-
export { t, setLocale, getLocale, getAvailableLocales, addTranslations, useTranslation } from './i18n.svelte';
|
|
39
|
+
export { t, setLocale, getLocale, getAvailableLocales, addTranslations, useTranslation, setI18nProvider, getI18nProvider, resetI18n } from './i18n.svelte';
|
|
40
|
+
export type { I18nProvider } from './i18n.svelte';
|
|
40
41
|
export { audit, setAuditHandler, setAuditLogProvider, getAuditLogProvider } from './audit';
|
|
41
42
|
export type { AuditLogProvider } from './audit';
|
|
42
43
|
export { setChatProvider, getChatProvider, setChatContext, getChatContext, setAgentProvider, getAgentProvider, registerApproval, resolveApproval, hasPendingApprovals, resetChatProvider } from './chatProvider.svelte';
|
|
@@ -79,11 +80,12 @@ export type { UseCanOptions, UseCanResult } from './useCan';
|
|
|
79
80
|
export { createCaslAccessControl } from './adapters/casl';
|
|
80
81
|
export { createCasbinAccessControl } from './adapters/casbin';
|
|
81
82
|
export type { CasbinAdapterOptions } from './adapters/casbin';
|
|
82
|
-
export { useExport, useImport } from './data-transfer.svelte';
|
|
83
|
-
export type { UseExportOptions, UseImportOptions } from './data-transfer.svelte';
|
|
83
|
+
export { useExport, useImport, downloadData } from './data-transfer.svelte';
|
|
84
|
+
export type { UseExportOptions, UseImportOptions, ExportFormat } from './data-transfer.svelte';
|
|
84
85
|
export {
|
|
85
86
|
useLogin, useLogout,
|
|
86
87
|
useRegister, useForgotPassword, useUpdatePassword,
|
|
88
|
+
useUpdateIdentity, useUpdateProfile,
|
|
87
89
|
useGetIdentity, useIsAuthenticated,
|
|
88
90
|
useOnError, usePermissions,
|
|
89
91
|
getLogoutVersion, resetLogoutVersion,
|
|
@@ -9,6 +9,7 @@ export interface CanParams {
|
|
|
9
9
|
resource: string;
|
|
10
10
|
action: Action;
|
|
11
11
|
params?: { id?: string | number; [key: string]: unknown };
|
|
12
|
+
meta?: Record<string, unknown>;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export interface CanResult {
|
|
@@ -81,8 +82,8 @@ export function getAccessControlOptions() {
|
|
|
81
82
|
* Supports both single capability requests or an array of batched checks.
|
|
82
83
|
*/
|
|
83
84
|
export async function canAccessAsync(params: CanParams[]): Promise<CanResult[]>;
|
|
84
|
-
export async function canAccessAsync(resource: string, action: Action, params?: Record<string, unknown>): Promise<CanResult>;
|
|
85
|
-
export async function canAccessAsync(resourceOrBatch: string | CanParams[], action?: Action, params?: Record<string, unknown>): Promise<CanResult | CanResult[]> {
|
|
85
|
+
export async function canAccessAsync(resource: string, action: Action, params?: Record<string, unknown>, meta?: Record<string, unknown>): Promise<CanResult>;
|
|
86
|
+
export async function canAccessAsync(resourceOrBatch: string | CanParams[], action?: Action, params?: Record<string, unknown>, meta?: Record<string, unknown>): Promise<CanResult | CanResult[]> {
|
|
86
87
|
if (!provider) {
|
|
87
88
|
return Array.isArray(resourceOrBatch)
|
|
88
89
|
? resourceOrBatch.map(() => ({ can: true }))
|
|
@@ -93,7 +94,7 @@ export async function canAccessAsync(resourceOrBatch: string | CanParams[], acti
|
|
|
93
94
|
return provider.can(resourceOrBatch);
|
|
94
95
|
}
|
|
95
96
|
|
|
96
|
-
return provider.can({ resource: resourceOrBatch, action: action as string, params }) as Promise<CanResult>;
|
|
97
|
+
return provider.can({ resource: resourceOrBatch, action: action as string, params, meta }) as Promise<CanResult>;
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
// ─── Feature Gate ─────────────────────────────────────────────
|
|
@@ -87,9 +87,26 @@ export function useList<TData extends BaseRecord = BaseRecord, TError = HttpErro
|
|
|
87
87
|
const queryOptions = opts.queryOptions;
|
|
88
88
|
const pagination = cloneQueryKeyPart(opts.pagination);
|
|
89
89
|
const sorters = cloneQueryKeyPart(opts.sorters);
|
|
90
|
-
const
|
|
90
|
+
const explicitFilters = cloneQueryKeyPart(opts.filters);
|
|
91
91
|
const meta = cloneQueryKeyPart(opts.meta);
|
|
92
|
-
|
|
92
|
+
|
|
93
|
+
// 自动注入嵌套资源 parent filter
|
|
94
|
+
// 仅消费路径解析出的 parentParams(如 /teams/123/users 的 teamId),
|
|
95
|
+
// 不再从 query params 中误取,避免 ?tenantId=1 等被静默转成 filter。
|
|
96
|
+
//
|
|
97
|
+
// 安全边界:仅在 useList 未显式指定 resource(回退到 parsed.resource),
|
|
98
|
+
// 或显式 resource 与当前路由资源一致时注入。
|
|
99
|
+
const parentFilters: typeof explicitFilters = [];
|
|
100
|
+
const injectParent = !opts.resource || opts.resource === parsed.resource;
|
|
101
|
+
if (injectParent && parsed.parentParams) {
|
|
102
|
+
for (const [paramKey, paramVal] of Object.entries(parsed.parentParams)) {
|
|
103
|
+
if (paramKey.endsWith('Id') && paramVal) {
|
|
104
|
+
parentFilters.push({ field: paramKey, operator: 'eq' as const, value: paramVal });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const filters = [...parentFilters, ...(explicitFilters ?? [])];
|
|
109
|
+
|
|
93
110
|
return {
|
|
94
111
|
queryKey: [opts.dataProviderName, resource, 'list', pagination, sorters, filters, meta],
|
|
95
112
|
queryFn: async () => provider.getList<TData>({
|
|
@@ -99,6 +99,15 @@ export function useResource(resourceName?: string) {
|
|
|
99
99
|
get resource() { return resolvedResource; },
|
|
100
100
|
get resources() { return getResources(); },
|
|
101
101
|
get identifier() { return resolvedResource?.identifier ?? resolvedResource?.name; },
|
|
102
|
+
/** 当前 URL 解析出的 parent 资源信息(嵌套资源路由) */
|
|
103
|
+
get parent() {
|
|
104
|
+
const parentParams: Record<string, string> = {};
|
|
105
|
+
// 只取路径解析出的 parent params,排除 query params 的误判
|
|
106
|
+
for (const [k, v] of Object.entries(parsed.parentParams)) {
|
|
107
|
+
if (v) parentParams[k] = v;
|
|
108
|
+
}
|
|
109
|
+
return Object.keys(parentParams).length > 0 ? parentParams : undefined;
|
|
110
|
+
},
|
|
102
111
|
/** Resolve a resource by name — useful for dynamic lookups inside callbacks */
|
|
103
112
|
select: (name: string) => {
|
|
104
113
|
try {
|
package/src/types.ts
CHANGED
|
@@ -72,6 +72,7 @@ export interface GetListParams {
|
|
|
72
72
|
export interface GetListResult<TData extends BaseRecord = BaseRecord> {
|
|
73
73
|
data: TData[];
|
|
74
74
|
total: number;
|
|
75
|
+
[key: string]: unknown;
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
export interface GetOneParams {
|
|
@@ -315,6 +316,8 @@ export interface AuthProvider {
|
|
|
315
316
|
register?: (params: Record<string, unknown>) => Promise<AuthActionResult>;
|
|
316
317
|
forgotPassword?: (params: Record<string, unknown>) => Promise<AuthActionResult>;
|
|
317
318
|
updatePassword?: (params: Record<string, unknown>) => Promise<AuthActionResult>;
|
|
319
|
+
/** Update current user's identity fields such as name or email. */
|
|
320
|
+
updateIdentity?: (params: Partial<Identity> & Record<string, unknown>) => Promise<AuthActionResult>;
|
|
318
321
|
/** Update user profile (name, avatar, etc.) */
|
|
319
322
|
updateProfile?: (params: { name?: string; avatar?: string | File; [key: string]: unknown }) => Promise<AuthActionResult>;
|
|
320
323
|
/** Get list of system roles for RBAC mapping */
|
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
|
@@ -10,7 +10,10 @@ interface ParsedRoute {
|
|
|
10
10
|
resourcePath?: string;
|
|
11
11
|
action?: 'list' | 'create' | 'edit' | 'show' | 'clone';
|
|
12
12
|
id?: string;
|
|
13
|
+
/** Query params parsed from the URL search string (e.g. ?page=2). */
|
|
13
14
|
params: Record<string, string>;
|
|
15
|
+
/** Parent path params derived from nested routes (e.g. /teams/123/users -> teamId). */
|
|
16
|
+
parentParams: Record<string, string>;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
let globalPath = $state('/');
|
|
@@ -21,6 +24,16 @@ export function resetGlobalPath(): void {
|
|
|
21
24
|
export function syncGlobalPath(): void {
|
|
22
25
|
if (typeof window !== 'undefined') globalPath = currentPath();
|
|
23
26
|
}
|
|
27
|
+
|
|
28
|
+
/** 简单的英文名词单数化,处理常见复数模式(-ies, -ses, -es, -s) */
|
|
29
|
+
function singularize(word: string): string {
|
|
30
|
+
if (word.endsWith('ies') && word.length > 3) return word.slice(0, -3) + 'y';
|
|
31
|
+
if (word.endsWith('ses') && word.length > 3) return word.slice(0, -2);
|
|
32
|
+
if (word.endsWith('es') && word.length > 2) return word.slice(0, -2);
|
|
33
|
+
if (word.endsWith('s') && word.length > 1) return word.slice(0, -1);
|
|
34
|
+
return word;
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
registerRouterSync(syncGlobalPath);
|
|
25
38
|
|
|
26
39
|
if (typeof window !== 'undefined') {
|
|
@@ -41,13 +54,15 @@ if (typeof window !== 'undefined') {
|
|
|
41
54
|
* // parsed.resource === 'posts'
|
|
42
55
|
* // parsed.action === 'edit'
|
|
43
56
|
* // parsed.id === '123'
|
|
57
|
+
* // parsed.params contains query params only
|
|
58
|
+
* // parsed.parentParams contains nested route params such as teamId
|
|
44
59
|
*/
|
|
45
60
|
export function useParsed(): ParsedRoute {
|
|
46
61
|
const resources = $derived((() => { try { return getResources(); } catch { return []; } })());
|
|
47
62
|
|
|
48
63
|
const parsed = $derived.by(() => {
|
|
49
64
|
const p = globalPath;
|
|
50
|
-
const result: ParsedRoute = { params: {} };
|
|
65
|
+
const result: ParsedRoute = { params: {}, parentParams: {} };
|
|
51
66
|
|
|
52
67
|
// Parse query params from hash
|
|
53
68
|
const [pathname, queryString] = p.split('?');
|
|
@@ -75,12 +90,15 @@ export function useParsed(): ParsedRoute {
|
|
|
75
90
|
result.resource = segments[resourceIndex];
|
|
76
91
|
result.resourcePath = segments.slice(0, resourceIndex + 1).join('/');
|
|
77
92
|
|
|
78
|
-
// Add parent path params based on `<parentResource>/<parentId>` structure
|
|
93
|
+
// Add parent path params based on `<parentResource>/<parentId>` structure.
|
|
94
|
+
// These are kept separate from query params so that downstream filter
|
|
95
|
+
// injection only applies to path-derived parent params, never to
|
|
96
|
+
// arbitrary query params like ?tenantId=1.
|
|
79
97
|
for (let i = 0; i < resourceIndex; i += 2) {
|
|
80
98
|
if (segments[i] && segments[i + 1]) {
|
|
81
99
|
const parentName = segments[i];
|
|
82
|
-
const singular =
|
|
83
|
-
result.
|
|
100
|
+
const singular = singularize(parentName);
|
|
101
|
+
result.parentParams[`${singular}Id`] = segments[i + 1];
|
|
84
102
|
}
|
|
85
103
|
}
|
|
86
104
|
|
|
@@ -136,5 +154,6 @@ export function useParsed(): ParsedRoute {
|
|
|
136
154
|
get action() { return parsed.action; },
|
|
137
155
|
get id() { return parsed.id; },
|
|
138
156
|
get params() { return parsed.params; },
|
|
157
|
+
get parentParams() { return parsed.parentParams; },
|
|
139
158
|
};
|
|
140
159
|
}
|
|
@@ -8,6 +8,8 @@ import { t } from './i18n.svelte';
|
|
|
8
8
|
import type { BaseRecord, HttpError, Filter, KnownResources, ResourceDefinition } from './types';
|
|
9
9
|
import { useList, type UseListOptions } from './query-hooks.svelte';
|
|
10
10
|
import { toast } from './toast.svelte';
|
|
11
|
+
import type { UseSelectOptions } from './hooks.svelte';
|
|
12
|
+
import { useSelect as useSelectImpl } from './hooks.svelte';
|
|
11
13
|
|
|
12
14
|
// ─── useModal ─────────────────────────────────────────────────
|
|
13
15
|
export function useModal(options?: { defaultVisible?: boolean }) {
|
|
@@ -181,3 +183,112 @@ export function useRelation<TData extends BaseRecord = BaseRecord, TError = Http
|
|
|
181
183
|
export function useNotification() {
|
|
182
184
|
return { open: toast.info, success: toast.success, error: toast.error, warning: toast.warning };
|
|
183
185
|
}
|
|
186
|
+
|
|
187
|
+
// ─── Field-specific hooks (refine-compatible) ─────────────────────
|
|
188
|
+
// useCheckboxGroup / useRadioGroup / useAutocomplete are convenience
|
|
189
|
+
// wrappers around useSelect for common form field patterns.
|
|
190
|
+
|
|
191
|
+
export interface UseCheckboxGroupOptions<TData extends BaseRecord = BaseRecord> extends Omit<UseSelectOptions<TData>, 'optionLabel' | 'optionValue' | 'defaultValue'> {
|
|
192
|
+
/** Field on the record used as the option label (default: 'title') */
|
|
193
|
+
optionLabel?: string | ((item: TData) => string);
|
|
194
|
+
/** Field on the record used as the checkbox value (default: 'id') */
|
|
195
|
+
optionValue?: string | ((item: TData) => string | number);
|
|
196
|
+
/** Currently selected values — controlled mode */
|
|
197
|
+
defaultValue?: (string | number)[];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* useCheckboxGroup — multi-select field state backed by a resource query.
|
|
202
|
+
* Provides a list of checkbox options plus helpers to toggle individual values.
|
|
203
|
+
*/
|
|
204
|
+
export function useCheckboxGroup<TData extends BaseRecord = BaseRecord>(options: UseCheckboxGroupOptions<TData>) {
|
|
205
|
+
const select = useSelectImpl<TData>(options);
|
|
206
|
+
let selected = $state<(string | number)[]>(options.defaultValue ?? []);
|
|
207
|
+
|
|
208
|
+
function toggle(value: string | number) {
|
|
209
|
+
const idx = selected.findIndex(v => String(v) === String(value));
|
|
210
|
+
if (idx >= 0) {
|
|
211
|
+
selected = selected.filter(v => String(v) !== String(value));
|
|
212
|
+
} else {
|
|
213
|
+
selected = [...selected, value];
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isSelected(value: string | number): boolean {
|
|
218
|
+
return selected.some(v => String(v) === String(value));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
query: select.query,
|
|
223
|
+
get options() { return select.options; },
|
|
224
|
+
onSearchChange: select.onSearchChange,
|
|
225
|
+
get selected() { return selected; },
|
|
226
|
+
toggle,
|
|
227
|
+
isSelected,
|
|
228
|
+
setSelected: (values: (string | number)[]) => { selected = values; },
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface UseRadioGroupOptions<TData extends BaseRecord = BaseRecord> extends Omit<UseSelectOptions<TData>, 'optionLabel' | 'optionValue' | 'defaultValue'> {
|
|
233
|
+
optionLabel?: string | ((item: TData) => string);
|
|
234
|
+
optionValue?: string | ((item: TData) => string | number);
|
|
235
|
+
defaultValue?: string | number;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* useRadioGroup — single-select field state backed by a resource query.
|
|
240
|
+
* Provides a list of radio options plus a setter for the currently selected value.
|
|
241
|
+
*/
|
|
242
|
+
export function useRadioGroup<TData extends BaseRecord = BaseRecord>(options: UseRadioGroupOptions<TData>) {
|
|
243
|
+
const { defaultValue: _dv, ...selectOpts } = options;
|
|
244
|
+
// useSelect 的 defaultValue 是数组类型;透传单值以便远程选项回填当前已选值
|
|
245
|
+
const select = useSelectImpl<TData>({
|
|
246
|
+
...selectOpts,
|
|
247
|
+
defaultValue: _dv !== undefined ? [_dv] : [],
|
|
248
|
+
});
|
|
249
|
+
let value = $state<string | number | undefined>(_dv);
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
query: select.query,
|
|
253
|
+
get options() { return select.options; },
|
|
254
|
+
onSearchChange: select.onSearchChange,
|
|
255
|
+
get value() { return value; },
|
|
256
|
+
setValue: (v: string | number) => { value = v; },
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface UseAutocompleteOptions<TData extends BaseRecord = BaseRecord> extends Omit<UseSelectOptions<TData>, 'onSearch' | 'defaultValue'> {
|
|
261
|
+
optionLabel?: string | ((item: TData) => string);
|
|
262
|
+
optionValue?: string | ((item: TData) => string | number);
|
|
263
|
+
/** Field name to search against (default: optionLabel) */
|
|
264
|
+
searchField?: string;
|
|
265
|
+
defaultValue?: string | number;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* useAutocomplete — typeahead search field backed by a resource query.
|
|
270
|
+
* Builds a 'contains' filter on the search field and debounces input.
|
|
271
|
+
*/
|
|
272
|
+
export function useAutocomplete<TData extends BaseRecord = BaseRecord>(options: UseAutocompleteOptions<TData>) {
|
|
273
|
+
const { searchField, defaultValue: _dv, ...selectOpts } = options;
|
|
274
|
+
const labelField = typeof options.optionLabel === 'string' ? options.optionLabel : 'title';
|
|
275
|
+
|
|
276
|
+
const select = useSelectImpl<TData>({
|
|
277
|
+
...selectOpts,
|
|
278
|
+
defaultValue: _dv !== undefined ? [_dv] : [],
|
|
279
|
+
onSearch: (term) => [{
|
|
280
|
+
field: searchField ?? labelField,
|
|
281
|
+
operator: 'contains' as const,
|
|
282
|
+
value: term,
|
|
283
|
+
}],
|
|
284
|
+
});
|
|
285
|
+
let value = $state<string | number | undefined>(_dv);
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
query: select.query,
|
|
289
|
+
get options() { return select.options; },
|
|
290
|
+
onSearchChange: select.onSearchChange,
|
|
291
|
+
get value() { return value; },
|
|
292
|
+
setValue: (v: string | number) => { value = v; },
|
|
293
|
+
};
|
|
294
|
+
}
|