@gadmin2n/schematics 0.0.148 → 0.0.150

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 (24) hide show
  1. package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/workflow.prisma +1 -1
  2. package/dist/lib/application/files/gadmin2-game-angle-demo/server/package.json +1 -1
  3. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-node-types.ts +66 -49
  4. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/agenda/agenda.controller.ts +4 -0
  5. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/agenda/agenda.service.ts +31 -4
  6. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-event-catalog.spec.ts +166 -17
  7. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-event-trigger.pg.spec.ts +82 -0
  8. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.dto.ts +7 -2
  9. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +91 -36
  10. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/node-config-validator.service.spec.ts +57 -0
  11. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/workflow.json +11 -3
  12. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/workflow.json +11 -3
  13. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/agenda/index.tsx +41 -1
  14. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/RegisterBusinessEventModal.test.tsx +46 -5
  15. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/RegisterBusinessEventModal.tsx +14 -10
  16. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/detail.test.tsx +1 -1
  17. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/detail.tsx +2 -1
  18. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/index.test.tsx +1 -1
  19. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/index.tsx +2 -2
  20. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerConfigEditor.test.tsx +140 -0
  21. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerConfigEditor.tsx +123 -60
  22. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SimplifiedNodeEditors.test.tsx +31 -14
  23. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/event-catalog.api.ts +4 -2
  24. package/package.json +1 -1
