@nocobase/app-template-default 1.0.0-beta.83 → 1.0.0-beta.85

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.
Files changed (104) hide show
  1. package/.agents/rules/build-and-deploy.md +1 -1
  2. package/.agents/rules/shell.md +4 -0
  3. package/AGENTS.md +8 -2
  4. package/README.MD +10 -2
  5. package/client/components/back-button.tsx +3 -5
  6. package/client/components/data-table/data-table.tsx +86 -32
  7. package/client/components/default-access-editor.tsx +13 -2
  8. package/client/components/feedback-form.tsx +474 -0
  9. package/client/components/lifecycle-diagram.tsx +10 -10
  10. package/client/components/list-feedback.tsx +174 -0
  11. package/client/components/permission-editor.tsx +419 -226
  12. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
  13. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
  14. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
  15. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
  16. package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
  17. package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
  18. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
  19. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
  20. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  21. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -7
  22. package/client/extensions/nocobase-workflow-management/execution-list.tsx +61 -48
  23. package/client/hooks/use-list-data.ts +76 -0
  24. package/client/hooks/use-list-location.ts +23 -0
  25. package/client/hooks/use-list-tabs.ts +42 -0
  26. package/client/hooks/use-table-url-state.ts +143 -30
  27. package/client/locales/system/en-US.ts +24 -1
  28. package/client/locales/system/settings-en-US.ts +8 -10
  29. package/client/locales/system/settings-zh-CN.ts +8 -8
  30. package/client/locales/system/zh-CN.ts +22 -1
  31. package/client/pages/settings/default-access-form.tsx +44 -26
  32. package/client/pages/settings/default-access.tsx +100 -93
  33. package/client/pages/settings/hooks.tsx +4 -59
  34. package/client/pages/settings/page-permissions.ts +17 -0
  35. package/client/pages/settings/permissions-model.ts +54 -59
  36. package/client/pages/settings/role-form.tsx +98 -56
  37. package/client/pages/settings/roles.tsx +69 -95
  38. package/client/pages/settings/shared.tsx +43 -33
  39. package/client/pages/settings/user-form.tsx +51 -30
  40. package/client/pages/settings/users.tsx +105 -91
  41. package/client/routing/session-preparation.tsx +10 -3
  42. package/client/runtime.ts +0 -4
  43. package/config.example.yml +24 -0
  44. package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
  45. package/package.json +24 -21
  46. package/server/app.ts +0 -2
  47. package/server/config/auth.ts +2 -0
  48. package/server/config/concurrency.ts +3 -0
  49. package/server/config/index.ts +3 -0
  50. package/server/config/jobs.ts +1 -13
  51. package/server/config/notification.ts +1 -1
  52. package/server/config/queue.ts +1 -7
  53. package/server/plugins.ts +2 -0
  54. package/server/providers/index.ts +4 -1
  55. package/server/providers/submission.ts +61 -0
  56. package/tests/client/auth-i18n.test.tsx +9 -0
  57. package/tests/client/back-button.test.tsx +3 -3
  58. package/tests/client/data-table.test.tsx +9 -4
  59. package/tests/client/default-access.test.tsx +99 -5
  60. package/tests/client/form-feedback.test.tsx +589 -0
  61. package/tests/client/list-feedback.test.tsx +301 -0
  62. package/tests/client/list-recovery.test.tsx +373 -0
  63. package/tests/client/list-tabs.test.tsx +121 -0
  64. package/tests/client/manual-list-retry.test.tsx +190 -0
  65. package/tests/client/page-permission-navigation.test.ts +8 -0
  66. package/tests/client/permission-editor.test.tsx +191 -21
  67. package/tests/client/permission-filter-binding.test.tsx +1 -1
  68. package/tests/client/permission-workspace.test.ts +13 -0
  69. package/tests/client/role-return.test.tsx +2 -1
  70. package/tests/client/session-preparation.test.tsx +24 -1
  71. package/tests/client/settings-continuity.test.tsx +85 -5
  72. package/tests/client/settings-feedback.test.tsx +32 -15
  73. package/tests/client/settings-permissions.test.ts +35 -0
  74. package/tests/client/settings.test.tsx +2 -1
  75. package/tests/client/table-url-state.test.tsx +117 -3
  76. package/tests/client/user-drawer.test.tsx +2 -1
  77. package/tests/client/user-permissions.test.tsx +30 -5
  78. package/tests/database/mutation-receipts.test.ts +45 -0
  79. package/tests/fixtures/form-feedback/README.md +33 -0
  80. package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
  81. package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
  82. package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
  83. package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
  84. package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
  85. package/tests/fixtures/form-feedback/equipment.tsx +369 -0
  86. package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
  87. package/tests/fixtures/form-feedback/verify.mjs +373 -0
  88. package/tests/fixtures/permission-alignment.html +16 -0
  89. package/tests/fixtures/permission-alignment.tsx +124 -0
  90. package/tests/fixtures/submission/README.md +35 -0
  91. package/tests/fixtures/submission/browser.test.ts +478 -0
  92. package/tests/fixtures/submission/migration.ts +61 -0
  93. package/tests/fixtures/submission/page.tsx +138 -0
  94. package/tests/fixtures/submission/playwright.config.ts +11 -0
  95. package/tests/fixtures/submission/routes.ts +208 -0
  96. package/tests/helpers/settings-client.tsx +31 -0
  97. package/tests/playwright/permission-alignment.test.ts +109 -0
  98. package/tests/server/auth-email-config.test.ts +79 -0
  99. package/tests/server/concurrency.test.ts +61 -0
  100. package/tests/server/config.test.ts +34 -14
  101. package/tests/server/member-role.test.ts +7 -1
  102. package/tests/server/submission-settings.test.ts +241 -0
  103. package/client/route-overrides.ts +0 -9
  104. package/client/source-extensions.ts +0 -19
