@nocobase/client-v2 2.3.0-alpha.1 → 3.0.0-alpha.2

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 (123) hide show
  1. package/es/acl/ACLProvider.d.ts +10 -0
  2. package/es/authRedirect.d.ts +5 -0
  3. package/es/components/AttachmentUpload.d.ts +51 -0
  4. package/es/components/form/ScanInput/useCodeScanner.d.ts +2 -2
  5. package/es/components/index.d.ts +1 -0
  6. package/es/flow/actions/linkageRules.d.ts +1 -0
  7. package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
  8. package/es/flow/components/FlowRoute.d.ts +2 -2
  9. package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
  10. package/es/flow/models/base/ActionModelCore.d.ts +4 -2
  11. package/es/flow/models/base/BlockGridModel.d.ts +3 -0
  12. package/es/flow/models/blocks/table/TableColumnModel.d.ts +1 -0
  13. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
  14. package/es/index.d.ts +4 -1
  15. package/es/index.mjs +139 -125
  16. package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
  17. package/es/nocobase-buildin-plugin/index.d.ts +9 -0
  18. package/es/settings-app/SettingsApplication.d.ts +18 -0
  19. package/es/settings-app/SettingsBrand.d.ts +16 -0
  20. package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
  21. package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
  22. package/es/settings-app/SettingsGroupNav.d.ts +17 -0
  23. package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
  24. package/es/settings-app/SettingsRouterManager.d.ts +15 -0
  25. package/es/settings-app/SettingsSearch.d.ts +17 -0
  26. package/es/settings-app/SettingsShell.d.ts +10 -0
  27. package/es/settings-app/isSettingsApp.d.ts +18 -0
  28. package/es/settings-app/runtimePaths.d.ts +18 -0
  29. package/es/settings-app/settingsDocumentPath.d.ts +14 -0
  30. package/es/settings-app/settingsTheme.d.ts +87 -0
  31. package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
  32. package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
  33. package/es/settings-center/groups.d.ts +66 -0
  34. package/es/settings-center/useSettingsGroups.d.ts +36 -0
  35. package/es/settings-center/useSettingsSearch.d.ts +35 -0
  36. package/es/settings-center/utils.d.ts +24 -0
  37. package/es/theme/index.d.ts +1 -0
  38. package/es/theme/itemActive.d.ts +24 -0
  39. package/es/theme/type.d.ts +9 -0
  40. package/lib/index.js +141 -127
  41. package/lib/locale/languageCodes.js +1 -0
  42. package/package.json +8 -7
  43. package/src/__tests__/authRedirect.test.ts +137 -0
  44. package/src/__tests__/browserChecker.test.ts +124 -6
  45. package/src/__tests__/mockSettingsApplication.ts +29 -0
  46. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +51 -10
  47. package/src/__tests__/plugin-manager.test.tsx +7 -6
  48. package/src/__tests__/settings-application.test.ts +164 -0
  49. package/src/__tests__/settings-auth-routes.test.ts +71 -0
  50. package/src/__tests__/settings-center.test.tsx +38 -37
  51. package/src/__tests__/settings-layout-root.test.tsx +260 -0
  52. package/src/__tests__/settings-runtime-paths.test.ts +99 -0
  53. package/src/__tests__/settings-shell.test.tsx +200 -0
  54. package/src/acl/ACLProvider.tsx +21 -0
  55. package/src/authRedirect.ts +72 -3
  56. package/src/components/AttachmentUpload.tsx +275 -0
  57. package/src/components/form/ScanInput/CodeScanner.tsx +61 -34
  58. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +101 -0
  59. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +114 -18
  60. package/src/components/form/ScanInput/useCodeScanner.ts +166 -23
  61. package/src/components/index.ts +1 -0
  62. package/src/flow/__tests__/FlowRoute.test.tsx +126 -8
  63. package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
  64. package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
  65. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
  66. package/src/flow/actions/linkageRules.tsx +26 -6
  67. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -56
  68. package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
  69. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
  70. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +77 -13
  71. package/src/flow/common/Markdown/style.ts +4 -0
  72. package/src/flow/components/BlockItemCard.tsx +2 -2
  73. package/src/flow/components/FlowRoute.tsx +71 -29
  74. package/src/flow/index.ts +2 -0
  75. package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
  76. package/src/flow/models/base/ActionModelCore.tsx +11 -2
  77. package/src/flow/models/base/BlockGridModel.tsx +26 -0
  78. package/src/flow/models/base/CollectionBlockModel.tsx +6 -2
  79. package/src/flow/models/base/PageModel/PageTabModel.tsx +67 -17
  80. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +509 -12
  81. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
  82. package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
  83. package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
  84. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +27 -3
  85. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +32 -4
  86. package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
  87. package/src/flow/models/blocks/table/TableBlockModel.tsx +3 -1
  88. package/src/flow/models/blocks/table/TableColumnModel.tsx +30 -1
  89. package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
  90. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
  91. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
  92. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
  93. package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
  94. package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
  95. package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
  96. package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
  97. package/src/index.ts +10 -1
  98. package/src/locale/languageCodes.ts +1 -0
  99. package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
  100. package/src/nocobase-buildin-plugin/index.tsx +99 -71
  101. package/src/settings-app/SettingsApplication.ts +31 -0
  102. package/src/settings-app/SettingsBrand.tsx +108 -0
  103. package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
  104. package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
  105. package/src/settings-app/SettingsGroupNav.tsx +124 -0
  106. package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
  107. package/src/settings-app/SettingsRouterManager.ts +92 -0
  108. package/src/settings-app/SettingsSearch.tsx +238 -0
  109. package/src/settings-app/SettingsShell.tsx +167 -0
  110. package/src/settings-app/isSettingsApp.ts +21 -0
  111. package/src/settings-app/runtimePaths.ts +104 -0
  112. package/src/settings-app/settingsDocumentPath.ts +66 -0
  113. package/src/settings-app/settingsTheme.ts +301 -0
  114. package/src/settings-app/useSettingsThemeConfig.ts +100 -0
  115. package/src/settings-center/AdminSettingsLayout.tsx +122 -141
  116. package/src/settings-center/SystemSettingsPage.tsx +4 -170
  117. package/src/settings-center/groups.ts +108 -0
  118. package/src/settings-center/useSettingsGroups.ts +105 -0
  119. package/src/settings-center/useSettingsSearch.ts +173 -0
  120. package/src/settings-center/utils.tsx +50 -1
  121. package/src/theme/index.tsx +1 -0
  122. package/src/theme/itemActive.ts +31 -0
  123. package/src/theme/type.ts +10 -0
