@nocobase/app-template-default 1.0.0-beta.88 → 1.0.0-beta.90
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/.agents/rules/components.md +3 -1
- package/.agents/rules/shell.md +12 -2
- package/AGENTS.md +1 -1
- package/LICENSE.txt +129 -0
- package/client/components/back-button.tsx +37 -12
- package/client/components/brand-loading-mark.tsx +5 -2
- package/client/components/brand-mark-geometry.ts +3 -3
- package/client/components/breadcrumbs.tsx +47 -3
- package/client/components/data-table/data-table.tsx +90 -19
- package/client/components/data-table/pagination.tsx +17 -2
- package/client/components/list-feedback.tsx +31 -22
- package/client/components/loading.tsx +127 -16
- package/client/components/pending-button.tsx +111 -0
- package/client/components/role-list.tsx +7 -0
- package/client/components/route-child-page.tsx +72 -1
- package/client/components/route-overlay.tsx +149 -15
- package/client/components/task-progress.tsx +155 -0
- package/client/components/use-route-overlay.ts +8 -11
- package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +62 -32
- package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +2 -0
- package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +2 -0
- package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +1 -0
- package/client/extensions/nocobase-workflow-management/README.md +5 -5
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +2 -9
- package/client/extensions/nocobase-workflow-management/execution-list.tsx +2 -4
- package/client/layouts/app-layout.tsx +0 -4
- package/client/lib/navigation-transform.ts +29 -1
- package/client/locales/system/en-US.ts +23 -0
- package/client/locales/system/zh-CN.ts +23 -0
- package/client/pages/home/index.tsx +66 -51
- package/client/pages/settings/database-explorer/en-US.ts +33 -0
- package/client/pages/settings/database-explorer/index.tsx +358 -0
- package/client/pages/settings/database-explorer/routes.ts +14 -0
- package/client/pages/settings/database-explorer/zh-CN.ts +30 -0
- package/client/pages/settings/default-access.tsx +1 -1
- package/client/pages/settings/roles.tsx +27 -27
- package/client/pages/settings/routes.ts +15 -7
- package/client/pages/settings/user-detail.tsx +3 -17
- package/client/pages/settings/user-form.tsx +9 -15
- package/client/pages/settings/users.tsx +11 -27
- package/client/plugins.ts +8 -1
- package/client/routes.ts +2 -1
- package/client/routing/client-route.tsx +199 -37
- package/client/routing/navigation-destination.ts +42 -0
- package/client/routing/route-leave-boundary.tsx +13 -1
- package/client/styles.css +18 -1
- package/config.example.yml +17 -11
- package/index.html +8 -11
- package/package.json +27 -27
- package/server/config/auth.ts +1 -1
- package/server/config/index.ts +3 -0
- package/server/config/jobs.ts +16 -8
- package/server/config/queue.ts +14 -13
- package/server/config/realtime.ts +4 -0
- package/tests/client/auth-i18n.test.tsx +1 -1
- package/tests/client/authenticated-client-fixture.test.tsx +37 -0
- package/tests/client/back-button.test.tsx +34 -0
- package/tests/client/client-route-recovery.test.tsx +93 -1
- package/tests/client/client-routes.test.ts +5 -3
- package/tests/client/database-explorer-integration.test.tsx +30 -0
- package/tests/client/database-explorer.test.tsx +152 -0
- package/tests/client/foundation-integration.test.ts +42 -29
- package/tests/client/home.test.tsx +26 -85
- package/tests/client/layout-i18n.test.tsx +83 -2
- package/tests/client/list-feedback.test.tsx +5 -5
- package/tests/client/list-loading.test.tsx +397 -0
- package/tests/client/loading.test.tsx +20 -11
- package/tests/client/manual-list-retry.test.tsx +2 -2
- package/tests/client/navigation-parent.test.tsx +172 -0
- package/tests/client/pending-button.test.tsx +106 -0
- package/tests/client/permission-management-tabs.test.tsx +48 -12
- package/tests/client/primitive-labels.test.tsx +2 -2
- package/tests/client/role-return.test.tsx +21 -3
- package/tests/client/roles.test.tsx +84 -3
- package/tests/client/route-leave-recheck.test.tsx +74 -0
- package/tests/client/route-presentation.test.tsx +557 -0
- package/tests/client/session-preparation.test.tsx +20 -17
- package/tests/client/settings-continuity.test.tsx +35 -11
- package/tests/client/settings-home.test.tsx +4 -2
- package/tests/client/settings-idle-list.test.tsx +124 -0
- package/tests/client/settings-navigation.test.ts +20 -0
- package/tests/client/user-drawer.test.tsx +3 -4
- package/tests/fixtures/dialog-footer.html +12 -0
- package/tests/fixtures/dialog-footer.tsx +215 -0
- package/tests/helpers/authenticated-client.tsx +77 -22
- package/tests/playwright/dialog-footer.test.ts +227 -0
- package/tests/project/bootstrap-loading.test.ts +60 -0
- package/tests/server/app-server.test.ts +18 -12
- package/tests/server/auth-email-config.test.ts +21 -0
- package/tests/server/backends-config.test.ts +118 -0
- package/tests/server/concurrency.test.ts +1 -1
- package/tests/server/config.test.ts +22 -14
- package/tests/server/lifecycle.test.ts +2 -2
- package/tests/server/realtime-config.test.ts +114 -0
- package/client/pages/home/capabilities.ts +0 -62
- package/client/pages/home/prompt-dialog.tsx +0 -122
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { ApiClientError, useApiClient } from '@nocobase/app-client';
|
|
2
|
+
import { DatabaseExplorerClient } from '@nocobase/app-plugin-database-explorer/client';
|
|
3
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
4
|
+
import {
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import { Link, useSearchParams } from 'react-router';
|
|
12
|
+
import { PageContainer } from '#components/page-container';
|
|
13
|
+
import { PageHeader } from '#components/page-header';
|
|
14
|
+
import { Button } from '#components/ui/button';
|
|
15
|
+
import { Input } from '#components/ui/input';
|
|
16
|
+
import {
|
|
17
|
+
Select,
|
|
18
|
+
SelectContent,
|
|
19
|
+
SelectItem,
|
|
20
|
+
SelectTrigger,
|
|
21
|
+
SelectValue,
|
|
22
|
+
} from '#components/ui/select';
|
|
23
|
+
import {
|
|
24
|
+
Table,
|
|
25
|
+
TableBody,
|
|
26
|
+
TableCell,
|
|
27
|
+
TableHead,
|
|
28
|
+
TableHeader,
|
|
29
|
+
TableRow,
|
|
30
|
+
} from '#components/ui/table';
|
|
31
|
+
|
|
32
|
+
/** A keyed read boundary prevents a late response from replacing a newer selection. */
|
|
33
|
+
function Read<T>({
|
|
34
|
+
load,
|
|
35
|
+
children,
|
|
36
|
+
}: {
|
|
37
|
+
load: () => Promise<T>;
|
|
38
|
+
children: (data: T) => ReactNode;
|
|
39
|
+
}) {
|
|
40
|
+
const { t } = useTranslation();
|
|
41
|
+
const [attempt, setAttempt] = useState(0);
|
|
42
|
+
const [result, setResult] = useState<{ data: T } | { error: unknown }>();
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
let active = true;
|
|
45
|
+
load().then(
|
|
46
|
+
(data) => {
|
|
47
|
+
if (active) setResult({ data });
|
|
48
|
+
},
|
|
49
|
+
(error: unknown) => {
|
|
50
|
+
if (active) setResult({ error });
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
return () => {
|
|
54
|
+
active = false;
|
|
55
|
+
};
|
|
56
|
+
}, [load, attempt]);
|
|
57
|
+
if (!result)
|
|
58
|
+
return (
|
|
59
|
+
<p role='status' className='p-4 text-sm text-muted-foreground'>
|
|
60
|
+
{t('databaseExplorer.loading')}
|
|
61
|
+
</p>
|
|
62
|
+
);
|
|
63
|
+
if ('error' in result) {
|
|
64
|
+
const status =
|
|
65
|
+
result.error instanceof ApiClientError ? result.error.status : undefined;
|
|
66
|
+
const key =
|
|
67
|
+
status === 401 ? 'expired' : status === 403 ? 'forbidden' : 'failed';
|
|
68
|
+
return (
|
|
69
|
+
<div role='alert' className='space-y-3 p-4'>
|
|
70
|
+
<p>{t(`databaseExplorer.${key}`)}</p>
|
|
71
|
+
{status === 401 ? (
|
|
72
|
+
<Link to='/login'>{t('databaseExplorer.signIn')}</Link>
|
|
73
|
+
) : status !== 403 ? (
|
|
74
|
+
<Button
|
|
75
|
+
variant='outline'
|
|
76
|
+
onClick={() => {
|
|
77
|
+
setResult(undefined);
|
|
78
|
+
setAttempt((value) => value + 1);
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
{t('databaseExplorer.retry')}
|
|
82
|
+
</Button>
|
|
83
|
+
) : null}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return children(result.data);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export default function DatabaseExplorerPage() {
|
|
91
|
+
const { t } = useTranslation();
|
|
92
|
+
const api = useApiClient();
|
|
93
|
+
const client = useMemo(() => new DatabaseExplorerClient(api), [api]);
|
|
94
|
+
const load = useCallback(() => client.connections(), [client]);
|
|
95
|
+
const [params, setParams] = useSearchParams();
|
|
96
|
+
return (
|
|
97
|
+
<PageContainer className='space-y-6'>
|
|
98
|
+
<PageHeader
|
|
99
|
+
title={t('databaseExplorer.title')}
|
|
100
|
+
description={t('databaseExplorer.description')}
|
|
101
|
+
/>
|
|
102
|
+
<Read load={load}>
|
|
103
|
+
{({ items, default: defaultConnection }) => {
|
|
104
|
+
if (!items.length)
|
|
105
|
+
return <p>{t('databaseExplorer.emptyConnections')}</p>;
|
|
106
|
+
const connection =
|
|
107
|
+
params.get('connection') ?? defaultConnection ?? items[0].name;
|
|
108
|
+
return (
|
|
109
|
+
<div className='space-y-6'>
|
|
110
|
+
<div className='max-w-sm space-y-2'>
|
|
111
|
+
<label
|
|
112
|
+
htmlFor='database-connection'
|
|
113
|
+
className='text-sm font-medium'
|
|
114
|
+
>
|
|
115
|
+
{t('databaseExplorer.connections')}
|
|
116
|
+
</label>
|
|
117
|
+
<Select
|
|
118
|
+
value={connection}
|
|
119
|
+
onValueChange={(value) => {
|
|
120
|
+
if (value) setParams({ connection: value });
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
<SelectTrigger id='database-connection' className='w-full'>
|
|
124
|
+
<SelectValue />
|
|
125
|
+
</SelectTrigger>
|
|
126
|
+
<SelectContent>
|
|
127
|
+
{items.map((item) => (
|
|
128
|
+
<SelectItem key={item.name} value={item.name}>
|
|
129
|
+
{item.name} · {item.dialect}
|
|
130
|
+
{item.isDefault
|
|
131
|
+
? ` · ${t('databaseExplorer.defaultConnection')}`
|
|
132
|
+
: ''}
|
|
133
|
+
</SelectItem>
|
|
134
|
+
))}
|
|
135
|
+
</SelectContent>
|
|
136
|
+
</Select>
|
|
137
|
+
</div>
|
|
138
|
+
<ConnectionTables
|
|
139
|
+
key={connection}
|
|
140
|
+
client={client}
|
|
141
|
+
connection={connection}
|
|
142
|
+
/>
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}}
|
|
146
|
+
</Read>
|
|
147
|
+
</PageContainer>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function ConnectionTables({
|
|
152
|
+
client,
|
|
153
|
+
connection,
|
|
154
|
+
}: {
|
|
155
|
+
client: DatabaseExplorerClient;
|
|
156
|
+
connection: string;
|
|
157
|
+
}) {
|
|
158
|
+
const { t } = useTranslation();
|
|
159
|
+
const [params, setParams] = useSearchParams();
|
|
160
|
+
const selected = params.get('table');
|
|
161
|
+
const query = params.get('q') ?? '';
|
|
162
|
+
const load = useCallback(
|
|
163
|
+
() => client.allCollections(connection),
|
|
164
|
+
[client, connection],
|
|
165
|
+
);
|
|
166
|
+
return (
|
|
167
|
+
<div className='grid min-w-0 gap-6 lg:grid-cols-[minmax(14rem,1fr)_minmax(0,3fr)]'>
|
|
168
|
+
<section
|
|
169
|
+
aria-label={t('databaseExplorer.tables')}
|
|
170
|
+
className='min-w-0 space-y-3'
|
|
171
|
+
>
|
|
172
|
+
<h2 className='font-heading text-base font-semibold'>
|
|
173
|
+
{t('databaseExplorer.tables')}
|
|
174
|
+
</h2>
|
|
175
|
+
<Input
|
|
176
|
+
aria-label={t('databaseExplorer.searchTables')}
|
|
177
|
+
placeholder={t('databaseExplorer.searchTables')}
|
|
178
|
+
value={query}
|
|
179
|
+
onChange={(event) => {
|
|
180
|
+
setParams(
|
|
181
|
+
(previous) => {
|
|
182
|
+
const next = new URLSearchParams(previous);
|
|
183
|
+
next.set('q', event.target.value);
|
|
184
|
+
return next;
|
|
185
|
+
},
|
|
186
|
+
{ replace: true },
|
|
187
|
+
);
|
|
188
|
+
}}
|
|
189
|
+
/>
|
|
190
|
+
<Read load={load}>
|
|
191
|
+
{({ items, truncated }) => {
|
|
192
|
+
const matches = items.filter((item) =>
|
|
193
|
+
`${item.name} ${item.tableName} ${item.title ?? ''}`
|
|
194
|
+
.toLocaleLowerCase()
|
|
195
|
+
.includes(query.toLocaleLowerCase()),
|
|
196
|
+
);
|
|
197
|
+
return (
|
|
198
|
+
<>
|
|
199
|
+
{truncated ? (
|
|
200
|
+
<p role='status' className='text-sm text-muted-foreground'>
|
|
201
|
+
{t('databaseExplorer.truncated')}
|
|
202
|
+
</p>
|
|
203
|
+
) : null}
|
|
204
|
+
{matches.length ? (
|
|
205
|
+
<ul className='max-h-96 space-y-1 overflow-auto rounded-lg border p-2'>
|
|
206
|
+
{matches.map((item) => (
|
|
207
|
+
<li key={item.name}>
|
|
208
|
+
<Button
|
|
209
|
+
variant={
|
|
210
|
+
selected === item.name ? 'secondary' : 'ghost'
|
|
211
|
+
}
|
|
212
|
+
className='h-auto w-full justify-start whitespace-normal break-all text-left'
|
|
213
|
+
aria-pressed={selected === item.name}
|
|
214
|
+
onClick={() => {
|
|
215
|
+
setParams((previous) => {
|
|
216
|
+
const next = new URLSearchParams(previous);
|
|
217
|
+
next.set('connection', connection);
|
|
218
|
+
next.set('table', item.name);
|
|
219
|
+
return next;
|
|
220
|
+
});
|
|
221
|
+
}}
|
|
222
|
+
>
|
|
223
|
+
{item.title
|
|
224
|
+
? `${item.title} (${item.name})`
|
|
225
|
+
: item.name}
|
|
226
|
+
</Button>
|
|
227
|
+
</li>
|
|
228
|
+
))}
|
|
229
|
+
</ul>
|
|
230
|
+
) : (
|
|
231
|
+
<p className='text-sm text-muted-foreground'>
|
|
232
|
+
{t('databaseExplorer.emptyTables')}
|
|
233
|
+
</p>
|
|
234
|
+
)}
|
|
235
|
+
</>
|
|
236
|
+
);
|
|
237
|
+
}}
|
|
238
|
+
</Read>
|
|
239
|
+
</section>
|
|
240
|
+
<section
|
|
241
|
+
aria-label={t('databaseExplorer.fields')}
|
|
242
|
+
className='min-w-0 space-y-3'
|
|
243
|
+
>
|
|
244
|
+
{selected ? (
|
|
245
|
+
<CollectionFields
|
|
246
|
+
key={selected}
|
|
247
|
+
client={client}
|
|
248
|
+
connection={connection}
|
|
249
|
+
collection={selected}
|
|
250
|
+
/>
|
|
251
|
+
) : (
|
|
252
|
+
<p className='rounded-lg border border-dashed p-6 text-sm text-muted-foreground'>
|
|
253
|
+
{t('databaseExplorer.selectTable')}
|
|
254
|
+
</p>
|
|
255
|
+
)}
|
|
256
|
+
</section>
|
|
257
|
+
</div>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function CollectionFields({
|
|
262
|
+
client,
|
|
263
|
+
connection,
|
|
264
|
+
collection,
|
|
265
|
+
}: {
|
|
266
|
+
client: DatabaseExplorerClient;
|
|
267
|
+
connection: string;
|
|
268
|
+
collection: string;
|
|
269
|
+
}) {
|
|
270
|
+
const { t } = useTranslation();
|
|
271
|
+
const [query, setQuery] = useState('');
|
|
272
|
+
const load = useCallback(
|
|
273
|
+
() => client.collection(connection, collection),
|
|
274
|
+
[client, connection, collection],
|
|
275
|
+
);
|
|
276
|
+
return (
|
|
277
|
+
<>
|
|
278
|
+
<h2 className='break-all font-heading text-base font-semibold'>
|
|
279
|
+
{collection}
|
|
280
|
+
</h2>
|
|
281
|
+
<Input
|
|
282
|
+
aria-label={t('databaseExplorer.searchFields')}
|
|
283
|
+
placeholder={t('databaseExplorer.searchFields')}
|
|
284
|
+
value={query}
|
|
285
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
286
|
+
/>
|
|
287
|
+
<Read load={load}>
|
|
288
|
+
{({ collection: artifact }) => {
|
|
289
|
+
const fields = (artifact.collection.fields ?? []).filter((field) =>
|
|
290
|
+
`${field.name} ${field.title ?? ''}`
|
|
291
|
+
.toLocaleLowerCase()
|
|
292
|
+
.includes(query.toLocaleLowerCase()),
|
|
293
|
+
);
|
|
294
|
+
return (
|
|
295
|
+
<>
|
|
296
|
+
{artifact.warnings.length ? (
|
|
297
|
+
<p role='status' className='text-sm text-muted-foreground'>
|
|
298
|
+
{t('databaseExplorer.warnings')}
|
|
299
|
+
</p>
|
|
300
|
+
) : null}
|
|
301
|
+
{fields.length ? (
|
|
302
|
+
<div className='overflow-hidden rounded-lg border'>
|
|
303
|
+
<Table>
|
|
304
|
+
<TableHeader>
|
|
305
|
+
<TableRow>
|
|
306
|
+
{[
|
|
307
|
+
'name',
|
|
308
|
+
'type',
|
|
309
|
+
'nullable',
|
|
310
|
+
'primaryKey',
|
|
311
|
+
'target',
|
|
312
|
+
].map((key) => (
|
|
313
|
+
<TableHead key={key}>
|
|
314
|
+
{t(`databaseExplorer.${key}`)}
|
|
315
|
+
</TableHead>
|
|
316
|
+
))}
|
|
317
|
+
</TableRow>
|
|
318
|
+
</TableHeader>
|
|
319
|
+
<TableBody>
|
|
320
|
+
{fields.map((field) => (
|
|
321
|
+
<TableRow key={field.name}>
|
|
322
|
+
<TableCell className='font-medium'>
|
|
323
|
+
{field.name}
|
|
324
|
+
{field.title ? (
|
|
325
|
+
<span className='block text-muted-foreground'>
|
|
326
|
+
{field.title}
|
|
327
|
+
</span>
|
|
328
|
+
) : null}
|
|
329
|
+
</TableCell>
|
|
330
|
+
<TableCell>{field.type}</TableCell>
|
|
331
|
+
<TableCell>
|
|
332
|
+
{t(
|
|
333
|
+
`databaseExplorer.${field.nullable === undefined ? 'unknown' : field.nullable ? 'yes' : 'no'}`,
|
|
334
|
+
)}
|
|
335
|
+
</TableCell>
|
|
336
|
+
<TableCell>
|
|
337
|
+
{t(
|
|
338
|
+
`databaseExplorer.${field.primaryKey ? 'yes' : 'no'}`,
|
|
339
|
+
)}
|
|
340
|
+
</TableCell>
|
|
341
|
+
<TableCell>{field.target ?? '—'}</TableCell>
|
|
342
|
+
</TableRow>
|
|
343
|
+
))}
|
|
344
|
+
</TableBody>
|
|
345
|
+
</Table>
|
|
346
|
+
</div>
|
|
347
|
+
) : (
|
|
348
|
+
<p className='text-sm text-muted-foreground'>
|
|
349
|
+
{t('databaseExplorer.emptyFields')}
|
|
350
|
+
</p>
|
|
351
|
+
)}
|
|
352
|
+
</>
|
|
353
|
+
);
|
|
354
|
+
}}
|
|
355
|
+
</Read>
|
|
356
|
+
</>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineAppRoutes } from '@nocobase/app-client/plugins';
|
|
2
|
+
import { DATABASE_EXPLORER_ACCESS } from '@nocobase/app-plugin-database-explorer/client';
|
|
3
|
+
|
|
4
|
+
export default defineAppRoutes([
|
|
5
|
+
{
|
|
6
|
+
parent: 'settingsSystem',
|
|
7
|
+
name: 'databaseExplorer',
|
|
8
|
+
path: '/database-explorer',
|
|
9
|
+
auth: 'required',
|
|
10
|
+
authz: DATABASE_EXPLORER_ACCESS,
|
|
11
|
+
breadcrumb: { title: 'databaseExplorer.title' },
|
|
12
|
+
componentLoader: () => import('./index.js'),
|
|
13
|
+
},
|
|
14
|
+
]);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
title: '数据库浏览器',
|
|
3
|
+
description: '查看应用的数据库连接、数据表和字段。',
|
|
4
|
+
connections: '数据库连接',
|
|
5
|
+
defaultConnection: '默认',
|
|
6
|
+
tables: '数据表',
|
|
7
|
+
searchTables: '搜索数据表',
|
|
8
|
+
fields: '字段',
|
|
9
|
+
searchFields: '搜索字段',
|
|
10
|
+
name: '名称',
|
|
11
|
+
type: '类型',
|
|
12
|
+
nullable: '允许为空',
|
|
13
|
+
primaryKey: '主键',
|
|
14
|
+
target: '关联表',
|
|
15
|
+
yes: '是',
|
|
16
|
+
no: '否',
|
|
17
|
+
unknown: '未指定',
|
|
18
|
+
emptyConnections: '尚未配置数据库连接。',
|
|
19
|
+
emptyTables: '没有找到数据表。',
|
|
20
|
+
emptyFields: '没有找到字段。',
|
|
21
|
+
selectTable: '选择数据表以查看字段。',
|
|
22
|
+
loading: '正在加载…',
|
|
23
|
+
failed: '无法加载数据库信息。',
|
|
24
|
+
forbidden: '你没有浏览此数据库的权限。',
|
|
25
|
+
expired: '登录已过期,请重新登录后继续。',
|
|
26
|
+
signIn: '登录',
|
|
27
|
+
retry: '重试',
|
|
28
|
+
truncated: '当前仅加载了部分数据表,请缩小数据库范围后查看其余数据表。',
|
|
29
|
+
warnings: '部分字段未能完整解析,显示的结构可能不完整。',
|
|
30
|
+
};
|
|
@@ -399,7 +399,7 @@ export default function DefaultAccessPage(): ReactElement {
|
|
|
399
399
|
getRowId={(rule) => rule.key}
|
|
400
400
|
showSelectedCount={false}
|
|
401
401
|
pageSize={20}
|
|
402
|
-
loading={rules.loading || catalog.loading}
|
|
402
|
+
loading={active && (rules.loading || catalog.loading)}
|
|
403
403
|
listState={listState}
|
|
404
404
|
onRetry={() => {
|
|
405
405
|
rules.reload();
|
|
@@ -12,7 +12,6 @@ import { PlusIcon } from 'lucide-react';
|
|
|
12
12
|
import { PermissionManagementPage } from './permission-management-page.js';
|
|
13
13
|
import { Input } from '#components/ui/input';
|
|
14
14
|
import { Button } from '#components/ui/button';
|
|
15
|
-
import { Spinner } from '#components/ui/spinner';
|
|
16
15
|
import { useTableUrlState } from '#hooks/use-table-url-state';
|
|
17
16
|
import { RoleList } from '#components/role-list';
|
|
18
17
|
import { ListFeedback, ListFeedbackActions } from '#components/list-feedback';
|
|
@@ -123,34 +122,35 @@ export default function RolesPage(): ReactElement {
|
|
|
123
122
|
state={listState}
|
|
124
123
|
onClear={clearSearch}
|
|
125
124
|
onRetry={request.reload}
|
|
126
|
-
busy={request.loading}
|
|
125
|
+
busy={active && request.loading}
|
|
127
126
|
/>
|
|
128
|
-
{active && request.loading && request.data ? (
|
|
129
|
-
<Spinner className='text-muted-foreground' />
|
|
130
|
-
) : null}
|
|
131
127
|
</div>
|
|
132
|
-
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
128
|
+
{active || listState !== 'loading' ? (
|
|
129
|
+
<ListFeedback state={listState}>
|
|
130
|
+
<RoleList
|
|
131
|
+
tableState={tableState}
|
|
132
|
+
// After a refresh failure the toolbar's retry button owns the wait.
|
|
133
|
+
loading={active && request.loading && listState === 'ready'}
|
|
134
|
+
ariaLabel={t('settings.roles')}
|
|
135
|
+
roles={rows.map((role) => ({
|
|
136
|
+
key: role.key,
|
|
137
|
+
title: text(role.title ?? role.key),
|
|
138
|
+
to: {
|
|
139
|
+
pathname: `${encodeURIComponent(role.key)}/edit`,
|
|
140
|
+
search: location.search,
|
|
141
|
+
},
|
|
142
|
+
protected: Boolean(role.protection),
|
|
143
|
+
}))}
|
|
144
|
+
labels={{
|
|
145
|
+
name: t('settings.roleName'),
|
|
146
|
+
key: t('settings.key'),
|
|
147
|
+
type: t('settings.roleType'),
|
|
148
|
+
protected: t('settings.protected'),
|
|
149
|
+
custom: t('settings.custom'),
|
|
150
|
+
}}
|
|
151
|
+
/>
|
|
152
|
+
</ListFeedback>
|
|
153
|
+
) : null}
|
|
154
154
|
</PermissionManagementPage>
|
|
155
155
|
<Outlet context={{ reload: request.reload }} />
|
|
156
156
|
</>
|
|
@@ -24,23 +24,27 @@ const systemSettingsRoutes: AppClientRouteContribution = defineAppRoutes([
|
|
|
24
24
|
breadcrumb: { title: 'settings.users' },
|
|
25
25
|
componentLoader: () => import('./users.js'),
|
|
26
26
|
children: [
|
|
27
|
+
// The forms declare their presentation, so the dialog or drawer opens at once and the wait for the form's module,
|
|
28
|
+
// its authorization and its data stays inside it instead of under the list.
|
|
27
29
|
{
|
|
28
30
|
name: 'settingsUserCreate',
|
|
29
31
|
path: 'create',
|
|
30
32
|
authz: { resource: { type: 'user', id: '*' }, action: 'create' },
|
|
33
|
+
presentation: { kind: 'dialog', title: 'settings.newUser' },
|
|
31
34
|
componentLoader: () => import('./user-form.js'),
|
|
32
35
|
},
|
|
36
|
+
// A sibling rather than the parent of the editor, so closing the drawer returns to the list instead of to this
|
|
37
|
+
// route, which only forwards older detail links to the editor.
|
|
33
38
|
{
|
|
34
39
|
name: 'settingsUser',
|
|
35
40
|
path: ':userId',
|
|
36
41
|
componentLoader: () => import('./user-detail.js'),
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'settingsUserEdit',
|
|
45
|
+
path: ':userId/edit',
|
|
46
|
+
presentation: { kind: 'drawer', title: 'settings.editUser' },
|
|
47
|
+
componentLoader: () => import('./user-form.js'),
|
|
44
48
|
},
|
|
45
49
|
],
|
|
46
50
|
},
|
|
@@ -80,12 +84,14 @@ const systemSettingsRoutes: AppClientRouteContribution = defineAppRoutes([
|
|
|
80
84
|
action: 'create',
|
|
81
85
|
},
|
|
82
86
|
breadcrumb: { title: 'settings.newRole' },
|
|
87
|
+
presentation: { kind: 'child-page' },
|
|
83
88
|
componentLoader: () => import('./role-form.js'),
|
|
84
89
|
},
|
|
85
90
|
{
|
|
86
91
|
name: 'settingsRoleEdit',
|
|
87
92
|
path: ':key/edit',
|
|
88
93
|
breadcrumb: { title: 'settings.editRole' },
|
|
94
|
+
presentation: { kind: 'child-page' },
|
|
89
95
|
componentLoader: () => import('./role-form.js'),
|
|
90
96
|
},
|
|
91
97
|
],
|
|
@@ -119,6 +125,7 @@ const systemSettingsRoutes: AppClientRouteContribution = defineAppRoutes([
|
|
|
119
125
|
action: 'create',
|
|
120
126
|
},
|
|
121
127
|
breadcrumb: { title: 'settings.defaultAccess.new' },
|
|
128
|
+
presentation: { kind: 'child-page' },
|
|
122
129
|
componentLoader: () => import('./default-access-form.js'),
|
|
123
130
|
},
|
|
124
131
|
// Read permission opens the full rule even when the viewer may not update it.
|
|
@@ -126,6 +133,7 @@ const systemSettingsRoutes: AppClientRouteContribution = defineAppRoutes([
|
|
|
126
133
|
name: 'settingsDefaultAccessEdit',
|
|
127
134
|
path: ':key/edit',
|
|
128
135
|
breadcrumb: { title: 'settings.defaultAccess.edit' },
|
|
136
|
+
presentation: { kind: 'child-page' },
|
|
129
137
|
componentLoader: () => import('./default-access-form.js'),
|
|
130
138
|
},
|
|
131
139
|
],
|
|
@@ -1,24 +1,10 @@
|
|
|
1
1
|
import type { ReactElement } from 'react';
|
|
2
|
-
import {
|
|
3
|
-
Navigate,
|
|
4
|
-
useLocation,
|
|
5
|
-
useOutlet,
|
|
6
|
-
useOutletContext,
|
|
7
|
-
useResolvedPath,
|
|
8
|
-
} from 'react-router';
|
|
9
|
-
import type { ManagedUser } from '@nocobase/app-plugin-users/client/user-client';
|
|
2
|
+
import { Navigate, useLocation } from 'react-router';
|
|
10
3
|
|
|
11
|
-
/**
|
|
4
|
+
/** Keeps stored route identifiers and old detail links working by forwarding them to the editor. */
|
|
12
5
|
export default function UserDetail(): ReactElement {
|
|
13
|
-
const parent = useOutletContext<{
|
|
14
|
-
reload: (updated?: ManagedUser) => void;
|
|
15
|
-
}>();
|
|
16
6
|
const location = useLocation();
|
|
17
|
-
const listPath = useResolvedPath('..').pathname;
|
|
18
|
-
const outlet = useOutlet({ ...parent, listPath });
|
|
19
7
|
return (
|
|
20
|
-
|
|
21
|
-
<Navigate to={{ pathname: 'edit', search: location.search }} replace />
|
|
22
|
-
)
|
|
8
|
+
<Navigate to={{ pathname: 'edit', search: location.search }} replace />
|
|
23
9
|
);
|
|
24
10
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PendingButton } from '#components/pending-button';
|
|
1
2
|
import { FeedbackForm } from '#components/feedback-form';
|
|
2
3
|
import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
|
|
3
4
|
import { useSubmission } from '@nocobase/app-client';
|
|
@@ -197,7 +198,6 @@ function UserEditor(): ReactElement {
|
|
|
197
198
|
const location = useLocation();
|
|
198
199
|
const parent = useOutletContext<{
|
|
199
200
|
reload: (updated?: ManagedUser) => void;
|
|
200
|
-
listPath?: string;
|
|
201
201
|
}>();
|
|
202
202
|
const data = useSettingsData<{ data: ManagedUser }>(
|
|
203
203
|
userId ? `users/${encodeURIComponent(userId)}` : 'users/options',
|
|
@@ -281,11 +281,8 @@ function UserEditor(): ReactElement {
|
|
|
281
281
|
const [serverErrors, setServerErrors] = useState<Record<string, string>>({});
|
|
282
282
|
const [submitted, setSubmitted] = useState(false);
|
|
283
283
|
|
|
284
|
-
const
|
|
285
|
-
const closeTo = {
|
|
286
|
-
pathname: creating ? parentPath : (parent.listPath ?? parentPath),
|
|
287
|
-
search: location.search,
|
|
288
|
-
};
|
|
284
|
+
const listPath = useResolvedPath('..').pathname;
|
|
285
|
+
const closeTo = { pathname: listPath, search: location.search };
|
|
289
286
|
const fieldError = (name: keyof typeof fields): string | undefined => {
|
|
290
287
|
if (!submitted) return;
|
|
291
288
|
if (serverErrors[name]) return serverErrors[name];
|
|
@@ -436,7 +433,9 @@ function UserEditor(): ReactElement {
|
|
|
436
433
|
{t('settings.cancel')}
|
|
437
434
|
</Button>
|
|
438
435
|
{allowed && (
|
|
439
|
-
<
|
|
436
|
+
<PendingButton
|
|
437
|
+
busy={busy}
|
|
438
|
+
pendingLabel={t('settings.saving')}
|
|
440
439
|
form={formId}
|
|
441
440
|
type='submit'
|
|
442
441
|
disabled={
|
|
@@ -455,14 +454,8 @@ function UserEditor(): ReactElement {
|
|
|
455
454
|
(options.loading || Boolean(options.error) || !options.data))
|
|
456
455
|
}
|
|
457
456
|
>
|
|
458
|
-
{t(
|
|
459
|
-
|
|
460
|
-
? 'settings.saving'
|
|
461
|
-
: creating
|
|
462
|
-
? 'settings.create'
|
|
463
|
-
: 'settings.save',
|
|
464
|
-
)}
|
|
465
|
-
</Button>
|
|
457
|
+
{t(creating ? 'settings.create' : 'settings.save')}
|
|
458
|
+
</PendingButton>
|
|
466
459
|
)}
|
|
467
460
|
</>
|
|
468
461
|
);
|
|
@@ -475,6 +468,7 @@ function UserEditor(): ReactElement {
|
|
|
475
468
|
) : (
|
|
476
469
|
!data.error && (
|
|
477
470
|
<FeedbackForm
|
|
471
|
+
aria-busy={busy}
|
|
478
472
|
submitting={busy}
|
|
479
473
|
error={error}
|
|
480
474
|
errorContent={<SettingsError error={error} inline />}
|