@@ -0,0 +1,76 @@
1
+ import { ApiClientError, useApiClient } from '@nocobase/app-client';
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+
4
+ /** A successful result belongs to one query and refresh generation only. */
5
+ export function useListData<T>(
6
+ path: string,
7
+ options: boolean | { keepPreviousData?: boolean; active?: boolean } = false,
8
+ ) {
9
+ const { keepPreviousData = false, active = true } =
10
+ typeof options === 'boolean' ? { keepPreviousData: options } : options;
11
+ const api = useApiClient();
12
+ const [revision, setRevision] = useState(0);
13
+ const key = useMemo(
14
+ () => ({ path, revision, active }),
15
+ [path, revision, active],
16
+ );
17
+ const generationRef = useRef(0);
18
+ const controllerRef = useRef<AbortController | undefined>(undefined);
19
+ const [result, setResult] = useState<{
20
+ path: string;
21
+ key?: typeof key;
22
+ data?: T;
23
+ error?: unknown;
24
+ }>({ path });
25
+ useEffect(() => {
26
+ if (!active) return;
27
+ const request = new AbortController();
28
+ controllerRef.current = request;
29
+ const ticket = generationRef.current;
30
+ void api.request<T>({ path, signal: request.signal }).then(
31
+ (data) => {
32
+ if (!request.signal.aborted && ticket === generationRef.current)
33
+ setResult({ path, key, data });
34
+ },
35
+ (error: unknown) => {
36
+ if (request.signal.aborted || ticket !== generationRef.current) return;
37
+ setResult((old) => ({
38
+ ...(old.path === path &&
39
+ !(
40
+ error instanceof ApiClientError &&
41
+ [401, 403, 404].includes(error.status)
42
+ )
43
+ ? old
44
+ : {}),
45
+ path,
46
+ key,
47
+ error,
48
+ }));
49
+ },
50
+ );
51
+ return () => request.abort();
52
+ }, [api, path, key, active]);
53
+ const invalidate = useCallback(() => {
54
+ // Invalidate synchronously, before React commits the next request effect.
55
+ generationRef.current += 1;
56
+ controllerRef.current?.abort();
57
+ }, []);
58
+ return {
59
+ path: result.path,
60
+ data: result.path === path || keepPreviousData ? result.data : undefined,
61
+ loading: !active || result.path !== path || result.key !== key,
62
+ error: result.path === path ? result.error : undefined,
63
+ revision,
64
+ reload: useCallback(() => {
65
+ invalidate();
66
+ setRevision((value) => value + 1);
67
+ }, [invalidate]),
68
+ replaceData: useCallback(
69
+ (data: T) => {
70
+ invalidate();
71
+ setResult({ path, key, data });
72
+ },
73
+ [path, key, invalidate],
74
+ ),
75
+ };
76
+ }
@@ -0,0 +1,23 @@
1
+ import {
2
+ useLocation,
3
+ useOutlet,
4
+ useResolvedPath,
5
+ useSearchParams,
6
+ } from 'react-router';
7
+ import { useRouteSearches } from '../routing/route-context.js';
8
+
9
+ /** Covered lists keep their own query; returning activates a fresh read. */
10
+ export function useListLocation() {
11
+ const location = useLocation();
12
+ const listPath = useResolvedPath('.').pathname;
13
+ const child = useOutlet();
14
+ const searches = useRouteSearches();
15
+ const [current, setParams] = useSearchParams();
16
+ const active =
17
+ (listPath === '/' && child === null) ||
18
+ location.pathname.replace(/\/$/u, '') === listPath.replace(/\/$/u, '');
19
+ const params = active
20
+ ? current
21
+ : new URLSearchParams(searches.get(listPath) ?? '');
22
+ return { params, setParams, active };
23
+ }
@@ -0,0 +1,42 @@
1
+ import { useLocation, useResolvedPath } from 'react-router';
2
+ import { useRouteSearches } from '../routing/route-context.js';
3
+
4
+ export interface ListTab {
5
+ readonly path: string;
6
+ readonly label: string;
7
+ /** Tabs sharing a dataset may retain its filters, sorting and pagination. */
8
+ readonly dataset: string;
9
+ }
10
+
11
+ /** Tabs are routes; only a list's own parameters are discarded on a dataset change. */
12
+ export function useListTabs({
13
+ tabs,
14
+ parameterKeys,
15
+ pending = false,
16
+ }: {
17
+ tabs: readonly ListTab[];
18
+ parameterKeys: readonly string[];
19
+ pending?: boolean;
20
+ }) {
21
+ const location = useLocation();
22
+ const parent = useResolvedPath('.').pathname.replace(/\/+$/u, '');
23
+ const searches = useRouteSearches();
24
+ const segment = location.pathname.slice(parent.length + 1).split('/')[0];
25
+ const active = tabs.find((tab) => tab.path === segment);
26
+ const destination = (tab: ListTab) => {
27
+ const params = new URLSearchParams(location.search);
28
+ if (!active || tab.dataset !== active.dataset) {
29
+ for (const key of parameterKeys) params.delete(key);
30
+ }
31
+ return { pathname: `${parent}/${tab.path}`, search: params.toString() };
32
+ };
33
+ const activePath = active ? `${parent}/${active.path}` : undefined;
34
+ return {
35
+ active,
36
+ source: activePath
37
+ ? { pathname: activePath, search: searches.get(activePath) ?? '' }
38
+ : undefined,
39
+ destination,
40
+ fallback: !pending && !active && tabs[0] ? destination(tabs[0]) : undefined,
41
+ };
42
+ }
@@ -1,50 +1,128 @@
1
- import { useEffect } from 'react';
2
- import { useSearchParams } from 'react-router';
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { useTranslation } from '@nocobase/i18n/client';
3
+ import { useLocation, useSearchParams } from 'react-router';
3
4
  import type {
4
5
  OnChangeFn,
5
6
  PaginationState,
6
7
  SortingState,
7
8
  } from '@tanstack/react-table';