@@ -7,13 +7,15 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { fireEvent, render, screen, waitFor } from '@testing-library/react';
10
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
11
11
  import React, { useCallback } from 'react';
12
12
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
13
- import { DEFAULT_CODE_FORMATS, getCodeScanBoxSize, useCodeScanner } from '../useCodeScanner';
13
+ import { DEFAULT_CODE_FORMATS, getCodeScanBoxSize, scanQrVideoFrame, useCodeScanner } from '../useCodeScanner';
14
14
 
15
15
  type MockScannerInstance = {
16
+ applyVideoConstraints: ReturnType<typeof vi.fn>;
16
17
  clear: ReturnType<typeof vi.fn>;
18
+ getRunningTrackCapabilities: ReturnType<typeof vi.fn>;
17
19
  getState: ReturnType<typeof vi.fn>;
18
20
  scanFileV2: ReturnType<typeof vi.fn>;
19
21
  start: ReturnType<typeof vi.fn>;
@@ -24,18 +26,24 @@ const mocks = vi.hoisted(() => {
24
26
  const start = vi.fn().mockResolvedValue(null);
25
27
  const stop = vi.fn().mockResolvedValue(null);
26
28
  const clear = vi.fn();
29
+ const applyVideoConstraints = vi.fn().mockResolvedValue(undefined);
30
+ const getRunningTrackCapabilities = vi.fn(() => ({}));
27
31
  const getState = vi.fn(() => 1);
28
32
  const scanFileV2 = vi.fn();
29
33
  const Html5Qrcode = vi.fn(function MockHtml5Qrcode(this: MockScannerInstance) {
30
34
  this.start = start;
31
35
  this.stop = stop;
32
36
  this.clear = clear;
37
+ this.applyVideoConstraints = applyVideoConstraints;
38
+ this.getRunningTrackCapabilities = getRunningTrackCapabilities;
33
39
  this.getState = getState;
34
40
  this.scanFileV2 = scanFileV2;
35
41
  });
36
42
 
37
43
  return {
44
+ applyVideoConstraints,
38
45
  clear,
46
+ getRunningTrackCapabilities,
39
47
  getState,
40
48
  Html5Qrcode,
41
49
  scanFileV2,
@@ -77,18 +85,15 @@ vi.mock('jsqr', () => jsQrMocks);
77
85
  function ScannerHost({
78
86
  onCameraStartFailure,
79
87
  onScanFailure,
80
- scanBoxSize,
81
88
  }: {
82
89
  onCameraStartFailure?: (error: unknown) => void;
83
90
  onScanFailure?: () => void;
84
- scanBoxSize?: { width: number; height: number };
85
91
  } = {}) {
86
92
  const handleScanSuccess = useCallback(() => undefined, []);
87
93
 
88
94
  useCodeScanner({
89
95
  elementId: 'scanner',
90
96
  enabled: true,
91
- scanBoxSize,
92
97
  onCameraStartFailure,
93
98
  onScanFailure,
94
99
  onScanSuccess: handleScanSuccess,
@@ -159,6 +164,11 @@ function stubCanvas() {
159
164
  describe('useCodeScanner', () => {
160
165
  beforeEach(() => {
161
166
  vi.clearAllMocks();
167
+ mocks.applyVideoConstraints.mockResolvedValue(undefined);
168
+ mocks.getRunningTrackCapabilities.mockReturnValue({});
169
+ mocks.start.mockResolvedValue(null);
170
+ mocks.stop.mockResolvedValue(null);
171
+ mocks.getState.mockReturnValue(1);
162
172
  jsQrMocks.default.mockReturnValue(undefined);
163
173
  });
164
174
 
@@ -188,32 +198,73 @@ describe('useCodeScanner', () => {
188
198
  | { qrbox?: (width: number, height: number) => { width: number; height: number } }
189
199
  | undefined;
190
200
 
191
- expect(config?.qrbox?.(390, 844)).toEqual({ width: 319, height: 240 });
192
- expect(getCodeScanBoxSize(390, 844)).toEqual({ width: 319, height: 240 });
201
+ expect(config?.qrbox?.(1280, 720)).toEqual({ width: 1152, height: 504 });
202
+ expect(getCodeScanBoxSize(1280, 720)).toEqual({ width: 1152, height: 504 });
193
203
  });
194
204
 
195
- it('can use the visible viewport scan box when the camera video is wider than the viewport', async () => {
196
- render(<ScannerHost scanBoxSize={{ width: 319, height: 240 }} />);
205
+ it('uses optimized camera constraints without requiring a user-selected scan mode', async () => {
206
+ render(<ScannerHost />);
197
207
 
198
208
  await waitFor(() => expect(mocks.start).toHaveBeenCalled());
199
209
 
200
210
  const config = mocks.start.mock.calls[0]?.[1] as
201
- | { qrbox?: (width: number, height: number) => { width: number; height: number } }
211
+ | {
212
+ disableFlip?: boolean;
213
+ fps?: number;
214
+ qrbox?: (width: number, height: number) => { width: number; height: number };
215
+ videoConstraints?: MediaTrackConstraints;
216
+ }
202
217
  | undefined;
203
218
 
204
- expect(config?.qrbox?.(1200, 844)).toEqual({ width: 319, height: 240 });
219
+ expect(config?.qrbox?.(1280, 720)).toEqual({ width: 1152, height: 504 });
220
+ expect(config).toMatchObject({
221
+ disableFlip: false,
222
+ fps: 8,
223
+ videoConstraints: {
224
+ facingMode: { ideal: 'environment' },
225
+ width: { ideal: 1920 },
226
+ height: { ideal: 1080 },
227
+ frameRate: { ideal: 30 },
228
+ },
229
+ });
230
+ expect(mocks.Html5Qrcode).toHaveBeenCalledWith('scanner', {
231
+ formatsToSupport: DEFAULT_CODE_FORMATS,
232
+ verbose: false,
233
+ });
205
234
  });
206
235
 
207
- it('clamps the scan box to the camera viewfinder size', async () => {
208
- render(<ScannerHost scanBoxSize={{ width: 319, height: 240 }} />);
236
+ it('decodes a centered QR code from the raw camera frame fast path', () => {
237
+ jsQrMocks.default.mockReturnValueOnce({ data: 'FAST-QR' });
238
+ stubCanvas();
239
+ const video = document.createElement('video');
240
+ Object.defineProperties(video, {
241
+ clientHeight: { value: 720 },
242
+ clientWidth: { value: 1280 },
243
+ readyState: { value: HTMLMediaElement.HAVE_CURRENT_DATA },
244
+ videoHeight: { value: 1080 },
245
+ videoWidth: { value: 1920 },
246
+ });
247
+ const canvas = document.createElement('canvas');
248
+
249
+ expect(scanQrVideoFrame(video, canvas)).toBe('FAST-QR');
209
250
 
210
- await waitFor(() => expect(mocks.start).toHaveBeenCalled());
251
+ const context = canvas.getContext('2d');
252
+ expect(context?.drawImage).toHaveBeenCalledWith(video, 96, 162, 1728, 756, 0, 0, 960, 420);
253
+ expect(jsQrMocks.default).toHaveBeenCalledWith(expect.any(Uint8ClampedArray), 960, 420, {
254
+ inversionAttempts: 'dontInvert',
255
+ });
256
+ });
211
257
 
212
- const config = mocks.start.mock.calls[0]?.[1] as
213
- | { qrbox?: (width: number, height: number) => { width: number; height: number } }
214
- | undefined;
258
+ it('enables continuous camera focus when the device supports it', async () => {
259
+ mocks.getRunningTrackCapabilities.mockReturnValue({ focusMode: ['manual', 'continuous'] });
260
+
261
+ render(<ScannerHost />);
215
262
 
216
- expect(config?.qrbox?.(200, 160)).toEqual({ width: 200, height: 160 });
263
+ await waitFor(() =>
264
+ expect(mocks.applyVideoConstraints).toHaveBeenCalledWith({
265
+ advanced: [{ focusMode: 'continuous' }],
266
+ }),
267
+ );
217
268
  });
218
269
 
219
270
  it('reports camera start failures through the dedicated handler', async () => {
@@ -228,6 +279,51 @@ describe('useCodeScanner', () => {
228
279
  expect(handleScanFailure).not.toHaveBeenCalled();
229
280
  });
230
281
 
282
+ it('stops a camera that finishes starting after the scanner unmounts', async () => {
283
+ let resolveStart: ((value: null) => void) | undefined;
284
+ mocks.start.mockReturnValueOnce(
285
+ new Promise<null>((resolve) => {
286
+ resolveStart = resolve;
287
+ }),
288
+ );
289
+ mocks.getState.mockReturnValueOnce(1).mockReturnValue(2);
290
+
291
+ const { unmount } = render(<ScannerHost />);
292
+ await waitFor(() => expect(mocks.start).toHaveBeenCalled());
293
+
294
+ unmount();
295
+ await act(async () => {
296
+ resolveStart?.(null);
297
+ });
298
+
299
+ await waitFor(() => expect(mocks.stop).toHaveBeenCalledTimes(1));
300
+ expect(mocks.clear).toHaveBeenCalled();
301
+ });
302
+
303
+ it('ignores a camera start failure after the scanner unmounts', async () => {
304
+ let rejectStart: ((error: Error) => void) | undefined;
305
+ mocks.start.mockReturnValueOnce(
306
+ new Promise<null>((_resolve, reject) => {
307
+ rejectStart = reject;
308
+ }),
309
+ );
310
+ const handleCameraStartFailure = vi.fn();
311
+ const handleScanFailure = vi.fn();
312
+
313
+ const { unmount } = render(
314
+ <ScannerHost onCameraStartFailure={handleCameraStartFailure} onScanFailure={handleScanFailure} />,
315
+ );
316
+ await waitFor(() => expect(mocks.start).toHaveBeenCalled());
317
+
318
+ unmount();
319
+ await act(async () => {
320
+ rejectStart?.(new Error('Camera start canceled'));
321
+ });
322
+
323
+ expect(handleCameraStartFailure).not.toHaveBeenCalled();
324
+ expect(handleScanFailure).not.toHaveBeenCalled();
325
+ });
326
+
231
327
  it('uses jsQR first for Safari uploaded QR images', async () => {
232
328
  const handleScanSuccess = vi.fn();
233
329
  jsQrMocks.default.mockReturnValueOnce({ data: 'JSQR-CODE' });
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { Html5Qrcode, Html5QrcodeScannerState, Html5QrcodeSupportedFormats } from 'html5-qrcode';
11
11
  import jsQR from 'jsqr';
12
- import { useCallback, useEffect, useState } from 'react';
12
+ import { useCallback, useEffect, useRef, useState } from 'react';
13
13
  import type { CodeFormatsToSupport } from './types';
14
14
 
15
15
  type ScannerSize = {
@@ -21,7 +21,6 @@ type UseCodeScannerOptions = {
21
21
  enabled: boolean;
22
22
  elementId: string;
23
23
  formatsToSupport?: CodeFormatsToSupport;
24
- scanBoxSize?: ScannerSize;
25
24
  onScannerSizeChanged?: (size: ScannerSize) => void;
26
25
  onScanSuccess: (text: string) => void;
27
26
  onScanFailure?: () => void;
@@ -38,7 +37,18 @@ type JsQRImageTransform = {
38
37
  threshold?: number;
39
38
  };
40
39
 
40
+ type FocusMediaTrackCapabilities = MediaTrackCapabilities & {
41
+ focusMode?: string[];
42
+ };
43
+
44
+ type FocusMediaTrackConstraintSet = MediaTrackConstraintSet & {
45
+ focusMode?: string;
46
+ };
47
+
41
48
  const QR_SCAN_IMAGE_SIZES = [3200, 2400, 1600, 1000];
49
+ const LIVE_QR_SCAN_INTERVAL = 120;
50
+ const LIVE_QR_SCAN_MAX_WIDTH = 960;
51
+ const LIVE_QR_SCAN_MAX_HEIGHT = 540;
42
52
  const QR_SCAN_IMAGE_TRANSFORMS: JsQRImageTransform[] = [
43
53
  {},
44
54
  { contrast: 3, threshold: 105 },
@@ -66,8 +76,72 @@ export const DEFAULT_CODE_FORMATS: CodeFormatsToSupport = [
66
76
 
67
77
  export function getCodeScanBoxSize(width: number, height: number) {
68
78
  return {
69
- width: Math.floor(Math.min(width * 0.82, 520)),
70
- height: Math.floor(Math.min(height * 0.32, 240)),
79
+ width: Math.floor(Math.min((width * 90) / 100, 1152)),
80
+ height: Math.floor(Math.min((height * 70) / 100, 540)),
81
+ };
82
+ }
83
+
84
+ export function scanQrVideoFrame(video: HTMLVideoElement, canvas: HTMLCanvasElement) {
85
+ if (!video.videoWidth || !video.videoHeight || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
86
+ return;
87
+ }
88
+
89
+ const viewfinderWidth = video.clientWidth || video.videoWidth;
90
+ const viewfinderHeight = video.clientHeight || video.videoHeight;
91
+ const scanBoxSize = getCodeScanBoxSize(viewfinderWidth, viewfinderHeight);
92
+ const sourceWidth = Math.min(video.videoWidth, Math.floor(scanBoxSize.width * (video.videoWidth / viewfinderWidth)));
93
+ const sourceHeight = Math.min(
94
+ video.videoHeight,
95
+ Math.floor(scanBoxSize.height * (video.videoHeight / viewfinderHeight)),
96
+ );
97
+ const sourceX = Math.floor((video.videoWidth - sourceWidth) / 2);
98
+ const sourceY = Math.floor((video.videoHeight - sourceHeight) / 2);
99
+ const targetScale = Math.min(1, LIVE_QR_SCAN_MAX_WIDTH / sourceWidth, LIVE_QR_SCAN_MAX_HEIGHT / sourceHeight);
100
+ const targetWidth = Math.max(1, Math.floor(sourceWidth * targetScale));
101
+ const targetHeight = Math.max(1, Math.floor(sourceHeight * targetScale));
102
+ if (canvas.width !== targetWidth) {
103
+ canvas.width = targetWidth;
104
+ }
105
+ if (canvas.height !== targetHeight) {
106
+ canvas.height = targetHeight;
107
+ }
108
+ const context = canvas.getContext('2d', { willReadFrequently: true });
109
+ if (!context) {
110
+ return;
111
+ }
112
+
113
+ context.drawImage(video, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, targetWidth, targetHeight);
114
+ const imageData = context.getImageData(0, 0, targetWidth, targetHeight);
115
+ return jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: 'dontInvert' })?.data;
116
+ }
117
+
118
+ function startLiveQrScan(elementId: string, onScanSuccess: (text: string) => void) {
119
+ const canvas = document.createElement('canvas');
120
+ let timer: number | undefined;
121
+ let stopped = false;
122
+
123
+ const scan = () => {
124
+ if (stopped) {
125
+ return;
126
+ }
127
+ const video = document.getElementById(elementId)?.querySelector('video');
128
+ if (video) {
129
+ const decodedText = scanQrVideoFrame(video, canvas);
130
+ if (decodedText) {
131
+ stopped = true;
132
+ onScanSuccess(decodedText);
133
+ return;
134
+ }
135
+ }
136
+ timer = window.setTimeout(scan, LIVE_QR_SCAN_INTERVAL);
137
+ };
138
+
139
+ timer = window.setTimeout(scan, 0);
140
+ return () => {
141
+ stopped = true;
142
+ if (timer !== undefined) {
143
+ window.clearTimeout(timer);
144
+ }
71
145
  };
72
146
  }
73
147
 
@@ -92,6 +166,19 @@ async function stopScanner(scanner?: Html5Qrcode, options: { clear?: boolean } =
92
166
  }
93
167
  }
94
168
 
169
+ async function enableContinuousFocus(scanner: Html5Qrcode) {
170
+ try {
171
+ const capabilities = scanner.getRunningTrackCapabilities() as FocusMediaTrackCapabilities;
172
+ if (!capabilities.focusMode?.includes('continuous')) {
173
+ return;
174
+ }
175
+ const focusConstraints: FocusMediaTrackConstraintSet = { focusMode: 'continuous' };
176
+ await scanner.applyVideoConstraints({ advanced: [focusConstraints] });
177
+ } catch {
178
+ // Some browsers expose incomplete camera capability APIs. Scanning should continue without explicit focus control.
179
+ }
180
+ }
181
+
95
182
  function isSafariBrowser() {
96
183
  const { userAgent, vendor } = navigator;
97
184
  return /Apple/i.test(vendor) && /Safari/i.test(userAgent) && !/CriOS|FxiOS|EdgiOS|Chrome/i.test(userAgent);
@@ -216,40 +303,95 @@ export function useCodeScanner({
216
303
  enabled,
217
304
  elementId,
218
305
  formatsToSupport,
219
- scanBoxSize,
220
306
  onScannerSizeChanged,
221
307
  onScanSuccess,
222
308
  onScanFailure,
223
309
  onCameraStartFailure,
224
310
  }: UseCodeScannerOptions) {
225
311
  const [scanner, setScanner] = useState<Html5Qrcode>();
312
+ const liveQrScanStopRef = useRef<() => void>();
313
+ const scanSucceededRef = useRef(false);
314
+ const scanSessionRef = useRef(0);
315
+
316
+ const stopLiveQrScan = useCallback(() => {
317
+ liveQrScanStopRef.current?.();
318
+ liveQrScanStopRef.current = undefined;
319
+ }, []);
320
+
321
+ const cancelActiveScan = useCallback(() => {
322
+ scanSessionRef.current += 1;
323
+ stopLiveQrScan();
324
+ }, [stopLiveQrScan]);
325
+
326
+ const reportScanSuccess = useCallback(
327
+ (text: string) => {
328
+ if (scanSucceededRef.current) {
329
+ return;
330
+ }
331
+ scanSucceededRef.current = true;
332
+ stopLiveQrScan();
333
+ onScanSuccess(text);
334
+ },
335
+ [onScanSuccess, stopLiveQrScan],
336
+ );
226
337
 
227
338
  const startScanCamera = useCallback(
228
339
  async (scannerInstance: Html5Qrcode) => {
229
- await scannerInstance.start(
230
- { facingMode: 'environment' },
231
- {
232
- fps: 10,
233
- qrbox(width, height) {
234
- onScannerSizeChanged?.({ width, height });
235
- return clampCodeScanBoxSize(scanBoxSize ?? getCodeScanBoxSize(width, height), width, height);
340
+ cancelActiveScan();
341
+ const scanSession = scanSessionRef.current;
342
+ scanSucceededRef.current = false;
343
+ try {
344
+ await scannerInstance.start(
345
+ { facingMode: 'environment' },
346
+ {
347
+ fps: 8,
348
+ disableFlip: false,
349
+ videoConstraints: {
350
+ facingMode: { ideal: 'environment' },
351
+ width: { ideal: 1920 },
352
+ height: { ideal: 1080 },
353
+ frameRate: { ideal: 30 },
354
+ },
355
+ qrbox(width, height) {
356
+ onScannerSizeChanged?.({ width, height });
357
+ return clampCodeScanBoxSize(getCodeScanBoxSize(width, height), width, height);
358
+ },
359
+ },
360
+ (decodedText) => {
361
+ reportScanSuccess(decodedText);
236
362
  },
237
- },
238
- (decodedText) => {
239
- onScanSuccess(decodedText);
240
- },
241
- undefined,
242
- );
363
+ undefined,
364
+ );
365
+ } catch (error) {
366
+ if (scanSession !== scanSessionRef.current) {
367
+ return;
368
+ }
369
+ throw error;
370
+ }
371
+ if (scanSession !== scanSessionRef.current) {
372
+ try {
373
+ await stopScanner(scannerInstance, { clear: true });
374
+ } catch {
375
+ // The scanner may already have been cleared by the canceled session cleanup.
376
+ }
377
+ return;
378
+ }
379
+ if (shouldScanQrWithJsQR(formatsToSupport)) {
380
+ liveQrScanStopRef.current = startLiveQrScan(elementId, reportScanSuccess);
381
+ }
382
+ await enableContinuousFocus(scannerInstance);
243
383
  },
244
- [onScanSuccess, onScannerSizeChanged, scanBoxSize],
384
+ [cancelActiveScan, elementId, formatsToSupport, onScannerSizeChanged, reportScanSuccess],
245
385
  );
246
386
 
247
387
  const startScanFile = useCallback(
248
388
  async (file: File) => {
389
+ cancelActiveScan();
390
+ scanSucceededRef.current = false;
249
391
  if (isSafariBrowser() && shouldScanQrWithJsQR(formatsToSupport)) {
250
392
  try {
251
393
  const decodedText = await scanFileWithJsQR(file, formatsToSupport);
252
- onScanSuccess(decodedText);
394
+ reportScanSuccess(decodedText);
253
395
  return;
254
396
  } catch {
255
397
  // Fall through to html5-qrcode so barcode uploads still work in Safari.
@@ -263,13 +405,13 @@ export function useCodeScanner({
263
405
  await stopScanner(scanner);
264
406
  try {
265
407
  const result = await scanner.scanFileV2(file, false);
266
- onScanSuccess(result.decodedText);
408
+ reportScanSuccess(result.decodedText);
267
409
  } catch {
268
410
  onScanFailure?.();
269
411
  await startScanCamera(scanner);
270
412
  }
271
413
  },
272
- [formatsToSupport, onScanFailure, onScanSuccess, scanner, startScanCamera],
414
+ [cancelActiveScan, formatsToSupport, onScanFailure, reportScanSuccess, scanner, startScanCamera],
273
415
  );
274
416
 
275
417
  useEffect(() => {
@@ -291,9 +433,10 @@ export function useCodeScanner({
291
433
  });
292
434
 
293
435
  return () => {
436
+ cancelActiveScan();
294
437
  stopScanner(scannerInstance, { clear: true }).catch(() => undefined);
295
438
  };
296
- }, [elementId, enabled, formatsToSupport, onCameraStartFailure, onScanFailure, startScanCamera]);
439
+ }, [cancelActiveScan, elementId, enabled, formatsToSupport, onCameraStartFailure, onScanFailure, startScanCamera]);
297
440
 
298
441
  return {
299
442
  startScanFile,
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  export * from './AppComponents';
11
+ export * from './AttachmentUpload';
11
12
  export * from './BlankComponent';
12
13
  export * from './category-tabs';
13
14
  export * from './form/table/dnd';
@@ -116,6 +116,7 @@ describe('FlowRoute', () => {
116
116
  }),
117
117
  );
118
118
  });
119
+ expect(engine.context.deviceType).toBe('computer');
119
120
 
120
121
  rerender(
121
122
  <FlowEngineProvider engine={engine}>
@@ -581,14 +582,14 @@ describe('FlowRoute', () => {
581
582
  expect(getRouteBySchemaUid).toHaveBeenCalledTimes(1);
582
583
  });
583
584
 
584
- it('should show 404 when current route is a legacy page in v2 runtime', async () => {
585
+ it('should explain how to open a legacy page outside the v2 runtime', async () => {
585
586
  const originalLocation = window.location;
586
587
  const replace = vi.fn();
587
588
  Object.defineProperty(window, 'location', {
588
589
  configurable: true,
589
590
  value: {
590
591
  ...originalLocation,
591
- pathname: '/v2/admin/test-page/tab/tab-1',
592
+ pathname: '/nocobase/v2/apps/jhb20/admin/test-page/tab/tab-1',
592
593
  search: '?from=direct',
593
594
  hash: '#dialog',
594
595
  replace,
@@ -607,9 +608,9 @@ describe('FlowRoute', () => {
607
608
  });
608
609
  engine.context.defineProperty('app', {
609
610
  value: {
610
- getPublicPath: () => '/v2/',
611
+ getPublicPath: () => '/nocobase/v2/',
611
612
  router: {
612
- getBasename: () => '/v2',
613
+ getBasename: () => '/nocobase/v2',
613
614
  },
614
615
  },
615
616
  });
@@ -625,15 +626,25 @@ describe('FlowRoute', () => {
625
626
 
626
627
  render(
627
628
  <FlowEngineProvider engine={engine}>
628
- <MemoryRouter initialEntries={['/flow/test-page']}>
629
+ <MemoryRouter initialEntries={['/nocobase/v2/apps/jhb20/admin/test-page/tab/tab-1?from=direct#dialog']}>
629
630
  <Routes>
630
- <Route path="/flow/:name" element={<FlowRoute />} />
631
+ <Route path="/nocobase/v2/apps/jhb20/admin/:name/*" element={<FlowRoute />} />
631
632
  </Routes>
632
633
  </MemoryRouter>
633
634
  </FlowEngineProvider>,
634
635
  );
635
636
 
636
- expect(await screen.findByText('404')).toBeInTheDocument();
637
+ expect(await screen.findByText('This page is not supported in the /v2/ branch')).toBeInTheDocument();
638
+ expect(
639
+ screen.getByText(
640
+ 'The /v2/ branch only supports new pages. This page is a legacy page. Please open it from the original entry.',
641
+ ),
642
+ ).toBeInTheDocument();
643
+ expect(screen.queryByText('404')).not.toBeInTheDocument();
644
+ expect(screen.getByRole('link', { name: 'Open from the original entry' })).toHaveAttribute(
645
+ 'href',
646
+ '/nocobase/apps/jhb20/admin/test-page/tab/tab-1?from=direct#dialog',
647
+ );
637
648
  expect(replace).not.toHaveBeenCalled();
638
649
  expect(adminLayoutModel.registerRoutePage).not.toHaveBeenCalled();
639
650
  expect(adminLayoutModel.updateRoutePage).not.toHaveBeenCalled();
@@ -706,6 +717,113 @@ describe('FlowRoute', () => {
706
717
  }
707
718
  });
708
719
 
720
+ it('should render 404 for missing FlowModel in notFound mode without routeRepository', async () => {
721
+ const engine = new FlowEngine();
722
+ engine.context.defineProperty('app', {
723
+ value: {
724
+ getPublicPath: () => '/v2/',
725
+ router: {
726
+ getBasename: () => '/v2',
727
+ },
728
+ },
729
+ });
730
+
731
+ const adminLayoutModel: MockAdminLayoutModel = Object.assign(
732
+ engine.createModel({ uid: 'admin-layout-model', use: 'FlowModel' }),
733
+ {
734
+ registerRoutePage: vi.fn(),
735
+ updateRoutePage: vi.fn(),
736
+ unregisterRoutePage: vi.fn(),
737
+ },
738
+ );
739
+
740
+ render(
741
+ <FlowEngineProvider engine={engine}>
742
+ <MemoryRouter initialEntries={['/embed/missing-page']}>
743
+ <Routes>
744
+ <Route path="/embed/:name" element={<FlowRoute legacyPageBehavior="notFound" />} />
745
+ </Routes>
746
+ </MemoryRouter>
747
+ </FlowEngineProvider>,
748
+ );
749
+
750
+ expect(await screen.findByText('404')).toBeInTheDocument();
751
+ expect(adminLayoutModel.registerRoutePage).not.toHaveBeenCalled();
752
+ });
753
+
754
+ it('should bridge by default when routeRepository does not exist', async () => {
755
+ const engine = new FlowEngine();
756
+ engine.context.defineProperty('app', {
757
+ value: {
758
+ getPublicPath: () => '/v2/',
759
+ router: {
760
+ getBasename: () => '/v2',
761
+ },
762
+ },
763
+ });
764
+
765
+ const adminLayoutModel: MockAdminLayoutModel = Object.assign(
766
+ engine.createModel({ uid: 'admin-layout-model', use: 'FlowModel' }),
767
+ {
768
+ registerRoutePage: vi.fn(),
769
+ updateRoutePage: vi.fn(),
770
+ unregisterRoutePage: vi.fn(),
771
+ },
772
+ );
773
+
774
+ render(
775
+ <FlowEngineProvider engine={engine}>
776
+ <MemoryRouter initialEntries={['/flow/missing-page']}>
777
+ <Routes>
778
+ <Route path="/flow/:name" element={<FlowRoute />} />
779
+ </Routes>
780
+ </MemoryRouter>
781
+ </FlowEngineProvider>,
782
+ );
783
+
784
+ await waitFor(() => {
785
+ expect(adminLayoutModel.registerRoutePage).toHaveBeenCalledWith('missing-page', expect.any(Object));
786
+ });
787
+ expect(screen.queryByText('404')).not.toBeInTheDocument();
788
+ });
789
+
790
+ it('should bridge existing FlowModel in notFound mode without routeRepository', async () => {
791
+ const engine = new FlowEngine();
792
+ engine.createModel({ uid: 'test-page', use: 'FlowModel' });
793
+ engine.context.defineProperty('app', {
794
+ value: {
795
+ getPublicPath: () => '/v2/',
796
+ router: {
797
+ getBasename: () => '/v2',
798
+ },
799
+ },
800
+ });
801
+
802
+ const adminLayoutModel: MockAdminLayoutModel = Object.assign(
803
+ engine.createModel({ uid: 'admin-layout-model', use: 'FlowModel' }),
804
+ {
805
+ registerRoutePage: vi.fn(),
806
+ updateRoutePage: vi.fn(),
807
+ unregisterRoutePage: vi.fn(),
808
+ },
809
+ );
810
+
811
+ render(
812
+ <FlowEngineProvider engine={engine}>
813
+ <MemoryRouter initialEntries={['/embed/test-page']}>
814
+ <Routes>
815
+ <Route path="/embed/:name" element={<FlowRoute legacyPageBehavior="notFound" />} />
816
+ </Routes>
817
+ </MemoryRouter>
818
+ </FlowEngineProvider>,
819
+ );
820
+
821
+ await waitFor(() => {
822
+ expect(adminLayoutModel.registerRoutePage).toHaveBeenCalledWith('test-page', expect.any(Object));
823
+ });
824
+ expect(screen.queryByText('404')).not.toBeInTheDocument();
825
+ });
826
+
709
827
  it('should bridge existing FlowModel when behavior is notFound and routeRepository has no route', async () => {
710
828
  const engine = new FlowEngine();
711
829
  engine.setModelRepository({
@@ -1801,7 +1919,7 @@ describe('FlowRoute', () => {
1801
1919
  }
1802
1920
  });
1803
1921
 
1804
- it('should render not found without redirecting when admin route does not exist', async () => {
1922
+ it('should render 404 when route and FlowModel do not exist', async () => {
1805
1923
  const originalLocation = window.location;
1806
1924
  const replace = vi.fn();
1807
1925
  Object.defineProperty(window, 'location', {