@@ -61,7 +61,7 @@ export interface BusinessEventCatalogItem {
61
61
  label: string;
62
62
  schemaName: string;
63
63
  tableName: string;
64
- operation: 'INSERT' | 'UPDATE' | 'DELETE';
64
+ operations: Array<'INSERT' | 'UPDATE' | 'DELETE'>;
65
65
  watchedFields: string[];
66
66
  primaryKeyFields: string[];
67
67
  isEnabled: boolean;
@@ -128,7 +128,7 @@ export function buildBusinessEventCatalog(
128
128
  code: string;
129
129
  schemaName: string;
130
130
  tableName: string;
131
- operation: string;
131
+ operations: string[];
132
132
  watchedFields: Prisma.JsonValue;
133
133
  primaryKeyFields: Prisma.JsonValue;
134
134
  isEnabled: boolean;
@@ -143,12 +143,14 @@ export function buildBusinessEventCatalog(
143
143
  column.schemaName === row.schemaName &&
144
144
  column.tableName === row.tableName,
145
145
  );
146
- const prefixes =
147
- row.operation === 'INSERT'
148
- ? ['after']
149
- : row.operation === 'DELETE'
146
+ const prefixes = [
147
+ ...(row.operations.some((operation) => operation !== 'INSERT')
150
148
  ? ['before']
151
- : ['before', 'after'];
149
+ : []),
150
+ ...(row.operations.some((operation) => operation !== 'DELETE')
151
+ ? ['after']
152
+ : []),
153
+ ];
152
154
  const fields = [
153
155
  ...primaryKeyFields.flatMap((columnName) => {
154
156
  const column = sourceColumns.find(
@@ -164,17 +166,22 @@ export function buildBusinessEventCatalog(
164
166
  `${prefix}.${column.columnName}`,
165
167
  column,
166
168
  false,
167
- column.nullable,
169
+ column.nullable ||
170
+ row.operations.includes(
171
+ prefix === 'before' ? 'INSERT' : 'DELETE',
172
+ ),
168
173
  ),
169
174
  ),
170
175
  ),
171
176
  ];
172
177
  return {
173
178
  registrationCode: row.code,
174
- label: `${row.schemaName}.${row.tableName} · ${row.operation}`,
179
+ label: `${row.schemaName}.${row.tableName} · ${row.operations.join(
180
+ ' / ',
181
+ )}`,
175
182
  schemaName: row.schemaName,
176
183
  tableName: row.tableName,
177
- operation: row.operation as BusinessEventCatalogItem['operation'],
184
+ operations: row.operations as BusinessEventCatalogItem['operations'],
178
185
  watchedFields,
179
186
  primaryKeyFields,
180
187
  isEnabled: row.isEnabled,
@@ -226,7 +233,28 @@ export function validateBusinessEventTriggers(
226
233
  const event = eventByCode.get(registrationCode);
227
234
  if (!event) continue;
228
235
 
229
- validateBusinessEventMatch(config.match, event);
236
+ const rules = config.operationRules;
237
+ if (!Array.isArray(rules) || rules.length === 0)
238
+ throw invalidBusinessEventMatch(event, '请至少启用一种操作的启动规则');
239
+ const seen = new Set<string>();
240
+ for (const value of rules) {
241
+ const rule = asRecord(value);
242
+ const operation =
243
+ rule.operation as BusinessEventCatalogItem['operations'][number];
244
+ if (!event.operations.includes(operation) || seen.has(operation))
245
+ throw invalidBusinessEventMatch(event, '操作未登记或重复配置');
246
+ seen.add(operation);
247
+ // before is unavailable for INSERT; after is unavailable for DELETE.
248
+ const availableEvent = {
249
+ ...event,
250
+ fields: event.fields.filter(
251
+ (field) =>
252
+ !(operation === 'INSERT' && field.path.startsWith('before.')) &&
253
+ !(operation === 'DELETE' && field.path.startsWith('after.')),
254
+ ),
255
+ };
256
+ validateBusinessEventMatch(rule.match, availableEvent);
257
+ }
230
258
 
231
259
  if (config.processingMode !== 'SERIAL_BY_KEY') continue;
232
260
 
@@ -246,7 +274,13 @@ export function validateBusinessEventTriggers(
246
274
  }
247
275
  }
248
276
 
249
- const EVENT_MATCH_OPERATORS = new Set(['EQ', 'NEQ', 'EMPTY', 'NOT_EMPTY']);
277
+ const EVENT_MATCH_OPERATORS = new Set([
278
+ 'EQ',
279
+ 'NEQ',
280
+ 'IN',
281
+ 'EMPTY',
282
+ 'NOT_EMPTY',
283
+ ]);
250
284
  const EVENT_MATCH_UNARY_OPERATORS = new Set(['EMPTY', 'NOT_EMPTY']);
251
285
 
252
286
  function validateBusinessEventMatch(
@@ -292,14 +326,27 @@ function validateBusinessEventMatch(
292
326
  : field.type === 'boolean'
293
327
  ? 'BOOLEAN'
294
328
  : 'STRING';
329
+ let values: unknown[] = [right.value];
330
+ if (operator === 'IN') {
331
+ try {
332
+ values = JSON.parse(String(right.value));
333
+ } catch {
334
+ throw invalidBusinessEventMatch(event, '比较值列表无效');
335
+ }
336
+ }
295
337
  if (
296
338
  right.source !== 'FIXED' ||
297
339
  right.valueType !== expectedValueType ||
298
- typeof right.value !== 'string' ||
299
- (expectedValueType === 'NUMBER' &&
300
- (!right.value.trim() || !Number.isFinite(Number(right.value)))) ||
301
- (expectedValueType === 'BOOLEAN' &&
302
- !['true', 'false'].includes(right.value))
340
+ !Array.isArray(values) ||
341
+ values.length === 0 ||
342
+ values.some(
343
+ (value) =>
344
+ typeof value !== 'string' ||
345
+ (expectedValueType === 'NUMBER' &&
346
+ (!value.trim() || !Number.isFinite(Number(value)))) ||
347
+ (expectedValueType === 'BOOLEAN' &&
348
+ !['true', 'false'].includes(value)),
349
+ )
303
350
  ) {
304
351
  throw invalidBusinessEventMatch(event, `字段“${path}”的比较值类型不正确`);
305
352
  }
@@ -721,11 +768,7 @@ export class WorkflowService {
721
768
  const [events, columns, schemaName] = await Promise.all([
722
769
  this.prisma.workflowBusinessEventRegistration.findMany({
723
770
  where: { isEnabled: true },
724
- orderBy: [
725
- { schemaName: 'asc' },
726
- { tableName: 'asc' },
727
- { operation: 'asc' },
728
- ],
771
+ orderBy: [{ schemaName: 'asc' }, { tableName: 'asc' }, { code: 'asc' }],
729
772
  }),
730
773
  this.findBusinessEventColumns(),
731
774
  this.findBusinessEventSchema(),
@@ -810,7 +853,7 @@ export class WorkflowService {
810
853
  data: {
811
854
  schemaName: string;
812
855
  tableName: string;
813
- operation: 'INSERT' | 'UPDATE' | 'DELETE';
856
+ operations: Array<'INSERT' | 'UPDATE' | 'DELETE'>;
814
857
  watchedFields: string[];
815
858
  },
816
859
  creator: string,
@@ -831,17 +874,24 @@ export class WorkflowService {
831
874
  if (sourceColumns.length === 0)
832
875
  throw new BadRequestException('请选择可监听的数据库表');
833
876
 
877
+ if (
878
+ !Array.isArray(data.operations) ||
879
+ data.operations.length === 0 ||
880
+ data.operations.some(
881
+ (operation) => !['INSERT', 'UPDATE', 'DELETE'].includes(operation),
882
+ )
883
+ ) {
884
+ throw new BadRequestException('请至少选择一种有效的数据操作');
885
+ }
886
+ const operations = [...new Set(data.operations)].sort();
834
887
  const requestedFields = [
835
888
  ...new Set(
836
889
  data.watchedFields.map((field) => field.trim()).filter(Boolean),
837
890
  ),
838
891
  ].sort();
839
- if (data.operation === 'UPDATE' && requestedFields.length === 0)
892
+ if (operations.includes('UPDATE') && requestedFields.length === 0)
840
893
  throw new BadRequestException('请至少选择一个监听字段');
841
- const watchedFields =
842
- data.operation === 'UPDATE'
843
- ? requestedFields
844
- : sourceColumns.map((column) => column.columnName).sort();
894
+ const watchedFields = operations.includes('UPDATE') ? requestedFields : [];
845
895
  const availableFields = new Set(
846
896
  sourceColumns.map((column) => column.columnName),
847
897
  );
@@ -861,7 +911,7 @@ export class WorkflowService {
861
911
  JSON.stringify({
862
912
  schemaName: data.schemaName,
863
913
  tableName: data.tableName,
864
- operation: data.operation,
914
+ operations,
865
915
  watchedFields,
866
916
  }),
867
917
  )
@@ -885,7 +935,7 @@ export class WorkflowService {
885
935
  registrationKey,
886
936
  schemaName: data.schemaName,
887
937
  tableName: data.tableName,
888
- operation: data.operation,
938
+ operations,
889
939
  watchedFields,
890
940
  primaryKeyFields,
891
941
  creator,
@@ -895,7 +945,9 @@ export class WorkflowService {
895
945
  return registration;
896
946
  });
897
947
  this.logger.log(
898
- `Registered ${event.operation} listener ${event.schemaName}.${event.tableName} (${event.code})`,
948
+ `Registered ${event.operations.join('/')} listener ${event.schemaName}.${
949
+ event.tableName
950
+ } (${event.code})`,
899
951
  );
900
952
  return buildBusinessEventCatalog([event], columns)[0];
901
953
  }
@@ -906,7 +958,7 @@ export class WorkflowService {
906
958
  id: bigint;
907
959
  schemaName: string;
908
960
  tableName: string;
909
- operation: string;
961
+ operations: string[];
910
962
  watchedFields: Prisma.JsonValue;
911
963
  primaryKeyFields: Prisma.JsonValue;
912
964
  },
@@ -934,10 +986,13 @@ export class WorkflowService {
934
986
  const workflowSchema = quoteIdentifier(
935
987
  workflowSchemaRows[0]?.schemaName || 'public',
936
988
  );
937
- const triggerEvent =
938
- event.operation === 'UPDATE'
939
- ? `UPDATE OF ${watchedFields.map(quoteIdentifier).join(', ')}`
940
- : event.operation;
989
+ const triggerEvent = event.operations
990
+ .map((operation) =>
991
+ operation === 'UPDATE'
992
+ ? `UPDATE OF ${watchedFields.map(quoteIdentifier).join(', ')}`
993
+ : operation,
994
+ )
995
+ .join(' OR ');
941
996
 
942
997
  await tx.$executeRawUnsafe(`
943
998
  CREATE OR REPLACE FUNCTION ${schema}.${functionName}()
@@ -353,3 +353,60 @@ describe('NodeConfigValidatorService', () => {
353
353
  ).not.toThrow();
354
354
  });
355
355
  });
356
+
357
+ describe('event trigger operation rules schema', () => {
358
+ const schema = NODE_TYPES.find((node) => node.type === 'event_trigger')!
359
+ .configSchema as JsonSchema;
360
+ const service = new NodeConfigValidatorService();
361
+ const validate = (operationRules: unknown) =>
362
+ service.validateOrThrow('event:5', schema, {
363
+ registrationCode: 'request-changes',
364
+ processingMode: 'PARALLEL',
365
+ operationRules,
366
+ });
367
+ it('accepts multiple operations with list conditions', () => {
368
+ expect(() =>
369
+ validate([
370
+ { operation: 'INSERT' },
371
+ {
372
+ operation: 'UPDATE',
373
+ match: {
374
+ combineOperation: 'AND',
375
+ conditions: [
376
+ {
377
+ id: 'status',
378
+ left: { source: 'WORKFLOW', path: 'after.status' },
379
+ operator: 'IN',
380
+ right: {
381
+ source: 'FIXED',
382
+ valueType: 'STRING',
383
+ value: '["PENDING","PENDING_SUPERIOR"]',
384
+ },
385
+ },
386
+ ],
387
+ },
388
+ },
389
+ ]),
390
+ ).not.toThrow();
391
+ });
392
+ it('rejects empty rules and incomplete conditions', () => {
393
+ expect(() => validate([])).toThrow(BadRequestException);
394
+ expect(() =>
395
+ validate([
396
+ {
397
+ operation: 'UPDATE',
398
+ match: {
399
+ combineOperation: 'AND',
400
+ conditions: [
401
+ {
402
+ id: 'status',
403
+ left: { source: 'WORKFLOW', path: 'after.status' },
404
+ operator: 'IN',
405
+ },
406
+ ],
407
+ },
408
+ },
409
+ ]),
410
+ ).toThrow(BadRequestException);
411
+ });
412
+ });
@@ -371,7 +371,7 @@
371
371
  "registerFailed": "Registration failed. Try again.",
372
372
  "source": "Database table",
373
373
  "operation": "Operation",
374
- "watchedFields": "Watched fields",
374
+ "watchedFields": "Fields watched on update",
375
375
  "allFields": "All fields",
376
376
  "allFieldsHint": "INSERT and DELETE trigger whenever a row is inserted or deleted, and the event includes every field in that row.",
377
377
  "sourceLoadFailed": "Database metadata could not be loaded",
@@ -552,7 +552,8 @@
552
552
  "EQ": "Equals",
553
553
  "NEQ": "Does not equal",
554
554
  "EMPTY": "Is empty",
555
- "NOT_EMPTY": "Is not empty"
555
+ "NOT_EMPTY": "Is not empty",
556
+ "IN": "Is one of"
556
557
  },
557
558
  "processing": "Event processing",
558
559
  "parallel": "Process in parallel",
@@ -564,7 +565,14 @@
564
565
  "primaryKey": "Primary key",
565
566
  "sameKeyPreview": "Events with the same {{field}} are processed in arrival order",
566
567
  "advanced": "Advanced settings",
567
- "maxConcurrency": "Maximum concurrent objects"
568
+ "maxConcurrency": "Maximum concurrent objects",
569
+ "neverStart": "Do not start",
570
+ "valueListHint": "Enter a value and press Enter",
571
+ "operations": {
572
+ "INSERT": "On insert",
573
+ "UPDATE": "On update",
574
+ "DELETE": "On delete"
575
+ }
568
576
  },
569
577
  "database": {
570
578
  "sql": "SQL statement",
@@ -371,7 +371,7 @@
371
371
  "registerFailed": "登记失败,请重试",
372
372
  "source": "数据库表",
373
373
  "operation": "数据操作",
374
- "watchedFields": "监听字段",
374
+ "watchedFields": "修改时监听字段",
375
375
  "allFields": "全部字段",
376
376
  "allFieldsHint": "INSERT 和 DELETE 会在任意一行插入或删除时触发,事件包含该行的全部字段。",
377
377
  "sourceLoadFailed": "数据库表结构加载失败",
@@ -552,7 +552,8 @@
552
552
  "EQ": "等于",
553
553
  "NEQ": "不等于",
554
554
  "EMPTY": "为空",
555
- "NOT_EMPTY": "不为空"
555
+ "NOT_EMPTY": "不为空",
556
+ "IN": "属于以下任一值"
556
557
  },
557
558
  "processing": "事件处理方式",
558
559
  "parallel": "并行处理",
@@ -564,7 +565,14 @@
564
565
  "primaryKey": "主键",
565
566
  "sameKeyPreview": "{{field}} 相同的事件会按到达顺序处理",
566
567
  "advanced": "高级设置",
567
- "maxConcurrency": "最大同时处理数"
568
+ "maxConcurrency": "最大同时处理数",
569
+ "neverStart": "不启动",
570
+ "valueListHint": "输入值并按回车添加",
571
+ "operations": {
572
+ "INSERT": "新增时",
573
+ "UPDATE": "修改时",
574
+ "DELETE": "删除时"
575
+ }
568
576
  },
569
577
  "database": {
570
578
  "sql": "SQL 语句",
@@ -6,6 +6,7 @@ import {
6
6
  Button,
7
7
  Card,
8
8
  Col,
9
+ DatePicker,
9
10
  Input,
10
11
  Modal,
11
12
  Popconfirm,
@@ -19,6 +20,8 @@ import {
19
20
  Typography,
20
21
  message,
21
22
  } from 'antd';
23
+ import type { Dayjs } from 'dayjs';
24
+ import dayjs from 'dayjs';
22
25
  import {
23
26
  DatabaseOutlined,
24
27
  DeleteOutlined,
@@ -33,6 +36,7 @@ import { PageHeader } from 'components';
33
36
  import { PAGE_WRAPPER_STYLE, SEARCH_CARD_PROPS } from 'constants/layout';
34
37
 
35
38
  const { Text } = Typography;
39
+ const { RangePicker } = DatePicker;
36
40
 
37
41
  // ─── Types ────────────────────────────────────────────────────────────────────
38
42
 
@@ -180,6 +184,14 @@ export default function AgendaJobsPage() {
180
184
  const page = Number(searchParams.get('page')) || 1;
181
185
  const PAGE_SIZE = 20;
182
186
 
187
+ // 时间过滤(按 lastRunAt):默认最近 7 天,URL 无 from/to 时使用默认区间。
188
+ const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(() => {
189
+ const from = searchParams.get('from');
190
+ const to = searchParams.get('to');
191
+ if (from && to) return [dayjs(from), dayjs(to)];
192
+ return [dayjs().subtract(7, 'day').startOf('day'), dayjs().endOf('day')];
193
+ });
194
+
183
195
  const updateParams = useCallback(
184
196
  (updates: Record<string, string | undefined>) => {
185
197
  setSearchParams(
@@ -217,6 +229,17 @@ export default function AgendaJobsPage() {
217
229
  (p: number) => updateParams({ page: p > 1 ? String(p) : undefined }),
218
230
  [updateParams],
219
231
  );
232
+ const setDateRangeAndSync = useCallback(
233
+ (range: [Dayjs, Dayjs] | null) => {
234
+ setDateRange(range);
235
+ updateParams({
236
+ from: range?.[0]?.toISOString(),
237
+ to: range?.[1]?.toISOString(),
238
+ page: undefined,
239
+ });
240
+ },
241
+ [updateParams],
242
+ );
220
243
 
221
244
  // Auto-refresh
222
245
  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -232,6 +255,8 @@ export default function AgendaJobsPage() {
232
255
  if (filterToggle) params.toggle = filterToggle;
233
256
  if (filterName) params.q = filterName;
234
257
  if (filterModule) params.module = filterModule;
258
+ if (dateRange?.[0]) params.dateFrom = dateRange[0].toISOString();
259
+ if (dateRange?.[1]) params.dateTo = dateRange[1].toISOString();
235
260
 
236
261
  const [res, metrics] = await Promise.all([
237
262
  customRequest<JobsResponse>('agenda/jobs', 'GET', params),
@@ -247,7 +272,7 @@ export default function AgendaJobsPage() {
247
272
  } finally {
248
273
  setLoading(false);
249
274
  }
250
- }, [page, filterState, filterToggle, filterName, filterModule]);
275
+ }, [page, filterState, filterToggle, filterName, filterModule, dateRange]);
251
276
 
252
277
  useEffect(() => {
253
278
  fetchJobs();
@@ -715,6 +740,21 @@ export default function AgendaJobsPage() {
715
740
  style={{ width: 160 }}
716
741
  options={modules.map((m) => ({ value: m, label: m }))}
717
742
  />
743
+ <RangePicker
744
+ prefix={
745
+ <span
746
+ style={{ color: 'var(--ant-color-text)', whiteSpace: 'nowrap' }}
747
+ >
748
+ 最近执行
749
+ </span>
750
+ }
751
+ value={dateRange}
752
+ onChange={(range) =>
753
+ setDateRangeAndSync(range as [Dayjs, Dayjs] | null)
754
+ }
755
+ allowClear
756
+ style={{ width: 300 }}
757
+ />
718
758
  <Input
719
759
  placeholder="按名称过滤"
720
760
  allowClear
@@ -60,9 +60,9 @@ describe('RegisterBusinessEventModal', () => {
60
60
  const sourceOptions = await screen.findAllByText('public.t_hardware_asset');
61
61
  fireEvent.click(sourceOptions[sourceOptions.length - 1]);
62
62
  await waitFor(() =>
63
- expect(screen.getByLabelText('监听字段')).not.toBeDisabled(),
63
+ expect(screen.getByLabelText('修改时监听字段')).not.toBeDisabled(),
64
64
  );
65
- fireEvent.mouseDown(screen.getByLabelText('监听字段'));
65
+ fireEvent.mouseDown(screen.getByLabelText('修改时监听字段'));
66
66
  const fieldOptions = await screen.findAllByText(/status/);
67
67
  fireEvent.click(fieldOptions[fieldOptions.length - 1]);
68
68
  fireEvent.click(screen.getByRole('button', { name: '完成登记' }));
@@ -71,7 +71,7 @@ describe('RegisterBusinessEventModal', () => {
71
71
  expect(apiMocks.register).toHaveBeenCalledWith({
72
72
  schemaName: 'public',
73
73
  tableName: 't_hardware_asset',
74
- operation: 'UPDATE',
74
+ operations: ['UPDATE'],
75
75
  watchedFields: ['status'],
76
76
  }),
77
77
  );
@@ -95,7 +95,16 @@ describe('RegisterBusinessEventModal', () => {
95
95
  const insertOptions = await screen.findAllByText('INSERT');
96
96
  fireEvent.click(insertOptions[insertOptions.length - 1]);
97
97
 
98
- expect(screen.queryByLabelText('监听字段')).not.toBeInTheDocument();
98
+ // The operation picker is multi-select: remove UPDATE for an INSERT-only registration.
99
+ fireEvent.click(
100
+ screen
101
+ .getAllByTitle('UPDATE')
102
+ .find((element) =>
103
+ element.classList.contains('ant-select-selection-item'),
104
+ )!
105
+ .querySelector('.ant-select-selection-item-remove')!,
106
+ );
107
+ expect(screen.queryByLabelText('修改时监听字段')).not.toBeInTheDocument();
99
108
  expect(screen.getByText(/任意一行插入或删除时触发/)).toBeInTheDocument();
100
109
  fireEvent.click(screen.getByRole('button', { name: '完成登记' }));
101
110
 
@@ -103,9 +112,41 @@ describe('RegisterBusinessEventModal', () => {
103
112
  expect(apiMocks.register).toHaveBeenCalledWith({
104
113
  schemaName: 'public',
105
114
  tableName: 't_hardware_asset',
106
- operation: 'INSERT',
115
+ operations: ['INSERT'],
107
116
  watchedFields: [],
108
117
  }),
109
118
  );
110
119
  });
120
+
121
+ it('registers INSERT and UPDATE together and still requires update fields', async () => {
122
+ await i18n.changeLanguage('zh_CN');
123
+ render(
124
+ <RegisterBusinessEventModal
125
+ open
126
+ onCancel={vi.fn()}
127
+ onRegistered={vi.fn()}
128
+ />,
129
+ );
130
+ fireEvent.mouseDown(await screen.findByLabelText('数据库表'));
131
+ const sourceOptions = await screen.findAllByText('public.t_hardware_asset');
132
+ fireEvent.click(sourceOptions[sourceOptions.length - 1]);
133
+ fireEvent.mouseDown(screen.getByLabelText('数据操作'));
134
+ const insertOptions = await screen.findAllByText('INSERT');
135
+ fireEvent.click(insertOptions[insertOptions.length - 1]);
136
+ fireEvent.click(screen.getByRole('button', { name: '完成登记' }));
137
+ await screen.findByRole('button', { name: '完成登记' });
138
+ expect(apiMocks.register).not.toHaveBeenCalled();
139
+ fireEvent.mouseDown(screen.getByLabelText('修改时监听字段'));
140
+ const options = await screen.findAllByText(/status/);
141
+ fireEvent.click(options[options.length - 1]);
142
+ fireEvent.click(await screen.findByRole('button', { name: '完成登记' }));
143
+ await waitFor(() =>
144
+ expect(apiMocks.register).toHaveBeenCalledWith({
145
+ schemaName: 'public',
146
+ tableName: 't_hardware_asset',
147
+ operations: ['UPDATE', 'INSERT'],
148
+ watchedFields: ['status'],
149
+ }),
150
+ );
151
+ });
111
152
  });
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
4
4
  import {
5
5
  eventCatalogApi,
6
6
  type BusinessEventSource,
7
+ type BusinessEventOperation,
7
8
  } from '../node-instances/widgets/event-catalog.api';
8
9
  import { useWorkflowFormValidation } from '../useWorkflowFormValidation';
9
10
 
@@ -15,7 +16,7 @@ interface RegisterBusinessEventModalProps {
15
16
 
16
17
  interface RegisterBusinessEventFormValues {
17
18
  source: string;
18
- operation: 'INSERT' | 'UPDATE' | 'DELETE';
19
+ operations: BusinessEventOperation[];
19
20
  watchedFields: string[];
20
21
  }
21
22
 
@@ -31,7 +32,7 @@ export function RegisterBusinessEventModal({
31
32
  const [loadingSources, setLoadingSources] = useState(false);
32
33
  const [submitting, setSubmitting] = useState(false);
33
34
  const sourceKey = Form.useWatch('source', form);
34
- const operation = Form.useWatch('operation', form);
35
+ const operations = Form.useWatch('operations', form) ?? [];
35
36
  const selectedSource = useMemo(
36
37
  () =>
37
38
  sources.find(
@@ -65,9 +66,10 @@ export function RegisterBusinessEventModal({
65
66
  await eventCatalogApi.register({
66
67
  schemaName: source.schemaName,
67
68
  tableName: source.tableName,
68
- operation: values.operation,
69
- watchedFields:
70
- values.operation === 'UPDATE' ? values.watchedFields : [],
69
+ operations: values.operations,
70
+ watchedFields: values.operations.includes('UPDATE')
71
+ ? values.watchedFields
72
+ : [],
71
73
  });
72
74
  message.success(t('eventCatalog.registration.success'));
73
75
  form.resetFields();
@@ -103,7 +105,7 @@ export function RegisterBusinessEventModal({
103
105
  form={form}
104
106
  layout="vertical"
105
107
  requiredMark={false}
106
- initialValues={{ operation: 'UPDATE', watchedFields: [] }}
108
+ initialValues={{ operations: ['UPDATE'], watchedFields: [] }}
107
109
  {...formValidation}
108
110
  >
109
111
  <Form.Item
@@ -123,21 +125,23 @@ export function RegisterBusinessEventModal({
123
125
  />
124
126
  </Form.Item>
125
127
  <Form.Item
126
- name="operation"
128
+ name="operations"
127
129
  label={t('eventCatalog.registration.operation')}
128
- rules={[{ required: true }]}
130
+ rules={[{ required: true, type: 'array', min: 1 }]}
129
131
  >
130
132
  <Select
133
+ mode="multiple"
131
134
  options={['INSERT', 'UPDATE', 'DELETE'].map((operation) => ({
132
135
  value: operation,
133
136
  label: operation,
134
137
  }))}
135
138
  onChange={(value) => {
136
- if (value !== 'UPDATE') form.setFieldValue('watchedFields', []);
139
+ if (!value.includes('UPDATE'))
140
+ form.setFieldValue('watchedFields', []);
137
141
  }}
138
142
  />
139
143
  </Form.Item>
140
- {operation === 'UPDATE' ? (
144
+ {operations.includes('UPDATE') ? (
141
145
  <Form.Item
142
146
  name="watchedFields"
143
147
  label={t('eventCatalog.registration.watchedFields')}
@@ -33,7 +33,7 @@ describe('BusinessEventDetailPage', () => {
33
33
  label: 'public.t_hardware_asset · UPDATE',
34
34
  schemaName: 'public',
35
35
  tableName: 't_hardware_asset',
36
- operation: 'UPDATE',
36
+ operations: ['UPDATE'],
37
37
  watchedFields: ['status'],
38
38
  primaryKeyFields: ['id'],
39
39
  isEnabled: true,
@@ -257,7 +257,8 @@ export default function BusinessEventDetailPage() {
257
257
  label={t('eventCatalog.identifier')}
258
258
  value={
259
259
  <Typography.Text code>
260
- {event.schemaName}.{event.tableName} · {event.operation}
260
+ {event.schemaName}.{event.tableName} ·{' '}
261
+ {event.operations.join(' / ')}
261
262
  </Typography.Text>
262
263
  }
263
264
  />
@@ -38,7 +38,7 @@ describe('BusinessEventCatalogPage', () => {
38
38
  label: 'public.t_hardware_asset · UPDATE',
39
39
  schemaName: 'public',
40
40
  tableName: 't_hardware_asset',
41
- operation: 'UPDATE',
41
+ operations: ['UPDATE'],
42
42
  watchedFields: ['status'],
43
43
  primaryKeyFields: ['id'],
44
44
  isEnabled: true,