@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.
@@ -0,0 +1,167 @@
1
+ import { parseRuntimeInstant, compareRuntimeOrder, runtimeInstant } from './time.js';
2
+ import { pushDiagnostic } from './diagnostics.js';
3
+
4
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled', 'skipped']);
5
+
6
+ export function resolveVisitStatus(records = []) {
7
+ if (!records.length) return 'idle';
8
+ if (records.some((item) => item.status === 'failed')) return 'failed';
9
+ if (records.some((item) => item.status === 'active')) return 'active';
10
+ if (records.every((item) => item.status === 'skipped')) return 'skipped';
11
+ if (records.some((item) => item.status === 'cancelled') && !records.some((item) => item.status === 'completed')) return 'cancelled';
12
+ return 'completed';
13
+ }
14
+
15
+ export function resolveEffectiveStatus(visits = []) {
16
+ const records = visits.flatMap((visit) => visit.records || []);
17
+ if (!records.length) return 'idle';
18
+ if (records.some((item) => item.status === 'active')) return 'active';
19
+ if (records.some((item) => item.status === 'failed')) return 'failed';
20
+ if (records.some((item) => item.status === 'completed')) return 'completed';
21
+ if (records.some((item) => item.status === 'cancelled') && !records.some((item) => item.status === 'completed')) return 'cancelled';
22
+ if (records.every((item) => item.status === 'skipped')) return 'skipped';
23
+ return resolveVisitStatus(records);
24
+ }
25
+
26
+ export function groupVisits(records = []) {
27
+ const groups = new Map();
28
+ records.forEach((record, index) => {
29
+ const key = record.visitId
30
+ ? `visit:${record.visitId}`
31
+ : record.multiInstanceId
32
+ ? `multi:${record.multiInstanceId}`
33
+ : `record:${record.id || index}`;
34
+ if (!groups.has(key)) {
35
+ groups.set(key, {
36
+ id: record.visitId || record.multiInstanceId || record.id || String(index),
37
+ records: [],
38
+ index,
39
+ });
40
+ }
41
+ groups.get(key).records.push(record);
42
+ });
43
+ return [...groups.values()]
44
+ .sort((left, right) => compareRuntimeOrder(visitOrderKey(left), visitOrderKey(right)))
45
+ .map((visit, index) => ({ id: visit.id, records: visit.records, round: index + 1, index: visit.index }));
46
+ }
47
+
48
+ export function markEffectiveVisits(visits = [], { elementId, transitions = [], diagnostics = null } = {}) {
49
+ if (visits.length <= 1) {
50
+ return visits.map((visit) => ({ ...visit, effective: true, superseded: false }));
51
+ }
52
+ const ranges = visits.map((visit, index) => ({ visit, index, ...visitRange(visit) }));
53
+ const superseded = new Set();
54
+
55
+ for (const visit of visits) {
56
+ for (const record of visit.records) {
57
+ const target = record.supersedesVisitId;
58
+ if (!target) continue;
59
+ if (visits.some((candidate) => candidate.id === target)) superseded.add(target);
60
+ else {
61
+ pushDiagnostic(diagnostics, {
62
+ code: 'unknown-superseded-visit',
63
+ level: 'warning',
64
+ message: 'supersedesVisitId 未匹配到同节点 Visit。',
65
+ fieldPath: `activities.supersedesVisitId`,
66
+ elementId,
67
+ activityId: record.id,
68
+ visitId: visit.id,
69
+ });
70
+ }
71
+ }
72
+ }
73
+
74
+ const inbound = transitions.filter((transition) => ['reject', 'return'].includes(transition.type) && transition.targetElementId === elementId);
75
+ const timed = ranges.some((range) => range.start != null || range.end != null);
76
+
77
+ for (let earlierIndex = 0; earlierIndex < ranges.length; earlierIndex += 1) {
78
+ for (let laterIndex = earlierIndex + 1; laterIndex < ranges.length; laterIndex += 1) {
79
+ const earlier = ranges[earlierIndex];
80
+ const later = ranges[laterIndex];
81
+ if (superseded.has(later.visit.id)) continue;
82
+ const sequential = isSequentialReplacement(earlier, later, inbound);
83
+ const overlapping = isOverlappingOpen(earlier, later);
84
+ if (overlapping && !sequential) {
85
+ pushDiagnostic(diagnostics, {
86
+ code: 'concurrent-visits',
87
+ level: 'info',
88
+ message: '同一节点存在时间重叠且未声明替代关系的独立 Visit。',
89
+ fieldPath: 'activities.visitId',
90
+ elementId,
91
+ visitId: earlier.visit.id,
92
+ });
93
+ continue;
94
+ }
95
+ if (sequential) superseded.add(earlier.visit.id);
96
+ }
97
+ }
98
+
99
+ if (!timed) {
100
+ const activeCount = ranges.filter((range) => range.status === 'active').length;
101
+ if (activeCount <= 1) {
102
+ visits.slice(0, -1).forEach((visit) => superseded.add(visit.id));
103
+ } else {
104
+ pushDiagnostic(diagnostics, {
105
+ code: 'ambiguous-visit-order',
106
+ level: 'warning',
107
+ message: '多个 Visit 缺少可比较时间,未将历史 active 解释为先后重办。',
108
+ fieldPath: 'activities.startTime',
109
+ elementId,
110
+ });
111
+ }
112
+ }
113
+
114
+ return visits.map((visit) => {
115
+ const isSuperseded = superseded.has(visit.id);
116
+ return { ...visit, effective: !isSuperseded, superseded: isSuperseded };
117
+ });
118
+ }
119
+
120
+ function visitOrderKey(visit) {
121
+ let instant = null;
122
+ visit.records.forEach((record) => {
123
+ const start = runtimeInstant(record.startTime);
124
+ const end = runtimeInstant(record.endTime);
125
+ const candidate = start ?? end;
126
+ if (candidate == null) return;
127
+ instant = instant == null ? candidate : Math.min(instant, candidate);
128
+ });
129
+ return { instant, index: visit.index || 0 };
130
+ }
131
+
132
+ function visitRange(visit) {
133
+ let start = null;
134
+ let end = null;
135
+ let open = false;
136
+ for (const record of visit.records) {
137
+ const started = parseRuntimeInstant(record.startTime).instant;
138
+ const ended = parseRuntimeInstant(record.endTime).instant;
139
+ if (started != null) start = start == null ? started : Math.min(start, started);
140
+ if (ended != null) end = end == null ? ended : Math.max(end, ended);
141
+ if (record.status === 'active' && ended == null) open = true;
142
+ }
143
+ const status = resolveVisitStatus(visit.records);
144
+ if (status === 'active') open = true;
145
+ return { start, end, open, status };
146
+ }
147
+
148
+ function isOverlappingOpen(earlier, later) {
149
+ if (!earlier.open) return false;
150
+ if (later.start == null) return earlier.open && later.open;
151
+ if (earlier.end == null) return true;
152
+ return later.start < earlier.end;
153
+ }
154
+
155
+ function isSequentialReplacement(earlier, later, inbound) {
156
+ if (!TERMINAL.has(earlier.status) && !earlier.open) return false;
157
+ if (earlier.open && !TERMINAL.has(earlier.status)) return false;
158
+ if (earlier.end != null && later.start != null && later.start >= earlier.end) return true;
159
+ if (TERMINAL.has(earlier.status) && !earlier.open && later.start != null && earlier.start != null && later.start > earlier.start) return true;
160
+ const laterInstant = later.start ?? later.end;
161
+ return inbound.some((transition) => {
162
+ const at = runtimeInstant(transition.occurredAt || transition.time);
163
+ if (at == null || laterInstant == null) return inbound.length > 0 && TERMINAL.has(earlier.status);
164
+ const afterEarlier = earlier.end == null || at >= earlier.end || (earlier.start != null && at >= earlier.start);
165
+ return afterEarlier && at <= laterInstant;
166
+ });
167
+ }
@@ -1,7 +1,7 @@
1
1
  import { cloneModel, createEdge, getScopeGraph, NODE_DEFINITIONS } from '../core/index.js';