8
9
 
10
+ /** A bounded recovery failed because concurrent writes kept moving the final page. */
11
+ export class ListRecoveryError extends Error {}
12
+
9
13
  const sizes = [10, 20, 50, 100] as const;
10
14
 
15
+ interface TableUrlOptions {
16
+ defaultPageSize?: number;
17
+ pageSizeOptions?: readonly number[];
18
+ sortColumns: readonly string[];
19
+ defaultSorting?: SortingState;
20
+ /** Match the existing endpoint contract; do not translate URLs on upgrade. */
21
+ sortFormat?: 'sort-order' | 'orderBy';
22
+ rowCount?: number;
23
+ active?: boolean;
24
+ revision?: number;
25
+ }
26
+
27
+ /** One parser for both the visible controls and the request. */
28
+ export function readTableUrlState(
29
+ params: URLSearchParams,
30
+ {
31
+ defaultPageSize = 10,
32
+ pageSizeOptions = sizes,
33
+ sortColumns,
34
+ defaultSorting = [],
35
+ sortFormat = 'sort-order',
36
+ }: TableUrlOptions,
37
+ ) {
38
+ const page = Number(params.get('page'));
39
+ const size = Number(params.get('pageSize'));
40
+ const fallback = defaultSorting.filter((sort) =>
41
+ sortColumns.includes(sort.id),
42
+ );
43
+ const fields =
44
+ sortFormat === 'orderBy'
45
+ ? (params.get('orderBy') ?? '').split(',').map((part) => {
46
+ const [id, direction = 'asc', ...rest] = part.trim().split(/\s+/u);
47
+ return { id, direction: rest.length ? '' : direction };
48
+ })
49
+ : [
50
+ {
51
+ id: params.get('sort') ?? sortColumns[0],
52
+ direction: params.get('order'),
53
+ },
54
+ ];
55
+ const valid = fields.every(
56
+ ({ id, direction }) =>
57
+ id &&
58
+ sortColumns.includes(id) &&
59
+ (direction === 'asc' || direction === 'desc'),
60
+ );
61
+ const sortingState: SortingState = valid
62
+ ? fields
63
+ .filter(
64
+ (field, index) =>
65
+ fields.findIndex((other) => other.id === field.id) === index,
66
+ )
67
+ .map(({ id, direction }) => ({ id, desc: direction === 'desc' }))
68
+ : fallback;
69
+ return {
70
+ paginationState: {
71
+ pageIndex: Number.isSafeInteger(page) && page > 0 ? page - 1 : 0,
72
+ pageSize: pageSizeOptions.includes(size) ? size : defaultPageSize,
73
+ },
74
+ sortingState,
75
+ };
76
+ }
77
+
11
78
  /** Keep list position shareable across overlays, refresh and browser history. */
