@bpmn-nova/studio 0.3.5-preview → 0.3.6-preview

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.
@@ -1,3 +1,10 @@
1
+ import { parseRuntimeInstant, formatRuntimeInstant, compareRuntimeOrder, runtimeInstant } from './time.js';
2
+ import { pushDiagnostic } from './diagnostics.js';
3
+ import { groupVisits, markEffectiveVisits, resolveEffectiveStatus, resolveVisitStatus } from './visits.js';
4
+
5
+ export { parseRuntimeInstant, formatRuntimeInstant, compareRuntimeOrder } from './time.js';
6
+ export { groupVisits, resolveVisitStatus, resolveEffectiveStatus } from './visits.js';
7
+
1
8
  const TERMINAL_PROCESS_STATUSES = new Set(['completed', 'terminated']);
2
9
  const ABNORMAL_TRANSITION_TYPES = new Set(['reject', 'return']);
3
10
  const APPROVAL_ACTION_LABELS = {
@@ -14,16 +21,21 @@ function normalizeParticipant(record = {}) {
14
21
  return null;
15
22
  }
16
23
 
24
+ function stampInstant(value) {
25
+ return runtimeInstant(value);
26
+ }
27
+
17
28
  function recordOrder(record, index) {
18
- const stamp = record.startTime || record.endTime;
19
- const parsed = stamp ? Date.parse(stamp.replace(' ', 'T')) : Number.NaN;
20
- return Number.isFinite(parsed) ? parsed : index;
29
+ return stampInstant(record.startTime || record.endTime);
21
30
  }
22
31
 
23
- function runtimeOrder(value, index) {
24
- const stamp = value?.occurredAt || value?.time || value?.endTime || value?.startTime;
25
- const parsed = stamp ? Date.parse(String(stamp).replace(' ', 'T')) : Number.NaN;
26
- return Number.isFinite(parsed) ? parsed : index;
32
+ function runtimeOrder(value) {
33
+ return stampInstant(value?.occurredAt || value?.time || value?.endTime || value?.startTime);
34
+ }
35
+
36
+ function isAfter(left, right) {
37
+ if (left == null || right == null) return false;
38
+ return left > right;
27
39
  }
28
40
 
29
41
  function normalizeRuntimeAsset(asset) {
@@ -107,12 +119,14 @@ function approvalActionLabel(action) {
107
119
  }
108
120
 
109
121
  function summarizeApprovalActions(actions = []) {
110
- const latestAction = [...actions].reverse().find((action) => action.plainText || action.assetCount) || actions.at(-1) || null;
122
+ const latestAction = actions.at(-1) || null;
123
+ const latestNonEmptyComment = [...actions].reverse().find((action) => action.plainText || action.assetCount) || null;
111
124
  const assetSummary = latestAction
112
125
  ? [latestAction.imageCount ? `${latestAction.imageCount} 张图片` : '', latestAction.fileCount ? `${latestAction.fileCount} 个附件` : ''].filter(Boolean).join(' · ')
113
126
  : '';
114
127
  return {
115
128
  latestAction,
129
+ latestNonEmptyComment,
116
130
  actionText: latestAction?.plainText || '',
117
131
  actionSummary: latestAction ? [latestAction.label, latestAction.plainText, assetSummary].filter(Boolean).join(' · ') : '',
118
132
  imageCount: actions.reduce((sum, action) => sum + action.imageCount, 0),
@@ -121,31 +135,6 @@ function summarizeApprovalActions(actions = []) {
121
135
  };
122
136
  }
123
137
 
124
- function groupVisits(records = []) {
125
- const groups = new Map();
126
- records.forEach((record, index) => {
127
- const key = record.visitId
128
- ? `visit:${record.visitId}`
129
- : record.multiInstanceId
130
- ? `multi:${record.multiInstanceId}`
131
- : `record:${record.id || index}`;
132
- if (!groups.has(key)) groups.set(key, { id: record.visitId || record.multiInstanceId || record.id || String(index), records: [], order: recordOrder(record, index) });
133
- const visit = groups.get(key);
134
- visit.records.push(record);
135
- visit.order = Math.max(visit.order, recordOrder(record, index));
136
- });
137
- return [...groups.values()].sort((a, b) => a.order - b.order).map((visit, index) => ({ ...visit, round: index + 1 }));
138
- }
139
-
140
- function resolveVisitStatus(records = []) {
141
- if (!records.length) return 'idle';
142
- if (records.some((item) => item.status === 'failed')) return 'failed';
143
- if (records.some((item) => item.status === 'active')) return 'active';
144
- if (records.every((item) => item.status === 'skipped')) return 'skipped';
145
- if (records.some((item) => item.status === 'cancelled') && !records.some((item) => item.status === 'completed')) return 'cancelled';
146
- return 'completed';
147
- }
148
-
149
138
  function uniqueParticipants(records = []) {
150
139
  const seen = new Set();
151
140
  const result = [];
@@ -180,16 +169,146 @@ function baseStatusLabel(status) {
180
169
  }[status] || status;
181
170
  }
182
171
 
183
- function transitionOrder(transition, index) {
184
- const stamp = transition.occurredAt || transition.time;
185
- const parsed = stamp ? Date.parse(stamp.replace(' ', 'T')) : Number.NaN;
186
- return Number.isFinite(parsed) ? parsed : index;
172
+ function transitionOrder(transition) {
173
+ return stampInstant(transition.occurredAt || transition.time);
174
+ }
175
+
176
+ function edgeVisitOrder(visit) {
177
+ return stampInstant(visit.occurredAt || visit.time);
178
+ }
179
+
180
+ function collectIdentityDiagnostics(runtime, { model = null, diagnostics }) {
181
+ const activityIds = new Set();
182
+ const actionIds = new Set();
183
+ const transitionIds = new Set();
184
+ const edgeVisitIds = new Set();
185
+ const visitOwners = new Map();
186
+ const nodeIds = new Set((model?.nodes || []).map((node) => node.id));
187
+ const edgeIds = new Set((model?.edges || []).map((edge) => edge.id));
188
+
189
+ runtime.activities.forEach((record, index) => {
190
+ const fieldPath = `activities[${index}]`;
191
+ if (record.id) {
192
+ if (activityIds.has(record.id)) pushDiagnostic(diagnostics, { code: 'duplicate-activity-id', level: 'error', message: '工作项 ID 重复。', fieldPath, activityId: record.id, elementId: record.elementId });
193
+ activityIds.add(record.id);
194
+ }
195
+ if (record.visitId) {
196
+ const owner = visitOwners.get(record.visitId);
197
+ if (owner && owner !== record.elementId) {
198
+ pushDiagnostic(diagnostics, { code: 'visit-element-mismatch', level: 'error', message: '同一 visitId 关联了不同 BPMN 元素。', fieldPath, visitId: record.visitId, elementId: record.elementId });
199
+ }
200
+ visitOwners.set(record.visitId, record.elementId);
201
+ }
202
+ ['startTime', 'endTime'].forEach((field) => {
203
+ const parsed = parseRuntimeInstant(record[field]);
204
+ if (parsed.kind === 'invalid') pushDiagnostic(diagnostics, { code: 'invalid-time', level: 'warning', message: '时间无法解析。', fieldPath: `${fieldPath}.${field}`, activityId: record.id, elementId: record.elementId });
205
+ else if (parsed.kind === 'naive') pushDiagnostic(diagnostics, { code: 'naive-time', level: 'info', message: '时间缺少时区,比较结果可能因环境而异。', fieldPath: `${fieldPath}.${field}`, activityId: record.id, elementId: record.elementId });
206
+ });
207
+ if (model && record.elementId && !nodeIds.has(record.elementId)) {
208
+ pushDiagnostic(diagnostics, { code: 'unknown-element', level: 'error', message: '工作项引用了不存在的 BPMN 元素。', fieldPath: `${fieldPath}.elementId`, activityId: record.id, elementId: record.elementId });
209
+ }
210
+ });
211
+
212
+ runtime.actions.forEach((action, index) => {
213
+ const fieldPath = `actions[${index}]`;
214
+ if (actionIds.has(action.id)) pushDiagnostic(diagnostics, { code: 'duplicate-action-id', level: 'error', message: '操作 ID 重复。', fieldPath, actionId: action.id, elementId: action.elementId });
215
+ actionIds.add(action.id);
216
+ if (action.activityId && !activityIds.has(action.activityId) && !String(action.id).startsWith('legacy-')) {
217
+ pushDiagnostic(diagnostics, { code: 'unknown-activity', level: 'error', message: '操作引用了不存在的工作项。', fieldPath: `${fieldPath}.activityId`, actionId: action.id, activityId: action.activityId, elementId: action.elementId });
218
+ }
219
+ const record = runtime.activities.find((item) => item.id && item.id === action.activityId);
220
+ if (record && ((action.elementId && record.elementId !== action.elementId) || (action.visitId && record.visitId && record.visitId !== action.visitId))) {
221
+ pushDiagnostic(diagnostics, { code: 'action-identity-mismatch', level: 'error', message: '操作与工作项的元素或 Visit 不一致。', fieldPath, actionId: action.id, activityId: action.activityId, elementId: action.elementId, visitId: action.visitId });
222
+ }
223
+ const parsed = parseRuntimeInstant(action.occurredAt);
224
+ if (parsed.kind === 'invalid') pushDiagnostic(diagnostics, { code: 'invalid-time', level: 'warning', message: '操作时间无法解析。', fieldPath: `${fieldPath}.occurredAt`, actionId: action.id });
225
+ else if (parsed.kind === 'naive') pushDiagnostic(diagnostics, { code: 'naive-time', level: 'info', message: '操作时间缺少时区。', fieldPath: `${fieldPath}.occurredAt`, actionId: action.id });
226
+ else if (parsed.kind === 'missing' && !String(action.id).startsWith('legacy-')) {
227
+ pushDiagnostic(diagnostics, { code: 'missing-time', level: 'warning', message: '操作缺少时间,排序仅用于展示。', fieldPath: `${fieldPath}.occurredAt`, actionId: action.id });
228
+ }
229
+ });
230
+
231
+ runtime.transitions.forEach((transition, index) => {
232
+ const fieldPath = `transitions[${index}]`;
233
+ const id = transition.id || `runtime-transition-${index + 1}`;
234
+ if (transitionIds.has(id)) pushDiagnostic(diagnostics, { code: 'duplicate-transition-id', level: 'error', message: '跳转 ID 重复。', fieldPath, transitionId: id });
235
+ transitionIds.add(id);
236
+ if (transition.sourceActivityId && !activityIds.has(transition.sourceActivityId)) {
237
+ pushDiagnostic(diagnostics, { code: 'unknown-activity', level: 'error', message: '跳转引用了不存在的来源工作项。', fieldPath: `${fieldPath}.sourceActivityId`, transitionId: id, activityId: transition.sourceActivityId, elementId: transition.sourceElementId });
238
+ }
239
+ if (transition.actionId && !actionIds.has(transition.actionId) && !String(transition.actionId).startsWith('legacy-')) {
240
+ pushDiagnostic(diagnostics, { code: 'unknown-action', level: 'error', message: '跳转引用了不存在的操作。', fieldPath: `${fieldPath}.actionId`, transitionId: id, actionId: transition.actionId });
241
+ }
242
+ if (model && transition.sourceElementId && !nodeIds.has(transition.sourceElementId)) {
243
+ pushDiagnostic(diagnostics, { code: 'unknown-element', level: 'error', message: '跳转来源元素不存在。', fieldPath: `${fieldPath}.sourceElementId`, transitionId: id, elementId: transition.sourceElementId });
244
+ }
245
+ if (model && transition.targetElementId && !nodeIds.has(transition.targetElementId)) {
246
+ pushDiagnostic(diagnostics, { code: 'unknown-element', level: 'error', message: '跳转目标元素不存在。', fieldPath: `${fieldPath}.targetElementId`, transitionId: id, elementId: transition.targetElementId });
247
+ }
248
+ const parsed = parseRuntimeInstant(transition.occurredAt || transition.time);
249
+ if ((transition.occurredAt || transition.time) && parsed.kind === 'invalid') {
250
+ pushDiagnostic(diagnostics, { code: 'invalid-time', level: 'warning', message: '跳转时间无法解析。', fieldPath: `${fieldPath}.occurredAt`, transitionId: id });
251
+ } else if (parsed.kind === 'naive') {
252
+ pushDiagnostic(diagnostics, { code: 'naive-time', level: 'info', message: '跳转时间缺少时区。', fieldPath: `${fieldPath}.occurredAt`, transitionId: id });
253
+ }
254
+ });
255
+
256
+ runtime.edgeVisits.forEach((visit, index) => {
257
+ if (visit.id) {
258
+ if (edgeVisitIds.has(visit.id)) pushDiagnostic(diagnostics, { code: 'duplicate-edge-visit-id', level: 'error', message: '连线经过 ID 重复。', fieldPath: `edgeVisits[${index}]`, edgeId: visit.edgeId });
259
+ edgeVisitIds.add(visit.id);
260
+ }
261
+ if (model && visit.edgeId && !edgeIds.has(visit.edgeId)) {
262
+ pushDiagnostic(diagnostics, { code: 'unknown-edge', level: 'error', message: '连线经过引用了不存在的 BPMN 连线。', fieldPath: `edgeVisits[${index}].edgeId`, edgeId: visit.edgeId });
263
+ }
264
+ });
187
265
  }
188
266
 
189
- function edgeVisitOrder(visit, index) {
190
- const stamp = visit.occurredAt || visit.time;
191
- const parsed = stamp ? Date.parse(stamp.replace(' ', 'T')) : Number.NaN;
192
- return Number.isFinite(parsed) ? parsed : index;
267
+ function resolveInvalidatedElements(transition, { activities, model, diagnostics }) {
268
+ const legacy = Array.isArray(transition.invalidatedActivityIds) ? transition.invalidatedActivityIds.map(String) : null;
269
+ const next = Array.isArray(transition.invalidatedElementIds) ? transition.invalidatedElementIds.map(String) : null;
270
+ const activityIds = new Set(activities.map((record) => record.id).filter(Boolean));
271
+ const nodeIds = new Set((model?.nodes || []).map((node) => node.id));
272
+ const warnIds = (ids, field) => {
273
+ ids.forEach((id) => {
274
+ if (activityIds.has(id) && !nodeIds.has(id)) {
275
+ pushDiagnostic(diagnostics, {
276
+ code: 'invalidation-work-item-id',
277
+ level: 'warning',
278
+ message: '失效字段收到工作项 ID,该字段只接受 BPMN 元素 ID。',
279
+ fieldPath: `transitions.${transition.id}.${field}`,
280
+ transitionId: transition.id,
281
+ activityId: id,
282
+ });
283
+ } else if (model && !nodeIds.has(id)) {
284
+ pushDiagnostic(diagnostics, {
285
+ code: 'unknown-element',
286
+ level: 'error',
287
+ message: '失效范围引用了不存在的 BPMN 元素。',
288
+ fieldPath: `transitions.${transition.id}.${field}`,
289
+ transitionId: transition.id,
290
+ elementId: id,
291
+ });
292
+ }
293
+ });
294
+ };
295
+ if (legacy && next) {
296
+ const same = legacy.length === next.length && [...legacy].sort().every((id, index) => id === [...next].sort()[index]);
297
+ if (!same) {
298
+ pushDiagnostic(diagnostics, {
299
+ code: 'invalidation-field-conflict',
300
+ level: 'warning',
301
+ message: 'invalidatedElementIds 与 invalidatedActivityIds 不一致,已按元素字段生效。',
302
+ fieldPath: `transitions.${transition.id}.invalidatedElementIds`,
303
+ transitionId: transition.id,
304
+ });
305
+ }
306
+ warnIds(next, 'invalidatedElementIds');
307
+ return [...next];
308
+ }
309
+ const resolved = [...(next || legacy || [])];
310
+ warnIds(resolved, next ? 'invalidatedElementIds' : 'invalidatedActivityIds');
311
+ return resolved;
193
312
  }
194
313
 
195
314
  function inferInvalidatedPath(model, runtime, transition) {
@@ -227,8 +346,9 @@ function inferInvalidatedPath(model, runtime, transition) {
227
346
  return { activityIds: paths[0].nodeIds.slice(1), edgeIds: paths[0].edgeIds, issues: [] };
228
347
  }
229
348
 
230
- export function normalizeRuntime(snapshot = {}) {
349
+ export function normalizeRuntime(snapshot = {}, options = {}) {
231
350
  snapshot = snapshot || {};
351
+ const diagnostics = options.diagnostics || null;
232
352
  const activities = Array.isArray(snapshot.activities)
233
353
  ? snapshot.activities.map((record) => ({ ...record, participant: normalizeParticipant(record) || undefined }))
234
354
  : [];
@@ -274,10 +394,13 @@ export function normalizeRuntime(snapshot = {}) {
274
394
  ? snapshot.edgeVisits.map((visit, index) => ({ id: visit.id || `runtime-edge-visit-${index + 1}`, status: visit.status || 'effective', ...visit }))
275
395
  : [];
276
396
  const orderedActions = actions
277
- .map((action, index) => ({ action, order: runtimeOrder(action, index), index }))
278
- .sort((a, b) => (a.order - b.order) || (a.index - b.index))
397
+ .map((action, index) => ({ action, index }))
398
+ .sort((left, right) => compareRuntimeOrder(
399
+ { instant: runtimeOrder(left.action), index: left.index },
400
+ { instant: runtimeOrder(right.action), index: right.index },
401
+ ))
279
402
  .map(({ action }) => action);
280
- return {
403
+ const normalized = {
281
404
  ...snapshot,
282
405
  processInstanceId: snapshot.processInstanceId || '',
283
406
  status: snapshot.status || 'running',
@@ -287,20 +410,45 @@ export function normalizeRuntime(snapshot = {}) {
287
410
  transitions,
288
411
  edgeVisits,
289
412
  };
413
+ if (diagnostics) collectIdentityDiagnostics(normalized, { model: options.model, diagnostics });
414
+ return normalized;
290
415
  }
291
416
 
292
- export function activityState(runtime, elementId) {
293
- if (!runtime) return { status: 'idle', records: [], latestRecords: [], visits: [] };
417
+ export function activityState(runtime, elementId, options = {}) {
418
+ if (!runtime) return { status: 'idle', records: [], latestRecords: [], visits: [], effectiveVisits: [] };
294
419
  const records = runtime.activities.filter((item) => item.elementId === elementId);
295
- const visits = groupVisits(records);
296
- const latestRecords = visits.at(-1)?.records || [];
297
- return { status: resolveVisitStatus(latestRecords), records, latestRecords, visits };
420
+ const visits = markEffectiveVisits(groupVisits(records), {
421
+ elementId,
422
+ transitions: runtime.transitions || [],
423
+ diagnostics: options.diagnostics || null,
424
+ });
425
+ const effectiveVisits = visits.filter((visit) => visit.effective);
426
+ const latestRecords = (effectiveVisits.at(-1) || visits.at(-1))?.records || [];
427
+ return {
428
+ status: resolveEffectiveStatus(effectiveVisits.length ? effectiveVisits : visits),
429
+ records,
430
+ latestRecords,
431
+ visits,
432
+ effectiveVisits,
433
+ };
298
434
  }
299
435
 
300
- export function createRuntimePresentation({ model, runtime, appearance = null } = {}) {
301
- const normalized = normalizeRuntime(runtime);
436
+ export function inspectRuntime(snapshot, options = {}) {
437
+ const diagnostics = [];
438
+ const presentation = createRuntimePresentation({
439
+ model: options.model || { id: '', nodes: [], edges: [] },
440
+ runtime: snapshot,
441
+ appearance: options.appearance || null,
442
+ diagnostics,
443
+ });
444
+ return { runtime: presentation.runtime, diagnostics: presentation.diagnostics };
445
+ }
446
+
447
+ export function createRuntimePresentation({ model, runtime, appearance = null, diagnostics = null } = {}) {
448
+ const collected = diagnostics || [];
449
+ const normalized = normalizeRuntime(runtime, { diagnostics: collected, model });
302
450
  const nodes = new Map((model?.nodes || []).map((node) => [node.id, node]));
303
- const activityStates = new Map((model?.nodes || []).map((node) => [node.id, activityState(normalized, node.id)]));
451
+ const activityStates = new Map((model?.nodes || []).map((node) => [node.id, activityState(normalized, node.id, { diagnostics: collected })]));
304
452
  const nodePresentations = new Map();
305
453
  const actionPresentations = normalized.actions.map((action, index) => {
306
454
  const assets = action.content.assets || [];
@@ -319,7 +467,7 @@ export function createRuntimePresentation({ model, runtime, appearance = null }
319
467
  imageCount: images.length,
320
468
  fileCount: files.length,
321
469
  assetCount: images.length + files.length,
322
- order: runtimeOrder(action, index),
470
+ order: runtimeOrder(action) ?? index,
323
471
  };
324
472
  const resolvedAppearance = appearance?.resolveAction?.(baseAction);
325
473
  return resolvedAppearance
@@ -328,20 +476,27 @@ export function createRuntimePresentation({ model, runtime, appearance = null }
328
476
  });
329
477
  const actionMap = new Map(actionPresentations.map((action) => [action.id, action]));
330
478
  const rawTransitions = normalized.transitions
331
- .map((transition, index) => ({ ...transition, order: transitionOrder(transition, index) }))
332
- .sort((a, b) => a.order - b.order);
479
+ .map((transition, index) => ({ ...transition, order: transitionOrder(transition) ?? index }))
480
+ .sort((left, right) => compareRuntimeOrder(
481
+ { instant: transitionOrder(left), index: left.order },
482
+ { instant: transitionOrder(right), index: right.order },
483
+ ));
333
484
  const transitionPresentations = rawTransitions.map((transition) => {
334
485
  const targetName = nodes.get(transition.targetElementId)?.name || transition.targetElementId || '';
335
486
  const labelPrefix = transition.type === 'reject' ? '驳回至' : transition.type === 'return' ? '退回至' : transition.type === 'skip' ? '跳过' : '流转至';
336
487
  const targetState = activityStates.get(transition.targetElementId);
337
- const targetRecordsAfter = (targetState?.records || []).filter((record, index) => recordOrder(record, index) > transition.order);
488
+ const targetRecordsAfter = (targetState?.records || []).filter((record) => isAfter(recordOrder(record), transition.order));
338
489
  const targetStatusAfter = resolveVisitStatus(groupVisits(targetRecordsAfter).at(-1)?.records || []);
339
490
  const state = transition.state || (ABNORMAL_TRANSITION_TYPES.has(transition.type)
340
491
  ? (targetRecordsAfter.length && targetStatusAfter !== 'active' ? 'resolved' : 'active')
341
492
  : 'resolved');
342
- const hasExplicitInvalidation = Array.isArray(transition.invalidatedActivityIds) || Array.isArray(transition.invalidatedEdgeIds);
493
+ const hasExplicitInvalidation = Array.isArray(transition.invalidatedElementIds) || Array.isArray(transition.invalidatedActivityIds) || Array.isArray(transition.invalidatedEdgeIds);
343
494
  const invalidation = hasExplicitInvalidation
344
- ? { activityIds: [...(transition.invalidatedActivityIds || [])], edgeIds: [...(transition.invalidatedEdgeIds || [])], issues: [] }
495
+ ? {
496
+ activityIds: resolveInvalidatedElements(transition, { activities: normalized.activities, model, diagnostics: collected }),
497
+ edgeIds: [...(transition.invalidatedEdgeIds || [])],
498
+ issues: [],
499
+ }
345
500
  : ABNORMAL_TRANSITION_TYPES.has(transition.type)
346
501
  ? inferInvalidatedPath(model, normalized, transition)
347
502
  : { activityIds: [], edgeIds: [], issues: [] };
@@ -354,6 +509,7 @@ export function createRuntimePresentation({ model, runtime, appearance = null }
354
509
  state,
355
510
  visible: ABNORMAL_TRANSITION_TYPES.has(transition.type) && state === 'active',
356
511
  resolvedAt: transition.resolvedAt || (state === 'resolved' ? targetRecordsAfter.map((record) => record.endTime).filter(Boolean).at(-1) : undefined),
512
+ invalidatedElementIds: invalidation.activityIds,
357
513
  invalidatedActivityIds: invalidation.activityIds,
358
514
  invalidatedEdgeIds: invalidation.edgeIds,
359
515
  issues: invalidation.issues,
@@ -365,15 +521,38 @@ export function createRuntimePresentation({ model, runtime, appearance = null }
365
521
  const invalidatedNodes = new Map();
366
522
  const invalidatedEdges = new Map();
367
523
  const hasActivityAfter = (elementId, transition) => (activityStates.get(elementId)?.records || [])
368
- .some((record, index) => recordOrder(record, index) > transition.order);
524
+ .some((record) => isAfter(recordOrder(record), transition.order));
369
525
  const hasEdgeVisitAfter = (edgeId, transition) => {
370
526
  const edgeVisits = normalized.edgeVisits.filter((visit) => visit.edgeId === edgeId);
371
- if (edgeVisits.length) return edgeVisits.some((visit, index) => visit.status !== 'superseded' && edgeVisitOrder(visit, index) > transition.order);
527
+ if (edgeVisits.length) return edgeVisits.some((visit) => visit.status !== 'superseded' && isAfter(edgeVisitOrder(visit), transition.order));
372
528
  const edge = model?.edges?.find((item) => item.id === edgeId);
373
529
  return edge ? hasActivityAfter(edge.target, transition) : false;
374
530
  };
375
531
  for (const transition of transitionPresentations.filter((item) => ABNORMAL_TRANSITION_TYPES.has(item.type))) {
376
- for (const elementId of transition.invalidatedActivityIds) if (!hasActivityAfter(elementId, transition)) invalidatedNodes.set(elementId, transition);
532
+ for (const elementId of transition.invalidatedActivityIds) {
533
+ const state = activityStates.get(elementId);
534
+ const concurrent = (state?.effectiveVisits || []).length > 1;
535
+ if (concurrent) {
536
+ pushDiagnostic(collected, {
537
+ code: 'element-invalidation-with-concurrent-visits',
538
+ level: 'warning',
539
+ message: '元素级失效遇到多个有效 Visit,未取消无关的并发执行。',
540
+ fieldPath: `transitions.${transition.id}.invalidatedElementIds`,
541
+ transitionId: transition.id,
542
+ elementId,
543
+ });
544
+ const endedBefore = (state?.visits || []).filter((visit) => {
545
+ const ended = visit.records.map((record) => recordOrder(record)).filter((value) => value != null);
546
+ const last = ended.length ? Math.max(...ended) : null;
547
+ return last != null && last <= transition.order;
548
+ });
549
+ if (endedBefore.length && endedBefore.length < (state?.visits || []).length && !hasActivityAfter(elementId, transition)) {
550
+ invalidatedNodes.set(elementId, transition);
551
+ }
552
+ continue;
553
+ }
554
+ if (!hasActivityAfter(elementId, transition)) invalidatedNodes.set(elementId, transition);
555
+ }
377
556
  for (const edgeId of transition.invalidatedEdgeIds) if (!hasEdgeVisitAfter(edgeId, transition)) invalidatedEdges.set(edgeId, transition);
378
557
  }
379
558
 
@@ -384,32 +563,40 @@ export function createRuntimePresentation({ model, runtime, appearance = null }
384
563
  const visitActions = actions.filter((action) => action.visitId === visit.id);
385
564
  return { ...visit, actions: visitActions, ...summarizeApprovalActions(visitActions) };
386
565
  });
387
- const latestVisit = visits.at(-1) || null;
566
+ const effectiveVisits = visits.filter((visit) => visit.effective);
567
+ const latestVisit = effectiveVisits.at(-1) || visits.at(-1) || null;
388
568
  const latestRecords = latestVisit?.records || [];
389
- const participants = uniqueParticipants(latestRecords);
569
+ const currentRecords = effectiveVisits.flatMap((visit) => visit.records);
570
+ const currentActions = effectiveVisits.flatMap((visit) => visit.actions);
571
+ const participants = uniqueParticipants(currentRecords.length ? currentRecords : latestRecords);
390
572
  const approvalMode = latestRecords.find((item) => item.approvalMode)?.approvalMode || null;
391
573
  const multiInstanceMode = latestRecords.find((item) => item.multiInstanceMode)?.multiInstanceMode || null;
392
574
  const declaredTotal = latestRecords.reduce((max, item) => Math.max(max, Number(item.totalInstances) || 0), 0);
393
- const total = Math.max(declaredTotal, latestRecords.length, participants.length);
575
+ const total = Math.max(declaredTotal, latestRecords.length, uniqueParticipants(latestRecords).length);
394
576
  const completed = latestRecords.filter((item) => item.status === 'completed').length;
395
577
  const required = latestRecords.reduce((max, item) => Math.max(max, Number(item.requiredInstances) || 0), 0) || (approvalMode === 'any' ? 1 : total);
396
578
  const inboundReentry = transitionPresentations.some((item) => ABNORMAL_TRANSITION_TYPES.has(item.type) && item.targetElementId === node.id);
397
579
  const latestSourceRejection = [...transitionPresentations].reverse().find((item) => item.type === 'reject' && item.sourceElementId === node.id);
398
580
  const rejectionSource = latestRecords.some((item) => item.outcome === 'rejected')
399
- || (latestSourceRejection && (!latestVisit || latestSourceRejection.order >= latestVisit.order));
581
+ || (latestSourceRejection && (!latestVisit || (latestSourceRejection.order ?? 0) >= (recordOrder(latestRecords.at(-1) || {}) ?? 0)));
400
582
  const skipped = !state.records.length && transitionPresentations.some((item) => item.type === 'skip' && item.targetElementId === node.id);
401
583
  let status = skipped ? 'skipped' : state.status;
402
- if (rejectionSource && status === 'completed') status = 'rejected';
584
+ if (rejectionSource && status === 'completed' && effectiveVisits.length <= 1) status = 'rejected';
403
585
  const isReentry = inboundReentry && Boolean(latestVisit) && (latestVisit.round > 1 || status === 'active');
404
586
  const superseded = invalidatedNodes.has(node.id);
405
587
  const pathStatus = superseded || status === 'rejected' ? 'idle' : status;
406
588
 
407
589
  let statusLabel = baseStatusLabel(status);
408
- if (isReentry && status === 'active') statusLabel = '重新审批';
590
+ const activeVisitCount = effectiveVisits.filter((visit) => resolveVisitStatus(visit.records) === 'active').length;
591
+ if (effectiveVisits.length > 1 && activeVisitCount) {
592
+ statusLabel = activeVisitCount === effectiveVisits.length
593
+ ? `处理中 ${activeVisitCount} 项执行`
594
+ : `处理中 ${activeVisitCount}/${effectiveVisits.length} 项执行`;
595
+ } else if (isReentry && status === 'active') statusLabel = '重新审批';
409
596
  else if (status === 'active' || status === 'completed') {
410
597
  if (approvalMode === 'all') statusLabel = `${multiInstanceMode === 'sequential' ? '顺序会签' : '会签'} ${completed}/${total || required}`;
411
598
  else if (approvalMode === 'any') statusLabel = `或签 ${completed}/${total || required}`;
412
- else if (participants.length > 1) statusLabel = `多人审批 ${completed}/${total}`;
599
+ else if (participants.length > 1 && effectiveVisits.length <= 1) statusLabel = `多人审批 ${completed}/${total}`;
413
600
  }
414
601
 
415
602
  let summary = participantSummary(participants);
@@ -424,30 +611,35 @@ export function createRuntimePresentation({ model, runtime, appearance = null }
424
611
  }
425
612
 
426
613
  const relatedTransitions = transitionPresentations.filter((item) => item.sourceElementId === node.id || item.targetElementId === node.id);
427
- const actionSummary = summarizeApprovalActions(actions);
614
+ const currentSummary = summarizeApprovalActions(currentActions);
615
+ const historySummary = summarizeApprovalActions(actions);
428
616
  nodePresentations.set(node.id, {
429
617
  elementId: node.id, status, pathStatus, superseded, statusLabel, summary, fullSummary: participants.map((item) => item.name).join('、') || summary,
430
618
  participants, approvalMode, multiInstanceMode, completed, total, required, round: latestVisit?.round || 0,
431
- records: state.records, latestRecords, visits, transitions: relatedTransitions, actions, ...actionSummary,
619
+ records: state.records, latestRecords, visits, effectiveVisits, transitions: relatedTransitions, actions,
620
+ ...currentSummary,
621
+ latestNonEmptyComment: historySummary.latestNonEmptyComment,
432
622
  isReentry,
433
623
  hasDetails: Boolean(state.records.length || actions.length || relatedTransitions.length),
434
624
  });
435
625
  }
436
626
 
627
+ const frozenDiagnostics = Object.freeze(collected.slice());
628
+ const emptyNode = (elementId) => ({
629
+ elementId, status: 'idle', pathStatus: 'idle', superseded: false, statusLabel: '未到达', summary: '', fullSummary: '', participants: [], approvalMode: null,
630
+ multiInstanceMode: null, completed: 0, total: 0, required: 0, round: 0, records: [], latestRecords: [], visits: [], effectiveVisits: [], transitions: [], actions: [], latestAction: null,
631
+ latestNonEmptyComment: null, actionText: '', actionSummary: '', imageCount: 0, fileCount: 0, assetCount: 0, isReentry: false, hasDetails: false,
632
+ });
633
+
437
634
  return {
438
635
  runtime: normalized,
439
- getNode(elementId) {
440
- return nodePresentations.get(elementId) || {
441
- elementId, status: 'idle', pathStatus: 'idle', superseded: false, statusLabel: '未到达', summary: '', fullSummary: '', participants: [], approvalMode: null,
442
- multiInstanceMode: null, completed: 0, total: 0, required: 0, round: 0, records: [], latestRecords: [], visits: [], transitions: [], actions: [], latestAction: null,
443
- actionText: '', actionSummary: '', imageCount: 0, fileCount: 0, assetCount: 0, isReentry: false, hasDetails: false,
444
- };
445
- },
636
+ diagnostics: frozenDiagnostics,
637
+ getNode(elementId) { return nodePresentations.get(elementId) || emptyNode(elementId); },
446
638
  getEdge(edgeId) {
447
639
  const edgeVisits = normalized.edgeVisits.filter((visit) => visit.edgeId === edgeId);
448
640
  const latestVisit = edgeVisits
449
- .map((visit, index) => ({ visit, order: edgeVisitOrder(visit, index) }))
450
- .sort((a, b) => a.order - b.order)
641
+ .map((visit, index) => ({ visit, index, instant: edgeVisitOrder(visit) }))
642
+ .sort((left, right) => compareRuntimeOrder(left, right))
451
643
  .at(-1)?.visit;
452
644
  const visited = normalized.visitedEdges.includes(edgeId) || edgeVisits.length > 0;
453
645
  const superseded = invalidatedEdges.has(edgeId) || latestVisit?.status === 'superseded';
@@ -0,0 +1,41 @@
1
+ const OFFSET_PATTERN = /(?:Z|[+-]\d{2}:?\d{2})$/i;
2
+
3
+ export function parseRuntimeInstant(value) {
4
+ if (value == null || value === '') return { instant: null, raw: '', kind: 'missing' };
5
+ const raw = String(value);
6
+ const trimmed = raw.trim();
7
+ if (!trimmed) return { instant: null, raw, kind: 'missing' };
8
+ const hasOffset = OFFSET_PATTERN.test(trimmed);
9
+ const normalized = !trimmed.includes('T') && trimmed.includes(' ') ? trimmed.replace(' ', 'T') : trimmed;
10
+ const instant = Date.parse(normalized);
11
+ if (!Number.isFinite(instant)) return { instant: null, raw, kind: 'invalid' };
12
+ if (hasOffset) return { instant, raw, kind: /Z$/i.test(trimmed) ? 'z' : 'offset' };
13
+ return { instant, raw, kind: 'naive' };
14
+ }
15
+
16
+ export function formatRuntimeInstant(value) {
17
+ const parsed = value && typeof value === 'object' && 'raw' in value ? value : parseRuntimeInstant(value);
18
+ if (!parsed?.raw) return '';
19
+ return String(parsed.raw).trim().replace('T', ' ');
20
+ }
21
+
22
+ export function compareRuntimeOrder(left, right) {
23
+ const a = normalizeOrderKey(left);
24
+ const b = normalizeOrderKey(right);
25
+ if (a.instant != null && b.instant != null && a.instant !== b.instant) return a.instant - b.instant;
26
+ if (a.instant != null && b.instant == null) return -1;
27
+ if (a.instant == null && b.instant != null) return 1;
28
+ return (a.index || 0) - (b.index || 0);
29
+ }
30
+
31
+ export function runtimeInstant(value) {
32
+ return parseRuntimeInstant(value).instant;
33
+ }
34
+
35
+ function normalizeOrderKey(value) {
36
+ if (value && typeof value === 'object' && ('instant' in value || 'index' in value || 'kind' in value)) {
37
+ return { instant: value.instant ?? null, index: Number.isFinite(value.index) ? value.index : 0, kind: value.kind };
38
+ }
39
+ const parsed = parseRuntimeInstant(value);
40
+ return { instant: parsed.instant, index: 0, kind: parsed.kind };
41
+ }