2
2
  import { createDefaultIconRegistry, createIconElement } from '../icons/index.js';
3
3
  import { DiagramRenderer } from '../renderer-svg/index.js';
4
- import { createRuntimePresentation, normalizeRuntime } from '../runtime/index.js';
4
+ import { compareRuntimeOrder, createRuntimePresentation, formatRuntimeInstant, normalizeRuntime, parseRuntimeInstant } from '../runtime/index.js';
5
5
  import { createRuntimeTraceProjection } from './runtime-trace.js';
6
6
  import { renderDefaultRuntimeTimeline, RuntimeTimelineHost } from './timeline.js';
7
7
  import { renderRuntimeApprovalContent } from './runtime-content.js';
@@ -51,7 +51,7 @@ function renderRuntimeAction({ container, viewer, action, resolveAsset, compact
51
51
  element('em', '', appearance.label),
52
52
  );
53
53
  header.appendChild(heading);
54
- if (action.occurredAt) header.appendChild(element('time', '', action.occurredAt));
54
+ if (action.occurredAt) header.appendChild(element('time', '', formatRuntimeInstant(action.occurredAt)));
55
55
  article.appendChild(header);
56
56
  if (action.targets?.length) article.appendChild(element('div', 'mb-runtime-action-targets', `目标:${action.targets.map((target) => target.name).join('、')}`));
57
57
  if (action.targetElementId && !action.targets?.length) article.appendChild(element('div', 'mb-runtime-action-targets', `目标节点:${action.targetElementId}`));
@@ -104,7 +104,7 @@ export function renderDefaultRuntimeDetails({ container, viewer, node, presentat
104
104
  applyRuntimeTone(recordStatus, viewer?.runtimeAppearance?.resolveStatus(record.status));
105
105
  title.append(element('strong', '', record.participant?.name || record.assignee || '待分配审批人'), recordStatus);
106
106
  copy.appendChild(title);
107
- const time = [record.startTime, record.endTime].filter(Boolean).join(' — ');
107
+ const time = [record.startTime, record.endTime].map((value) => formatRuntimeInstant(value)).filter(Boolean).join(' — ');
108
108
  if (time) copy.appendChild(element('time', '', time));
109
109
  row.append(avatar, copy);
110
110
  body.appendChild(row);
@@ -113,7 +113,10 @@ export function renderDefaultRuntimeDetails({ container, viewer, node, presentat
113
113
  const renderedActionIds = new Set();
114
114
  if (!visits.length) body.appendChild(element('div', 'mb-runtime-details-empty', presentation.summary || presentation.statusLabel));
115
115
  visits.forEach((visit) => {
116
- if (presentation.visits.length > 1) body.appendChild(element('div', 'mb-runtime-details-round', `第 ${visit.round} 次处理`));
116
+ if (presentation.visits.length > 1) {
117
+ const historical = visit.effective === false || visit.superseded;
118
+ body.appendChild(element('div', 'mb-runtime-details-round', historical ? `第 ${visit.round} 次处理 · 历史` : `第 ${visit.round} 次处理`));
119
+ }
117
120
  [...visit.records].reverse().forEach(appendRecord);
118
121
  [...visit.actions].reverse().forEach((action) => {
119
122
  renderRuntimeAction({ container: body, viewer, action, resolveAsset });
@@ -130,7 +133,7 @@ export function renderDefaultRuntimeDetails({ container, viewer, node, presentat
130
133
  const block = element('article', `mb-runtime-details-transition type-${transition.type}`);
131
134
  applyRuntimeTone(block, viewer?.runtimeAppearance?.resolveTransition(transition).tone);
132
135
  block.appendChild(element('strong', '', transition.type === 'reject' ? `驳回至 ${transition.targetName || transition.targetElementId}` : `退回至 ${transition.targetName || transition.targetElementId}`));
133
- const meta = [transition.operator, transition.occurredAt || transition.time].filter(Boolean).join(' · ');
136
+ const meta = [transition.operator, formatRuntimeInstant(transition.occurredAt || transition.time)].filter(Boolean).join(' · ');
134
137
  if (meta) block.appendChild(element('span', '', meta));
135
138
  if (transition.action && !renderedActionIds.has(transition.action.id)) {
136
139
  renderRuntimeApprovalContent({
@@ -189,7 +192,7 @@ export function renderDefaultRuntimeTransitionDetails({
189
192
  actorStatus,
190
193
  );
191
194
  actorCopy.appendChild(actorTitle);
192
- const time = transition.action?.occurredAt || transition.occurredAt || transition.time;
195
+ const time = formatRuntimeInstant(transition.action?.occurredAt || transition.occurredAt || transition.time);
193
196
  if (time) actorCopy.appendChild(element('time', '', time));
194
197
  actor.append(avatar, actorCopy);
195
198
  body.appendChild(actor);
@@ -312,7 +315,10 @@ function projectRuntime(runtime, projectedModel) {
312
315
  .map((id) => normalized.edgeVisits?.filter((visit) => visit.edgeId === id).at(-1))
313
316
  .filter(Boolean);
314
317
  if (visits.length === edge.sourceEdgeIds.length) {
315
- const latest = visits.sort((a, b) => String(a.occurredAt || a.time || '').localeCompare(String(b.occurredAt || b.time || ''))).at(-1);
318
+ const latest = visits
319
+ .map((visit, index) => ({ visit, index, instant: parseRuntimeInstant(visit.occurredAt || visit.time).instant }))
320
+ .sort((left, right) => compareRuntimeOrder(left, right))
321
+ .at(-1)?.visit;
316
322
  projectedEdgeVisits.push({ ...latest, id: `ProjectedVisit_${edge.id}`, edgeId: edge.id });
317
323
  }
318
324
  }
@@ -396,6 +402,10 @@ export class BpmnViewer {
396
402
  this.selection = null;
397
403
  this._sidebarTransitionActive = false;
398
404
  this._sidebarViewport = null;
405
+ this._lastViewport = null;
406
+ this._timelineScroll = 0;
407
+ this._silentViewport = false;
408
+ this._pendingFit = false;
399
409
  this._detailsCleanup = null;
400
410
  this._detailsMotion = null;
401
411
  this._detailsRoot = null;
@@ -426,7 +436,13 @@ export class BpmnViewer {
426
436
  throw error;
427
437
  }
428
438
  this._resizeObserver = typeof ResizeObserver === 'function'
429
- ? new ResizeObserver(() => this._refreshAutoProjection())
439
+ ? new ResizeObserver(() => {
440
+ this._refreshAutoProjection();
441
+ if (this._pendingFit && this._containerHasSize()) {
442
+ this._pendingFit = false;
443
+ this.renderer?.fitView?.();
444
+ }
445
+ })
430
446
  : null;
431
447
  this._resizeObserver?.observe(options.container);
432
448
  }
@@ -453,8 +469,12 @@ export class BpmnViewer {
453
469
  const next = this._resolveProjection();
454
470
  if (next === this.activeProjection) return;
455
471
  if (this.activeProjection !== 'compact') this._sidebarViewport = this.renderer?.getViewportState?.() || this._sidebarViewport;
456
- this.refresh();
457
- if (this.activeProjection !== 'compact' && this._sidebarViewport) this.renderer?.setViewportState?.(this._sidebarViewport);
472
+ this.refresh({ preserveViewport: this.activeProjection !== 'compact' });
473
+ if (this.activeProjection !== 'compact' && this._sidebarViewport) {
474
+ this._silentViewport = true;
475
+ try { this.renderer?.setViewportState?.(this._sidebarViewport); }
476
+ finally { this._silentViewport = false; }
477
+ }
458
478
  }
459
479
 
460
480
  _rebuildProjection() {
@@ -642,7 +662,11 @@ export class BpmnViewer {
642
662
  this._emitTraceClick({ targetType: 'transition', element, transition, originalEvent: event });
643
663
  if (this.runtimeDetailsOptions.autoOpen) this.openRuntimeTransitionDetails(transition, event.currentTarget);
644
664
  },
645
- onViewportChange: (viewport) => { this._disposeRuntimeDetails(); this.onViewportChange(viewport); },
665
+ onViewportChange: (viewport) => {
666
+ if (viewport) this._lastViewport = { zoom: viewport.zoom, pan: { ...viewport.pan } };
667
+ if (!this._silentViewport) this._disposeRuntimeDetails();
668
+ this.onViewportChange(viewport);
669
+ },
646
670
  });
647
671
  }
648
672
 
@@ -964,26 +988,121 @@ export class BpmnViewer {
964
988
  });
965
989
  }
966
990
 
967
- refresh() {
991
+ _rendererKind() {
992
+ if (!this.renderer) return null;
993
+ return this.renderer instanceof RuntimeTimelineHost ? 'timeline' : 'diagram';
994
+ }
995
+
996
+ _containerHasSize() {
997
+ if (typeof document !== 'undefined' && document.hidden) return false;
998
+ const width = this.container.clientWidth || this.container.getBoundingClientRect?.().width || 0;
999
+ const height = this.container.clientHeight || this.container.getBoundingClientRect?.().height || 0;
1000
+ return width >= 1 && height >= 1;
1001
+ }
1002
+
1003
+ _capturePresentationState() {
1004
+ const kind = this._rendererKind();
1005
+ if (kind === 'diagram') {
1006
+ const viewport = this.renderer?.getViewportState?.();
1007
+ if (viewport) this._lastViewport = viewport;
1008
+ } else if (kind === 'timeline') {
1009
+ this._timelineScroll = this.container.scrollTop || this._timelineScroll || 0;
1010
+ }
1011
+ return {
1012
+ kind,
1013
+ details: this._detailsTarget ? { ...this._detailsTarget, anchor: this._detailsAnchor } : null,
1014
+ };
1015
+ }
1016
+
1017
+ _syncDiagramRenderer() {
1018
+ this.renderer.options.visualModel = this.model;
1019
+ this.renderer.options.runtimePresenter = () => this.runtimePresentation;
1020
+ this.renderer.options.runtimeAppearance = this.runtimeAppearance;
1021
+ this.renderer.mode = this.mode;
1022
+ this.renderer.model = this.projectedModel;
1023
+ this.renderer.runtime = this.projectedRuntime;
1024
+ this.renderer.render();
1025
+ }
1026
+
1027
+ _syncTimelineRenderer() {
1028
+ Object.assign(this.renderer.options, {
1029
+ model: this.projectedModel,
1030
+ runtime: this.projectedRuntime,
1031
+ presentation: this.runtimePresentation,
1032
+ projection: this.traceProjection,
1033
+ ...this._resolveTimelineOptions(),
1034
+ appearance: this.runtimeAppearance,
1035
+ themeState: this.themeController.getState(),
1036
+ });
1037
+ this.renderer.render();
1038
+ }
1039
+
1040
+ _pruneSelection() {
1041
+ if (!this.selection) return;
1042
+ const collection = this.selection.kind === 'edge' ? this.projectedModel?.edges : this.projectedModel?.nodes;
1043
+ if (!collection?.some((item) => item.id === this.selection.id)) this.selection = null;
1044
+ }
1045
+
1046
+ _restoreRuntimeDetails(target) {
1047
+ if (!target) return;
1048
+ if (target.kind === 'node') {
1049
+ const node = this.model.nodes.find((item) => item.id === target.id);
1050
+ const presentation = node ? this.runtimePresentation.getNode(node.id) : null;
1051
+ if (node && presentation?.hasDetails) this.openRuntimeDetails(node, presentation, target.anchor || null);
1052
+ return;
1053
+ }
1054
+ if (target.kind === 'transition') {
1055
+ const transition = this.runtimePresentation.getTransition(target.id);
1056
+ if (transition) this._openRuntimeTransitionDetails(transition, target.anchor || null);
1057
+ }
1058
+ }
1059
+
1060
+ refresh(options = {}) {
1061
+ const preserveViewport = options.preserveViewport === true;
1062
+ const previous = this._capturePresentationState();
968
1063
  this._disposeRuntimeDetails();
969
1064
  this._rebuildProjection();
970
- this._mountRenderer();
1065
+ const kind = this.runtime && this.activeProjection === 'compact' && this.traceProjection ? 'timeline' : 'diagram';
1066
+ const sameKind = previous.kind === kind && this.renderer;
1067
+ if (sameKind && kind === 'diagram') this._syncDiagramRenderer();
1068
+ else if (sameKind && kind === 'timeline') this._syncTimelineRenderer();
1069
+ else this._mountRenderer();
1070
+ this._pruneSelection();
971
1071
  if (this.selection) this.renderer.setSelection?.(this.selection);
1072
+ else this.renderer.setSelection?.(null);
972
1073
  this.options._onPanelSelection?.();
1074
+ this._silentViewport = true;
1075
+ try {
1076
+ if (kind === 'diagram' && preserveViewport && this._lastViewport) this.renderer.setViewportState?.(this._lastViewport);
1077
+ if (kind === 'timeline' && preserveViewport) this.container.scrollTop = this._timelineScroll || 0;
1078
+ } finally {
1079
+ this._silentViewport = false;
1080
+ }
1081
+ this._restoreRuntimeDetails(previous.details);
973
1082
  }
974
1083
 
975
- setModel(model) { this.model = model; this._svgExportAssetCache.clear(); this.refresh(); }
1084
+ setModel(model) {
1085
+ const same = this.model === model || Boolean(this.model?.id && model?.id && this.model.id === model.id);
1086
+ this.model = model;
1087
+ this._svgExportAssetCache.clear();
1088
+ this.refresh({ preserveViewport: same });
1089
+ }
976
1090
  setRuntime(runtime) {
977
- this.runtime = runtime ? normalizeRuntime(runtime) : null;
1091
+ const next = runtime ? normalizeRuntime(runtime) : null;
1092
+ const sameInstance = Boolean(this.runtime) && Boolean(next) && (this.runtime.processInstanceId || '') === (next.processInstanceId || '');
1093
+ this.runtime = next;
978
1094
  this.mode = this.options.mode === 'instance' || this.runtime ? 'instance' : 'viewer';
979
1095
  this._svgExportAssetCache.clear();
980
- this.refresh();
1096
+ this.refresh({ preserveViewport: sameInstance });
981
1097
  }
982
1098
  setProjection(projection) {
983
1099
  if (!['auto', 'standard', 'approval', 'compact'].includes(projection)) return;
984
1100
  this.projection = projection;
985
- this.refresh();
986
- requestAnimationFrame(() => this.renderer.fitView());
1101
+ this.refresh({ preserveViewport: false });
1102
+ requestAnimationFrame(() => {
1103
+ if (this._containerHasSize()) this.renderer?.fitView?.();
1104
+ else this._pendingFit = true;
1105
+ });
987
1106
  }
988
1107
  setDisplayOptions({ timeline, runtimeDetails, runtimeTraceOptions, runtimeAssetResolver, replace = false } = {}) {
989
1108
  let changed = false;
@@ -1006,7 +1125,7 @@ export class BpmnViewer {
1006
1125
  this.runtimeDetailsOptions = mergeRuntimeDetailsOptions(replace ? {} : this.runtimeDetailsOptions, runtimeDetails || {});
1007
1126
  changed = true;
1008
1127
  }
1009
- if (changed) this.refresh();
1128
+ if (changed) this.refresh({ preserveViewport: true });
1010
1129
  }
1011
1130
  setTheme(theme) { return this.themeController.setTheme(theme); }
1012
1131
  setThemeMode(mode) { return this.themeController.setMode(mode); }
@@ -1014,7 +1133,7 @@ export class BpmnViewer {
1014
1133
  setRuntimeAppearance(runtimeAppearance) {
1015
1134
  this.runtimeAppearanceOptions = runtimeAppearance || {};
1016
1135
  this.runtimeAppearance = createRuntimeAppearance(this.runtimeAppearanceOptions);
1017
- this.refresh();
1136
+ this.refresh({ preserveViewport: true });
1018
1137
  }
1019
1138
  refreshPresentation() {
1020
1139
  if (this.mode !== 'viewer' || (this.runtime && this.activeProjection === 'compact')) return;
@@ -6,29 +6,34 @@ import {
6
6
  getScopeGraph,
7
7
  NODE_DEFINITIONS,
8
8
  } from '../core/index.js';
9
- import { createRuntimePresentation, normalizeRuntime } from '../runtime/index.js';
9
+ import { compareRuntimeOrder, createRuntimePresentation, normalizeRuntime, parseRuntimeInstant } from '../runtime/index.js';
10
10
 
11
11
  const HUMAN_TASKS = new Set(['userTask', 'manualTask', 'callActivity']);
12
12
  const AUTOMATED_TASKS = new Set(['serviceTask', 'scriptTask', 'businessRuleTask', 'sendTask', 'receiveTask']);
13
13
  const CONDITIONAL_GATEWAYS = new Set(['exclusiveGateway', 'inclusiveGateway', 'eventBasedGateway', 'complexGateway']);
14
14
  const ABNORMAL_TRANSITIONS = new Set(['reject', 'return']);
15
15
 
16
- function timestamp(value, fallback = 0) {
17
- if (!value) return fallback;
18
- const parsed = Date.parse(String(value).replace(' ', 'T'));
19
- return Number.isFinite(parsed) ? parsed : fallback;
16
+ function timestamp(value) {
17
+ return parseRuntimeInstant(value).instant;
20
18
  }
21
19
 
22
- function recordOrder(record, index = 0) {
23
- return timestamp(record.startTime || record.endTime, index);
20
+ function recordOrder(record) {
21
+ return timestamp(record.startTime || record.endTime);
24
22
  }
25
23
 
26
- function transitionOrder(transition, index = 0) {
27
- return timestamp(transition.occurredAt || transition.time, index);
24
+ function transitionOrder(transition) {
25
+ return timestamp(transition.occurredAt || transition.time);
28
26
  }
29
27
 
30
- function edgeVisitOrder(visit, index = 0) {
31
- return timestamp(visit.occurredAt || visit.time, index);
28
+ function edgeVisitOrder(visit) {
29
+ return timestamp(visit.occurredAt || visit.time);
30
+ }
31
+
32
+ function byTime(left, right) {
33
+ return compareRuntimeOrder(
34
+ { instant: left.order ?? left.instant ?? null, index: left.index ?? left.originalIndex ?? 0 },
35
+ { instant: right.order ?? right.instant ?? null, index: right.index ?? right.originalIndex ?? 0 },
36
+ );
32
37
  }
33
38
 
34
39
  function visitStatus(records) {
@@ -186,8 +191,8 @@ function inferVisitedEdges(model, runtime, outgoing) {
186
191
  ]);
187
192
  if (ids.size) return ids;
188
193
  const ordered = [...runtime.activities]
189
- .map((record, index) => ({ record, order: recordOrder(record, index) }))
190
- .sort((a, b) => a.order - b.order)
194
+ .map((record, index) => ({ record, order: recordOrder(record), index }))
195
+ .sort(byTime)
191
196
  .map(({ record }) => record.elementId)
192
197
  .filter((id, index, values) => id && (!index || id !== values[index - 1]));
193
198
  for (let index = 0; index < ordered.length - 1; index += 1) {
@@ -198,9 +203,9 @@ function inferVisitedEdges(model, runtime, outgoing) {
198
203
 
199
204
  function latestRuntimeElement(runtime, nodeMap) {
200
205
  return runtime.activities
201
- .map((record, index) => ({ record, order: recordOrder(record, index) }))
206
+ .map((record, index) => ({ record, order: recordOrder(record), index }))
202
207
  .filter(({ record }) => nodeMap.has(record.elementId) && !isGateway(nodeMap.get(record.elementId)))
203
- .sort((a, b) => a.order - b.order)
208
+ .sort(byTime)
204
209
  .at(-1)?.record?.elementId || null;
205
210
  }
206
211
 
@@ -290,11 +295,11 @@ function projectRuntime(runtime, graphModel, presentation) {
290
295
  if (sourcePresentations.every((item) => item.historicallyVisited)) projectedVisited.push(edge.id);
291
296
  const visits = sources.map((id) => runtime.edgeVisits
292
297
  .filter((visit) => visit.edgeId === id)
293
- .map((visit, index) => ({ visit, order: edgeVisitOrder(visit, index) }))
294
- .sort((a, b) => a.order - b.order)
298
+ .map((visit, index) => ({ visit, order: edgeVisitOrder(visit), index }))
299
+ .sort(byTime)
295
300
  .at(-1)).filter(Boolean);
296
301
  if (visits.length === sources.length) {
297
- const latest = visits.sort((a, b) => a.order - b.order).at(-1);
302
+ const latest = visits.sort(byTime).at(-1);
298
303
  projectedEdgeVisits.push({
299
304
  ...latest.visit,
300
305
  id: `TraceVisit_${edge.id}`,
@@ -324,27 +329,25 @@ function buildVisitItems(model, runtime, presentation, predictedNodeIds, ordered
324
329
  if (!node || isGateway(node) || !isVisibleTraceNode(node, { actual: true, ...options })) return;
325
330
  const visitKey = record.visitId || record.multiInstanceId || record.id || String(index);
326
331
  const key = `${record.elementId}:${visitKey}`;
327
- if (!groups.has(key)) groups.set(key, { key, node, visitId: visitKey, records: [], order: recordOrder(record, index), originalIndex: index });
332
+ if (!groups.has(key)) groups.set(key, { key, node, visitId: visitKey, records: [], order: recordOrder(record), originalIndex: index });
328
333
  const group = groups.get(key);
329
334
  group.records.push(record);
330
- group.order = Math.min(group.order, recordOrder(record, index));
335
+ const nextOrder = recordOrder(record);
336
+ if (nextOrder != null) group.order = group.order == null ? nextOrder : Math.min(group.order, nextOrder);
331
337
  });
332
338
  const rounds = new Map();
333
- const items = [...groups.values()].sort((a, b) => (a.order - b.order) || (a.originalIndex - b.originalIndex)).map((group) => {
339
+ const items = [...groups.values()].sort(byTime).map((group) => {
334
340
  const round = (rounds.get(group.node.id) || 0) + 1;
335
341
  rounds.set(group.node.id, round);
336
342
  const status = visitStatus(group.records);
337
343
  const participants = uniqueParticipants(group.records);
338
344
  const startTime = group.records.map((record) => record.startTime).filter(Boolean).sort()[0];
339
345
  const endTime = group.records.map((record) => record.endTime).filter(Boolean).sort().at(-1);
340
- const actions = (presentation.getNode(group.node.id).visits.find((visit) => visit.id === group.visitId)?.actions || [])
341
- .filter((action) => !transitionActionIds.has(action.id));
342
- const latestAction = [...actions].reverse().find((action) => action.plainText || action.assetCount) || actions.at(-1) || null;
346
+ const visitPresentation = presentation.getNode(group.node.id).visits.find((visit) => visit.id === group.visitId);
347
+ const actions = (visitPresentation?.actions || []).filter((action) => !transitionActionIds.has(action.id));
348
+ const latestAction = visitPresentation?.latestAction || actions.at(-1) || null;
343
349
  const imageCount = actions.reduce((sum, action) => sum + action.imageCount, 0);
344
350
  const fileCount = actions.reduce((sum, action) => sum + action.fileCount, 0);
345
- const assetSummary = latestAction
346
- ? [latestAction.imageCount ? `${latestAction.imageCount} 张图片` : '', latestAction.fileCount ? `${latestAction.fileCount} 个附件` : ''].filter(Boolean).join(' · ')
347
- : '';
348
351
  return {
349
352
  id: `TraceItem_${group.node.id}_${group.visitId}`,
350
353
  kind: isStartOrEnd(group.node) ? 'milestone' : 'activity',
@@ -362,7 +365,7 @@ function buildVisitItems(model, runtime, presentation, predictedNodeIds, ordered
362
365
  actions,
363
366
  latestAction,
364
367
  actionText: latestAction?.plainText || '',
365
- actionSummary: latestAction ? [latestAction.label, latestAction.plainText, assetSummary].filter(Boolean).join(' · ') : '',
368
+ actionSummary: visitPresentation?.actionSummary || (latestAction ? [latestAction.label, latestAction.plainText].filter(Boolean).join(' · ') : ''),
366
369
  imageCount,
367
370
  fileCount,
368
371
  assetCount: imageCount + fileCount,
@@ -375,7 +378,7 @@ function buildVisitItems(model, runtime, presentation, predictedNodeIds, ordered
375
378
  order: group.order,
376
379
  };
377
380
  });
378
- let predictedOrder = Math.max(0, ...items.map((item) => item.order)) + 1;
381
+ let predictedOrder = Math.max(0, ...items.map((item) => item.order).filter((value) => Number.isFinite(value))) + 1;
379
382
  for (const nodeId of orderedPredictedIds) {
380
383
  if (!predictedNodeIds.has(nodeId) || items.some((item) => item.elementId === nodeId)) continue;
381
384
  const node = nodeMap.get(nodeId);
@@ -435,7 +438,7 @@ function buildVisitItems(model, runtime, presentation, predictedNodeIds, ordered
435
438
  order: transitionOrder(transition, 0),
436
439
  });
437
440
  }
438
- return items.sort((a, b) => (a.order - b.order) || a.id.localeCompare(b.id));
441
+ return items.sort((left, right) => byTime(left, right) || left.id.localeCompare(right.id));
439
442
  }
440
443
 
441
444
  function buildTimelineLinks(items, maps, historicalEdgeIds, allowedEdgeIds) {
@@ -1,4 +1,5 @@
1
1
  import { createDefaultIconRegistry, createIconElement } from '../icons/index.js';
2
+ import { formatRuntimeInstant } from '../runtime/index.js';
2
3
  import { renderRuntimeApprovalContent } from './runtime-content.js';
3
4
  import { applyRuntimeTone } from '../theme/index.js';
4
5
 
@@ -31,8 +32,7 @@ function resultText(item) {
31
32
  }
32
33
 
33
34
  function formatTime(value) {
34
- if (!value) return '';
35
- return String(value).replace('T', ' ');
35
+ return formatRuntimeInstant(value);
36
36
  }
37
37
 
38
38
  function renderParticipants(item) {
package/dist/shell.js CHANGED
@@ -1034,7 +1034,7 @@ export class BpmnStudioShell {
1034
1034
  ui: Object.freeze({ ...this._config.ui, regions: Object.freeze(normalizeStudioRegions(regions, this._config.ui.regions)) }),
1035
1035
  });
1036
1036
  }
1037
- if (changed || regions?.right === 'default') this._applyRegions();
1037
+ if (changed || regions?.right === 'default') this._applyRegions({ fit: false });
1038
1038
  return this.getRegions();
1039
1039
  }
1040
1040
  _isSidebarHidden(side) {