12
79
  export function useTableUrlState({
13
80
  defaultPageSize = 10,
14
81
  pageSizeOptions = sizes,
15
82
  sortColumns,
16
83
  rowCount,
17
- }: {
18
- defaultPageSize?: number;
19
- pageSizeOptions?: readonly number[];
20
- sortColumns: readonly string[];
21
- rowCount?: number;
22
- }) {
84
+ active = true,
85
+ revision = 0,
86
+ defaultSorting = [],
87
+ sortFormat = 'sort-order',
88
+ }: TableUrlOptions) {
23
89
  const [params, setParams] = useSearchParams();
24
- const requestedPage = Number(params.get('page'));
25
- const pageIndex =
26
- Number.isSafeInteger(requestedPage) && requestedPage > 0
27
- ? requestedPage - 1
28
- : 0;
29
- const requestedSize = Number(params.get('pageSize'));
30
- const pageSize = pageSizeOptions.includes(requestedSize)
31
- ? requestedSize
32
- : defaultPageSize;
33
- const paginationState = { pageIndex, pageSize };
34
- const sort = params.get('sort') ?? sortColumns[0];
35
- const order = params.get('order');
36
- const sortingState: SortingState =
37
- sort && sortColumns.includes(sort) && (order === 'asc' || order === 'desc')
38
- ? [{ id: sort, desc: order === 'desc' }]
39
- : [];
90
+ const { paginationState, sortingState } = readTableUrlState(params, {
91
+ defaultPageSize,
92
+ pageSizeOptions,
93
+ sortColumns,
94
+ defaultSorting,
95
+ sortFormat,
96
+ });
97
+ const { pageIndex, pageSize } = paginationState;
98
+ const { t } = useTranslation();
99
+ const { pathname } = useLocation();
100
+ const contextParams = new URLSearchParams(params);
101
+ contextParams.delete('page');
102
+ const context = JSON.stringify([
103
+ pathname,
104
+ contextParams.toString(),
105
+ revision,
106
+ ]);
107
+ const correctionsRef = useRef({ context, attempts: 0, expectedPage: -1 });
108
+ const [blocked, setBlocked] = useState<string>();
40
109
  const onPaginationChange: OnChangeFn<PaginationState> = (updater) => {
41
110
  const next =
42
111
  typeof updater === 'function' ? updater(paginationState) : updater;
43
112
  setParams(
44
113
  (old) => {
45
114
  const result = new URLSearchParams(old);
46
- result.set('page', String(next.pageIndex + 1));
47
- result.set('pageSize', String(next.pageSize));
115
+ const size = pageSizeOptions.includes(next.pageSize)
116
+ ? next.pageSize
117
+ : defaultPageSize;
118
+ const index =
119
+ size !== pageSize ||
120
+ !Number.isSafeInteger(next.pageIndex) ||
121
+ next.pageIndex < 0
122
+ ? 0
123
+ : next.pageIndex;
124
+ result.set('page', String(index + 1));
125
+ result.set('pageSize', String(size));
48
126
  return result;
49
127
  },
50
128
  { replace: false },
@@ -57,7 +135,17 @@ export function useTableUrlState({
57
135
  (old) => {
58
136
  const result = new URLSearchParams(old);
59
137
  result.delete('page');
60
- if (next[0] && sortColumns.includes(next[0].id)) {
138
+ if (sortFormat === 'orderBy') {
139
+ const valid = next.filter((sort) => sortColumns.includes(sort.id));
140
+ if (valid.length)
141
+ result.set(
142
+ 'orderBy',
143
+ valid
144
+ .map((sort) => `${sort.id}${sort.desc ? ' desc' : ''}`)
145
+ .join(','),
146
+ );
147
+ else result.delete('orderBy');
148
+ } else if (next[0] && sortColumns.includes(next[0].id)) {
61
149
  result.set('sort', next[0].id);
62
150
  result.set('order', next[0].desc ? 'desc' : 'asc');
63
151
  } else {
@@ -71,9 +159,30 @@ export function useTableUrlState({
71
159
  };
72
160
  // Only pass a count for the current, successfully loaded query.
73
161
  useEffect(() => {
74
- if (rowCount === undefined) return;
162
+ if (
163
+ !active ||
164
+ rowCount === undefined ||
165
+ !Number.isSafeInteger(rowCount) ||
166
+ rowCount < 0
167
+ )
168
+ return;
169
+ if (
170
+ correctionsRef.current.context !== context ||
171
+ (correctionsRef.current.expectedPage !== -1 &&
172
+ correctionsRef.current.expectedPage !== pageIndex)
173
+ )
174
+ correctionsRef.current = { context, attempts: 0, expectedPage: -1 };
75
175
  const lastPage = Math.max(0, Math.ceil(rowCount / pageSize) - 1);
76
- if (pageIndex <= lastPage) return;
176
+ if (pageIndex <= lastPage) {
177
+ correctionsRef.current.attempts = 0;
178
+ return;
179
+ }
180
+ if (correctionsRef.current.attempts >= 2) {
181
+ setBlocked(`${context}:${pageIndex}`);
182
+ return;
183
+ }
184
+ correctionsRef.current.attempts += 1;
185
+ correctionsRef.current.expectedPage = lastPage;
77
186
  setParams(
78
187
  (old) => {
79
188
  const result = new URLSearchParams(old);
@@ -82,8 +191,12 @@ export function useTableUrlState({
82
191
  },
83
192
  { replace: true },
84
193
  );
85
- }, [rowCount, pageSize, pageIndex, setParams]);
194
+ }, [rowCount, pageSize, pageIndex, setParams, active, context]);
86
195
  return {
196
+ error:
197
+ blocked === `${context}:${pageIndex}`
198
+ ? new ListRecoveryError(t('settings.listChanged'))
199
+ : undefined,
87
200
  paginationState,
88
201
  onPaginationChange,
89
202
  sortingState,
@@ -8,6 +8,19 @@ import inboxEnUS from '#extensions/nocobase-inbox/locales/en-US';
8
8
  * upgrade replaces this file, so put the application's own copy in `../en-US.ts` rather than here.
9
9
  */
10
10
  const systemEnUS = {
11
+ listFeedback: {
12
+ loading: 'Loading',
13
+ empty: 'No data yet',
14
+ noResults: 'No results',
15
+ forbidden: 'Access denied',
16
+ unauthenticated: 'Session expired',
17
+ failed: 'Unable to load list',
18
+ refreshFailed: 'Refresh failed; showing previous data',
19
+ retry: 'Retry',
20
+ retrying: 'Retrying…',
21
+ clear: 'Clear filters',
22
+ signIn: 'Sign in',
23
+ },
11
24
  flowDiagram: {
12
25
  controls: 'Diagram controls',
13
26
  fitView: 'Fit to view',
@@ -54,6 +67,16 @@ const systemEnUS = {
54
67
  ...deviceApprovalEnUS,
55
68
  // The UI Library block of the `/inbox` page; the keys below may reword it.
56
69
  ...inboxEnUS,
70
+ // Shared form wording overrides the defaults included by composed blocks.
71
+ formFeedback: {
72
+ field: 'This value was rejected. Check it and try again.',
73
+ invalid: 'Check the highlighted fields and try again.',
74
+ failed: 'Unable to save. Your input has been kept. Try again.',
75
+ network:
76
+ 'Unable to connect. Check your connection and try again. Your input has been kept.',
77
+ denied:
78
+ 'You cannot submit this form with your current access. Your input has been kept.',
79
+ },
57
80
  // The UI Library component of the header's inbox button.
58
81
  'inboxButton.title': 'Inbox',
59
82
  'inboxButton.pending': 'Inbox, {{count}} waiting',
@@ -389,7 +412,7 @@ const systemEnUS = {
389
412
  back: 'Back',
390
413
  },
391
414
  dataTable: {
392
- noResults: 'No results.',
415
+ noResults: 'No results',
393
416
  sortAscending: 'Asc',
394
417
  sortDescending: 'Desc',
395
418
  hideColumn: 'Hide',
@@ -1,4 +1,5 @@
1
1
  const settings = {
2
+ listChanged: 'The list has changed. Please retry.',
2
3
  setup: {
3
4
  title: 'Feature prerequisites',
4
5
  checking: 'Checking current configuration…',
@@ -49,10 +50,6 @@ const settings = {
49
50
  records: 'Selected records ({{count}})',
50
51
  retained: 'Retain existing configuration',
51
52
  search: 'Search business objects or keys',
52
- empty: 'No default data scopes',
53
- emptyHint:
54
- 'Create a rule to configure baseline access for a business object.',
55
- readOnlyEmpty: 'Ask an administrator to configure baseline data access.',
56
53
  noResources:
57
54
  'Every available business object already has a default rule, or no configurable objects are registered.',
58
55
  unsupported:
@@ -85,8 +82,6 @@ const settings = {
85
82
  'Manage user information, account security and role assignments. Review each permission separately.',
86
83
  businessOperationsHint:
87
84
  'Choose the operations this role can perform and the data each operation can use.',
88
- pageAccessHint:
89
- 'Choose the business pages this role can open. All system settings, including user management and business configuration, are controlled under Configuration permissions. Data and business actions require separate permissions.',
90
85
  roleAssignment: 'Role assignment',
91
86
  accountSecurity: 'Account security',
92
87
  userInformation: 'User information',
@@ -144,10 +139,6 @@ const settings = {
144
139
  noResults: 'No matching results',
145
140
  clearHint: 'Try a different search or clear the filters.',
146
141
  noUsers: 'No users yet',
147
- usersReadOnlyEmpty:
148
- 'Contact an administrator to add users or grant the permissions needed to create users and assign roles.',
149
- rolesReadOnlyEmpty:
150
- 'Contact an administrator to create a role or grant you permission to create roles.',
151
142
  noRoleAccess:
152
143
  'This user can still sign in and use account basics. No business access is granted by roles.',
153
144
  noAssignedRoles: 'No assigned roles',
@@ -207,6 +198,13 @@ const settings = {
207
198
  configurationPermissionsHint:
208
199
  'Control access and actions for system settings, user management and business configuration. Only actual application features are shown.',
209
200
  permissionSearch: 'Search categories, groups or permissions',
201
+ permissionAll: 'All',
202
+ permissionGranted: 'Granted',
203
+ permissionUngranted: 'Ungranted',
204
+ permissionStatus: 'Authorization status',
205
+ permissionClearSearch: 'Clear search',
206
+ permissionCollapseAll: 'Collapse all',
207
+ permissionExpandAll: 'Expand all',
210
208
  enabledOnly: 'Enabled only',
211
209
  noPermissions: 'No permissions to configure',
212
210
  permissionEmptyHint:
@@ -1,4 +1,5 @@
1
1
  const settings = {
2
+ listChanged: '列表已变化,请重试。',
2
3
  setup: {
3
4
  title: '功能前置配置',
4
5
  checking: '正在检查当前配置…',
@@ -43,9 +44,6 @@ const settings = {
43
44
  records: '指定记录({{count}} 条)',
44
45
  retained: '保留现有配置',
45
46
  search: '搜索业务对象或标识',
46
- empty: '暂无默认数据范围',
47
- emptyHint: '新建规则,为业务对象配置基础数据访问范围。',
48
- readOnlyEmpty: '请联系管理员配置基础数据访问范围。',
49
47
  noResources: '可用业务对象均已配置默认规则,或当前没有可配置的业务对象。',
50
48
  unsupported:
51
49
  '此规则包含当前插件目录中不存在的配置,已保留原值并以只读方式展示。',
@@ -74,8 +72,6 @@ const settings = {
74
72
  '管理用户资料、账号安全和角色分配,请逐项确认需要授予的权限。',
75
73
  businessOperationsHint:
76
74
  '设置此角色可以执行的业务操作,以及每项操作可使用的数据范围。',
77
- pageAccessHint:
78
- '设置此角色可访问的业务页面。系统设置(包括用户管理、业务配置项)统一在“配置项权限”中控制。页面内的数据和业务操作仍需单独授权。',
79
75
  roleAssignment: '角色分配',
80
76
  accountSecurity: '账号安全',
81
77
  userInformation: '用户资料',
@@ -133,9 +129,6 @@ const settings = {
133
129
  noResults: '没有匹配的结果',
134
130
  clearHint: '请更换搜索条件或清除筛选。',
135
131
  noUsers: '暂无用户',
136
- usersReadOnlyEmpty:
137
- '请联系管理员添加用户,或授予创建用户和分配角色所需的权限。',
138
- rolesReadOnlyEmpty: '请联系管理员创建角色,或授予你创建角色的权限。',
139
132
  noRoleAccess: '此用户仍可登录并使用账号基础功能,角色未授予业务访问。',
140
133
  noAssignedRoles: '未分配角色',
141
134
  noRoles: '暂无角色',
@@ -190,6 +183,13 @@ const settings = {
190
183
  configurationPermissionsHint:
191
184
  '统一设置系统设置、用户管理和业务配置项的访问与操作权限,仅显示应用实际开放的功能。',
192
185
  permissionSearch: '搜索分类、分组或权限',
186
+ permissionAll: '全部',
187
+ permissionGranted: '已授权',
188
+ permissionUngranted: '未授权',
189
+ permissionStatus: '授权状态',
190
+ permissionClearSearch: '清空搜索',
191
+ permissionCollapseAll: '全部收起',
192
+ permissionExpandAll: '全部展开',
193
193
  enabledOnly: '仅显示已授权',
194
194
  noPermissions: '暂无可配置权限',
195
195
  permissionEmptyHint: '请联系应用开发者,开放此角色需要配置的权限。',
@@ -5,6 +5,19 @@ import inboxZhCN from '#extensions/nocobase-inbox/locales/zh-CN';
5
5
 
6
6
  /** The Chinese copy of what the template ships; see `./en-US.ts`. */
7
7
  const systemZhCN: SystemResource = {
8
+ listFeedback: {
9
+ loading: '加载中',
10
+ empty: '暂无数据',
11
+ noResults: '无结果',
12
+ forbidden: '无权访问',
13
+ unauthenticated: '会话已过期',
14
+ failed: '列表加载失败',
15
+ refreshFailed: '刷新失败,仍显示旧数据',
16
+ retry: '重试',
17
+ retrying: '正在重试…',
18
+ clear: '清空筛选',
19
+ signIn: '登录',
20
+ },
8
21
  flowDiagram: {
9
22
  controls: '流程图控制',
10
23
  fitView: '适应画布',
@@ -50,6 +63,14 @@ const systemZhCN: SystemResource = {
50
63
  ...deviceApprovalZhCN,
51
64
  // The UI Library block of the `/inbox` page; the keys below may reword it.
52
65
  ...inboxZhCN,
66
+ // Shared form wording overrides the defaults included by composed blocks.
67
+ formFeedback: {
68
+ field: '此字段的值未通过校验,请检查并修改后重试。',
69
+ invalid: '请检查标记的字段后重试。',
70
+ failed: '保存失败,已保留您的输入,请重试。',
71
+ network: '无法连接,请检查网络后重试。已保留您的输入。',
72
+ denied: '当前权限无法提交此表单。已保留您的输入。',
73
+ },
53
74
  // The UI Library component of the header's inbox button.
54
75
  'inboxButton.title': '收件箱',
55
76
  'inboxButton.pending': '收件箱,{{count}} 项待处理',
@@ -366,7 +387,7 @@ const systemZhCN: SystemResource = {
366
387
  back: '返回',
367
388
  },
368
389
  dataTable: {
369
- noResults: '暂无数据。',
390
+ noResults: '无结果',
370
391
  sortAscending: '升序',
371
392
  sortDescending: '降序',
372
393
  hideColumn: '隐藏',
@@ -1,3 +1,6 @@
1
+ import { FeedbackForm } from '#components/feedback-form';
2
+ import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
3
+ import { useSubmission } from '@nocobase/app-client';
1
4
  import { ApiClientError, useApiClient, useToaster } from '@nocobase/app-client';
2
5
  import {
3
6
  useCan,
@@ -60,7 +63,12 @@ function DefaultAccessFormBody(): ReactElement {
60
63
  action: creating ? 'create' : 'update',
61
64
  }).can;
62
65
  const [draft, setDraft] = useState<DefaultAccessRule>();
63
- const [busy, setBusy] = useState(false);
66
+ const { session } = useAuthentication();
67
+ const submission = useSubmission({
68
+ scope: `${session?.user.id}:settings:default-access:${key}`,
69
+ });
70
+ const [requestBusy, setRequestBusy] = useState(false);
71
+ const busy = requestBusy || submission.submitting;
64
72
  const [error, setError] = useState<unknown>();
65
73
  const [message, setMessage] = useState<string>();
66
74
  const [submitted, setSubmitted] = useState(false);
@@ -245,28 +253,36 @@ function DefaultAccessFormBody(): ReactElement {
245
253
  );
246
254
  return;
247
255
  }
248
- setBusy(true);
256
+ setRequestBusy(true);
249
257
  try {
250
- const latest = await api.request<{ data: readonly DefaultAccessRule[] }>({
251
- path: 'authorization/defaultAccess',
252
- });
253
- if (
254
- !creating &&
255
- JSON.stringify(latest.data.find((rule) => rule.key === key)) !==
256
- JSON.stringify(initial)
257
- ) {
258
- setMessage(t('settings.defaultAccess.changed'));
259
- return;
260
- }
261
- const saved = await api.request<{ data: DefaultAccessRule }>({
262
- path: creating
263
- ? 'authorization/defaultAccess'
264
- : `authorization/defaultAccess/${encodeURIComponent(key ?? '')}`,
265
- method: creating ? 'POST' : 'PATCH',
266
- json: creating
267
- ? { ...value, key: value.key.trim() }
268
- : { actions: value.actions },
269
- });
258
+ const saved = await submission.run(
259
+ { creating, key, value },
260
+ async (idempotencyKey, _signal, _snapshot, recovering) => {
261
+ if (!creating && !recovering) {
262
+ const latest = await api.request<{
263
+ data: readonly DefaultAccessRule[];
264
+ }>({ path: 'authorization/defaultAccess' });
265
+ if (
266
+ JSON.stringify(latest.data.find((rule) => rule.key === key)) !==
267
+ JSON.stringify(initial)
268
+ ) {
269
+ setMessage(t('settings.defaultAccess.changed'));
270
+ return undefined;
271
+ }
272
+ }
273
+ return api.request<{ data: DefaultAccessRule }>({
274
+ path: creating
275
+ ? 'authorization/defaultAccess'
276
+ : `authorization/defaultAccess/${encodeURIComponent(key ?? '')}`,
277
+ headers: { 'Idempotency-Key': idempotencyKey },
278
+ method: creating ? 'POST' : 'PATCH',
279
+ json: creating
280
+ ? { ...value, key: value.key.trim() }
281
+ : { actions: value.actions },
282
+ });
283
+ },
284
+ );
285
+ if (!saved) return;
270
286
  toaster.show({
271
287
  type: 'success',
272
288
  title: t('settings.defaultAccess.saved', { name: value.key }),
@@ -331,7 +347,7 @@ function DefaultAccessFormBody(): ReactElement {
331
347
  setMessage(t('settings.defaultAccess.invalid'));
332
348
  else setError(next);
333
349
  } finally {
334
- setBusy(false);
350
+ setRequestBusy(false);
335
351
  }
336
352
  };
337
353
  return (
@@ -360,7 +376,10 @@ function DefaultAccessFormBody(): ReactElement {
360
376
  {!creating && !initial ? (
361
377
  <p role='status'>{t('settings.missing')}</p>
362
378
  ) : (
363
- <form
379
+ <FeedbackForm
380
+ submitting={busy}
381
+ error={error}
382
+ errorContent={<SettingsError error={error} inline />}
364
383
  ref={formRef}
365
384
  noValidate
366
385
  className='flex flex-col gap-6'
@@ -369,7 +388,6 @@ function DefaultAccessFormBody(): ReactElement {
369
388
  void save();
370
389
  }}
371
390
  >
372
- <SettingsError error={error} />
373
391
  {message && (
374
392
  <Alert variant='destructive'>
375
393
  <AlertDescription>
@@ -487,7 +505,7 @@ function DefaultAccessFormBody(): ReactElement {
487
505
  </Button>
488
506
  )}
489
507
  </div>
490
- </form>
508
+ </FeedbackForm>
491
509
  )}
492
510
  {!creating && !initial && (
493
511
  <Button variant='outline' onClick={close}>