gis-view-webgl2 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +40 -8
  2. package/dist/gis-view.css +1 -1
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.d.ts +1036 -249
  5. package/dist/index.js +1 -1
  6. package/docs/AI/345/257/271/350/257/235/346/261/207/346/200/273.md +194 -0
  7. package/docs/GisView.md +9 -8
  8. package/docs/GisView/345/256/236/347/216/260/345/216/237/347/220/206.md +2 -2
  9. package/docs/ai/06-/344/274/232/350/257/235/347/264/242/345/274/225/344/270/216/345/244/215/346/237/245/346/226/271/346/263/225.md +172 -0
  10. package/docs/api/index.html +545 -59
  11. package/docs/demos/BackendSyncDemo.vue +10 -8
  12. package/docs/demos/BasicDemo.vue +101 -101
  13. package/docs/demos/BatchAddDemo.vue +5 -5
  14. package/docs/demos/ChannelDemo.vue +617 -10
  15. package/docs/demos/ConnectDemo.vue +392 -0
  16. package/docs/demos/CopyMergeDemo.vue +980 -929
  17. package/docs/demos/DecorationDraftDemo.vue +1300 -0
  18. package/docs/demos/DemoShell.vue +569 -240
  19. package/docs/demos/DragDropDemo.vue +11 -11
  20. package/docs/demos/EffectsDemo.vue +6 -6
  21. package/docs/demos/EventDemo.vue +327 -327
  22. package/docs/demos/ExtensionDemo.vue +458 -0
  23. package/docs/demos/GisViewDemo.vue +61 -48
  24. package/docs/demos/GraphicDemo.vue +338 -0
  25. package/docs/demos/LabelDemo.vue +553 -497
  26. package/docs/demos/MinimapDemo.vue +358 -0
  27. package/docs/demos/PerfDemo.vue +29 -0
  28. package/docs/demos/SearchFilterDemo.vue +412 -0
  29. package/docs/demos/StyleDemo.vue +121 -121
  30. package/docs/demos/api-index.js +98 -77
  31. package/docs/demos/demos.js +159 -135
  32. package/docs/demos/shared/demoData.js +107 -107
  33. package/docs/demos/shared/demoStyle.css +249 -249
  34. package/docs/demos/shared/fakeBackend.js +93 -6
  35. package/docs/format/03-/346/213/220/347/202/271/350/243/205/351/245/260/344/270/216/346/240/267/345/274/217.md +28 -8
  36. package/docs/format/04-/351/200/232/351/201/223/344/270/216/346/240/207/347/255/276.md +5 -4
  37. package/docs/format/06-/345/257/274/345/205/245/345/257/274/345/207/272/344/270/216/346/240/207/345/207/206/346/226/207/346/241/243.md +8 -6
  38. package/docs/format/07-/344/272/213/344/273/266/350/275/275/350/215/267/344/270/216/347/244/272/344/276/213/346/225/260/346/215/256.md +6 -6
  39. package/docs/guide/sample-document.html +4 -4
  40. package/docs/index.html +180 -9
  41. package/docs/internals/11-/350/246/206/347/233/226/345/261/202/346/240/207/347/255/276/344/270/216/346/217/220/347/244/272.md +1 -1
  42. package/docs/internals/13-/351/271/260/347/234/274/345/233/276.md +3 -2
  43. package/docs/internals/15-03-/351/200/220/350/275/256/350/256/260/345/275/225-/346/200/247/350/203/275/344/270/216/344/277/256/345/244/215-/344/270/213.md +16 -0
  44. package/docs/internals/16-03-/344/274/230/345/214/226/344/270/216/345/217/226/350/210/215-/344/270/213.md +11 -0
  45. package/docs/internals/17-/350/260/203/350/257/225/344/270/216/346/211/251/345/261/225/345/273/272/350/256/256.md +1 -1
  46. package/docs/manual/02-/345/235/220/346/240/207/344/270/216/346/225/260/346/215/256.md +18 -4
  47. package/docs/manual/03-/351/205/215/347/275/256/351/241/271.md +110 -25
  48. package/docs/manual/04-/344/272/213/344/273/266/344/270/216/345/233/236/350/260/203.md +13 -8
  49. package/docs/manual/06-/346/225/260/346/215/256/344/270/216/345/234/272/346/231/257/346/233/264/346/226/260.md +111 -10
  50. package/docs/manual/07-/346/214/207/351/222/210/344/270/216/350/246/201/347/264/240/347/274/226/350/276/221.md +47 -9
  51. package/docs/manual/08-/351/200/211/344/270/255/344/270/216/346/213/220/347/202/271.md +17 -15
  52. package/docs/manual/09-/351/200/232/351/201/223/344/270/216/345/244/232/345/233/236/347/272/277.md +61 -11
  53. package/docs/manual/10-/351/200/232/351/201/223/347/274/226/350/276/221/344/270/216/346/226/260/345/242/236.md +74 -7
  54. package/docs/manual/11-/346/240/267/345/274/217/344/270/216/350/247/204/345/210/231.md +3 -2
  55. package/docs/manual/12-/346/240/207/347/255/276/344/270/216/350/243/205/351/245/260.md +260 -3
  56. package/docs/manual/13-/345/212/250/346/225/210/344/270/216/345/233/276/345/205/203.md +6 -5
  57. package/docs/manual/14-/350/247/206/345/233/276/344/270/216/347/255/233/351/200/211.md +27 -14
  58. package/docs/manual/15-/345/257/274/345/207/272/344/270/216/351/233/206/346/210/220.md +142 -49
  59. package/docs/manual/16-/345/256/236/344/276/213/346/226/271/346/263/225/346/200/273/350/241/250.md +31 -29
  60. package/docs/manual/17-/345/217/202/346/225/260/351/200/237/346/237/245.md +55 -39
  61. package/docs/manual/18-/351/200/220/346/226/271/346/263/225/347/244/272/344/276/213.md +189 -64
  62. package/docs/manual/19-/350/257/273/346/225/260/344/270/216/346/213/226/346/213/275/351/242/204/350/247/210.md +4 -4
  63. package/docs/manual/20-/346/200/247/350/203/275/344/270/216/345/244/247/346/225/260/346/215/256.md +42 -2
  64. package/docs/manual/21-/350/257/264/346/230/216/344/270/216/345/270/270/350/247/201/351/227/256/351/242/230.md +22 -6
  65. package/docs/playground/index.html +5 -5
  66. package/docs/steps/README.md +14 -10
  67. package/docs/steps/check-doc-truth.mjs +1 -1
  68. package/docs/steps/selftest-actions.html +10 -10
  69. package/docs/steps/selftest-data.html +96 -14
  70. package/docs/steps/selftest-decoration.html +577 -0
  71. package/docs/steps/selftest-demo.html +6 -5
  72. package/docs/steps/selftest-demos.html +2201 -1612
  73. package/docs/steps/selftest-docs.html +89 -16
  74. package/docs/steps/selftest-effects.html +5 -5
  75. package/docs/steps/selftest-features.html +757 -86
  76. package/docs/steps/selftest-full.html +583 -583
  77. package/docs/steps/selftest-labels.html +1034 -652
  78. package/docs/steps/selftest-perf.html +1899 -1844
  79. package/docs/steps/selftest-scale.html +117 -7
  80. package/docs/steps/selftest-select.html +24 -17
  81. package/docs/steps/step-10-full.js +1 -1
  82. package/docs//346/216/245/345/217/243/345/215/217/350/256/256.md +167 -17
  83. package/docs//346/225/260/346/215/256/346/240/274/345/274/217.md +1 -1
  84. package/package.json +43 -43
  85. package/docs/demos/HistoryDemo.vue +0 -230
  86. package/docs/demos/InterfaceDemo.vue +0 -309
  87. package/docs/demos/SampleDemo.vue +0 -283
@@ -1,30 +1,31 @@
1
- <script setup>
2
- /**
3
- * 批量复制与合并(可交互版):同一张画布上把三条链路走完 ——
4
- * 框选 → 复制 / 合并;Alt 点击复制 → 从 pointCopy 载荷接管或回滚;
5
- * 拖点相撞 → 从 pointMerge 的 pending / confirm 载荷指定保留节点并改写最终节点。
6
- */
1
+ <script setup>
2
+ /**
3
+ * 批量复制与合并(可交互版):同一张画布上把三条链路走完 ——
4
+ * 框选 → 复制 / 合并;Alt 点击复制 → 从 pointCopy 载荷接管或回滚;
5
+ * 拖点相撞 → 从 pointMerge 的 pending / confirm 载荷指定保留节点并改写最终节点。
6
+ */
7
7
  import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, triggerRef } from 'vue'
8
8
  import { createGisView } from '../../src/index.js'
9
9
  import { buildDemoData } from './shared/demoData.js'
10
10
  import { createFakeBackend, cloneFakeData, FAKE_BACKEND_MODES } from './shared/fakeBackend.js'
11
11
  import './shared/demoStyle.css'
12
-
13
- const emit = defineEmits(['notice'])
14
- const container = ref(null)
15
- const selectionText = ref('未选中')
16
- const actionText = ref('先框选一组节点,再试复制或合并')
17
- const copyText = ref('复制:—')
18
- const clipboardText = ref('剪贴板:—')
19
- const mergeText = ref('合并:—')
20
- const pendingText = ref('拖动一个节点撞到另一个节点上,这里会出现待确认载荷')
21
- const mergeDetailText = ref('合并影响详情:—')
12
+
13
+ const emit = defineEmits(['notice'])
14
+ const container = ref(null)
15
+ const selectionText = ref('未选中')
16
+ const actionText = ref('先框选一组节点,再试复制或合并')
17
+ const copyText = ref('复制:—')
18
+ const clipboardText = ref('剪贴板:—')
19
+ const mergeText = ref('合并:—')
20
+ const pendingText = ref('拖动一个节点撞到另一个节点上,这里会出现待确认载荷')
21
+ const mergeDetailText = ref('合并影响详情:—')
22
22
  const copyEventText = ref('还没有 pointCopy 事件')
23
23
  const copySourcesText = ref('—')
24
24
  const copyItemsText = ref('—')
25
25
  const copyLineChannelsText = ref('—')
26
26
  const manualText = ref('—')
27
27
  const mergeBackendText = ref('合并变更 → 后端事务:—')
28
+ const splitText = ref('拆分:先选中一个节点(不选就取第一个),再点拆分')
28
29
  const copyBackendText = ref('复制落库:—')
29
30
  const mergeSyncText = ref('合并落库:—')
30
31
  const backendMode = ref('success')
@@ -37,24 +38,24 @@ const copyBackendRetry = shallowRef(null)
37
38
  const mergeBackendRetry = shallowRef(null)
38
39
  const copyBackendLast = shallowRef(null)
39
40
  const mergeBackendLast = shallowRef(null)
40
- const mergedName = ref('合并后节点(demo 改写)')
41
- const mergedZone = ref('合并后(item 改写)')
42
- const mergedVoltage = ref('525')
43
- const mergedColor = ref('#ff5d8f')
44
- const mergedSize = ref('16')
45
- const mergedSymbol = ref('square')
46
- const mergedStrokeColor = ref('#ffffff')
47
- const mergeTargetId = ref('')
48
- const mergeKeep = ref('target')
49
- const rewriteOnConfirm = ref(true)
50
- const pointOptions = ref([])
51
- const selectedPoints = ref([])
52
- const selectionCounts = ref({ points: 0, lines: 0, channels: 0 })
53
- const selectionFilters = ref({ point: true, line: true, channel: true })
54
- const logLines = ref([])
55
- const copyToast = shallowRef(null)
56
- const lastBatch = shallowRef(null)
57
- const clipboardItem = shallowRef(null)
41
+ const mergedName = ref('合并后节点(demo 改写)')
42
+ const mergedZone = ref('合并后(item 改写)')
43
+ const mergedVoltage = ref('525')
44
+ const mergedColor = ref('#ff5d8f')
45
+ const mergedSize = ref('16')
46
+ const mergedSymbol = ref('square')
47
+ const mergedStrokeColor = ref('#ffffff')
48
+ const mergeTargetId = ref('')
49
+ const mergeKeep = ref('target')
50
+ const rewriteOnConfirm = ref(true)
51
+ const pointOptions = ref([])
52
+ const selectedPoints = ref([])
53
+ const selectionCounts = ref({ points: 0, lines: 0, channels: 0 })
54
+ const selectionFilters = ref({ point: true, line: true, channel: true })
55
+ const logLines = ref([])
56
+ const copyToast = shallowRef(null)
57
+ const lastBatch = shallowRef(null)
58
+ const clipboardItem = shallowRef(null)
58
59
  const pendingMerge = shallowRef(null)
59
60
  const copyEvent = shallowRef(null)
60
61
  const fakeBackend = createFakeBackend({ initialVersion: 12, latency: 420, prefix: 'copy-merge' })
@@ -64,10 +65,11 @@ let logSeq = 0
64
65
  let copyRound = 0
65
66
  let pasteRound = 0
66
67
  let addPointRound = 0
68
+ let splitRound = 0
67
69
  let mergeSyncTimer = null
68
70
  let mergeSyncQueue = []
69
71
  let disposed = false
70
-
72
+
71
73
  const pointLabel = item => String(item?.name ?? item?.label ?? item?.id ?? '节点')
72
74
  const pointKey = item => String(item?.id ?? item?.label ?? item?.name ?? '—')
73
75
  const itemId = item => (item?.id ?? item?.line_id ?? item?.channel_id ?? '无 id')
@@ -95,7 +97,7 @@ const describeMergeChangeSets = (sets) => {
95
97
  return `后端事务:删节点 [${points('removedPoint')}] → 更新节点 [${points('point')}] · 更新线 [${ids('lines')}] / 删线 [${ids('removedLines')}] · 更新通道 [${ids('channels')}] / 删通道 [${ids('removedChannels')}]`
96
98
  }
97
99
  const describeMergeChanges = changes => describeMergeChangeSets(changes ? [changes] : [])
98
- /** 合并载荷里的影响预演:lineCount 是「改写 + 删除」的线路并集,后两项分别与落地结果同口径 */
100
+ /** 合并载荷里的影响预演:lineCount 是「改写 + 删除」的线路并集,后两项分别与落地结果同口径 */
99
101
  const describeMergeImpact = impact => (impact
100
102
  ? `影响 ${impact.lineCount} 条线路(改线 ${impact.linesChanged} / 删线 ${impact.removedLines})· 通道 改 ${impact.channelsChanged} / 删 ${impact.removedChannels} / 并 ${impact.mergedChannels}`
101
103
  : '影响读数:当前数据对不上')
@@ -376,447 +378,487 @@ const resetBackendDemo = () => {
376
378
  }
377
379
  /** pending / confirm 阶段按 impact 里的 id 调现有 getLine / getChannel 查详情;确认落地前查,删掉的通道仍有数据 */
378
380
  const showMergeImpactDetails = () => {
379
- const payload = pendingMerge.value
380
- const impact = payload?.impact
381
- if (!impact) {
382
- mergeDetailText.value = '合并影响详情:当前没有可用的影响载荷'
383
- return mergeDetailText.value
384
- }
385
- const lines = impact.lineIds.map(id => {
386
- const line = view?.getLine(id)
387
- if (!line) {
388
- return `${id}(已不存在)`
389
- }
390
- return `${id}(${line.item?.name ?? '未命名'}:${line.item?.from ?? '—'}→${line.item?.to ?? '—'})`
391
- })
392
- const channels = impact.channelIds.map(id => {
393
- const channel = view?.getChannel(id)
394
- if (!channel) {
395
- return `${id}(已不存在)`
396
- }
397
- return `${id}(${channel.item?.name ?? '未命名'}:${channel.lineIndexes.length} 回)`
398
- })
399
- mergeDetailText.value = `影响线路 ${lines.join(' / ') || '无'} · 影响通道 ${channels.join(' / ') || '无'}`
400
- return mergeDetailText.value
401
- }
402
- const selectionKinds = computed(() => ['point', 'line', 'channel'].filter(kind => selectionFilters.value[kind]))
403
- const selectionKindsText = computed(() => selectionKinds.value.length ? selectionKinds.value.join(' + ') : '全关')
404
- const mergeTargetOptions = computed(() => selectedPoints.value.map(item => ({
405
- value: pointKey(item),
406
- label: `${pointLabel(item)}(${pointKey(item)})`
407
- })))
408
- const canMerge = computed(() => selectedPoints.value.length >= 2)
409
- const copyToastTitle = computed(() => {
410
- const payload = copyToast.value
411
- if (!payload) return ''
412
- if (payload.ok === false) return '复制没有落地'
413
- if (payload.phase === 'clipboard') return '剪贴板快照已就绪'
414
- if (payload.phase === 'batch') return payload.via === 'paste' ? 'Ctrl+V 整组粘贴已落地' : '整组复制已落地'
415
- if (payload.phase === 'end') return payload.via === 'paste' ? 'Ctrl+V 已粘贴' : 'Alt 复制已落地'
416
- if (payload.phase === 'commit') return '复制正在提交'
417
- return '复制预览中'
418
- })
419
- const copyToastText = computed(() => {
420
- const payload = copyToast.value
421
- if (!payload) return ''
422
- const sources = payload.sources ?? (payload.source ? [payload.source] : [])
423
- const items = payload.items ?? (payload.item ? [payload.item] : [])
424
- const sourceText = sources.slice(0, 4).map(entry => pointLabel(entry.item)).join(' / ') || '—'
425
- const itemText = items.slice(0, 4).map(pointLabel).join(' / ') || '—'
426
- const supportCount = payload.supportSources?.length ?? sources.filter(entry => entry.support).length
427
- return `来源 ${sourceText}${supportCount ? `(含 ${supportCount} 个补端点)` : ''} → 结果 ${itemText} · 线路 ${payload.lines?.length ?? 0} / 通道 ${payload.channels?.length ?? 0}`
428
- })
429
-
430
- const pushLog = (name, text) => {
431
- logSeq += 1
432
- logLines.value = [{ id: logSeq, name, text }, ...logLines.value].slice(0, 18)
433
- }
434
-
435
- const focusCanvas = () => nextTick(() => {
436
- container.value?.querySelector('.gis-view')?.focus({ preventScroll: true })
437
- })
438
-
439
- const refreshState = () => {
440
- if (!view) return
441
- const data = view.getData()
442
- pointOptions.value = data.points.map((item, index) => ({
443
- value: pointKey(item) || String(index),
444
- label: `${pointLabel(item)}(${pointKey(item)})`
445
- }))
446
- const selection = view.getSelectionData()
447
- selectedPoints.value = selection.points ?? []
448
- selectionCounts.value = { points: selection.pointCount, lines: selection.lineCount, channels: selection.channelCount }
449
- selectionText.value = selection.count
450
- ? `${selection.pointCount} 节点 / ${selection.lineCount} 线路 / ${selection.channelCount} 通道:${(selection.items ?? []).slice(0, 5).map(entry => pointLabel(entry.item)).join(' / ')}`
451
- : '未选中'
452
- if (!mergeTargetOptions.value.some(item => item.value === mergeTargetId.value)) {
453
- mergeTargetId.value = mergeTargetOptions.value[0]?.value ?? ''
454
- }
455
- }
456
-
457
- const describeBatchCopy = payload => {
458
- const sourceNames = (payload?.sources ?? []).slice(0, 4).map(entry => pointLabel(entry.item)).join(' / ')
459
- const supportCount = payload?.supportSources?.length ?? 0
460
- return `phase=${payload?.phase ?? '—'} · via=${payload?.via ?? '—'} · count=${payload?.count ?? 0} · 来源 ${sourceNames || '—'} · 节点 ${payload?.items?.length ?? 0}${supportCount ? `(补端点 ${supportCount})` : ''} / 线路 ${payload?.lines?.length ?? 0} / 通道 ${payload?.channels?.length ?? 0}`
461
- }
462
-
381
+ const payload = pendingMerge.value
382
+ const impact = payload?.impact
383
+ if (!impact) {
384
+ mergeDetailText.value = '合并影响详情:当前没有可用的影响载荷'
385
+ return mergeDetailText.value
386
+ }
387
+ const lines = impact.lineIds.map(id => {
388
+ const line = view?.getLine(id)
389
+ if (!line) {
390
+ return `${id}(已不存在)`
391
+ }
392
+ return `${id}(${line.item?.name ?? '未命名'}:${line.item?.from ?? '—'}→${line.item?.to ?? '—'})`
393
+ })
394
+ const channels = impact.channelIds.map(id => {
395
+ const channel = view?.getChannel(id)
396
+ if (!channel) {
397
+ return `${id}(已不存在)`
398
+ }
399
+ return `${id}(${channel.item?.name ?? '未命名'}:${channel.lineIndexes.length} 回)`
400
+ })
401
+ mergeDetailText.value = `影响线路 ${lines.join(' / ') || '无'} · 影响通道 ${channels.join(' / ') || '无'}`
402
+ return mergeDetailText.value
403
+ }
404
+ const selectionKinds = computed(() => ['point', 'line', 'channel'].filter(kind => selectionFilters.value[kind]))
405
+ const selectionKindsText = computed(() => selectionKinds.value.length ? selectionKinds.value.join(' + ') : '全关')
406
+ const mergeTargetOptions = computed(() => selectedPoints.value.map(item => ({
407
+ value: pointKey(item),
408
+ label: `${pointLabel(item)}(${pointKey(item)})`
409
+ })))
410
+ const canMerge = computed(() => selectedPoints.value.length >= 2)
411
+ const copyToastTitle = computed(() => {
412
+ const payload = copyToast.value
413
+ if (!payload) return ''
414
+ if (payload.ok === false) return '复制没有落地'
415
+ if (payload.phase === 'clipboard') return '剪贴板快照已就绪'
416
+ if (payload.phase === 'batch') return payload.via === 'paste' ? 'Ctrl+V 整组粘贴已落地' : '整组复制已落地'
417
+ if (payload.phase === 'end') return payload.via === 'paste' ? 'Ctrl+V 已粘贴' : 'Alt 复制已落地'
418
+ if (payload.phase === 'commit') return '复制正在提交'
419
+ return '复制预览中'
420
+ })
421
+ const copyToastText = computed(() => {
422
+ const payload = copyToast.value
423
+ if (!payload) return ''
424
+ const sources = payload.sources ?? (payload.source ? [payload.source] : [])
425
+ const items = payload.items ?? (payload.item ? [payload.item] : [])
426
+ const sourceText = sources.slice(0, 4).map(entry => pointLabel(entry.item)).join(' / ') || '—'
427
+ const itemText = items.slice(0, 4).map(pointLabel).join(' / ') || '—'
428
+ const supportCount = payload.supportSources?.length ?? sources.filter(entry => entry.support).length
429
+ return `来源 ${sourceText}${supportCount ? `(含 ${supportCount} 个补端点)` : ''} → 结果 ${itemText} · 线路 ${payload.lines?.length ?? 0} / 通道 ${payload.channels?.length ?? 0}`
430
+ })
431
+
432
+ const pushLog = (name, text) => {
433
+ logSeq += 1
434
+ logLines.value = [{ id: logSeq, name, text }, ...logLines.value].slice(0, 18)
435
+ }
436
+
437
+ const focusCanvas = () => nextTick(() => {
438
+ container.value?.querySelector('.gis-view')?.focus({ preventScroll: true })
439
+ })
440
+
441
+ const refreshState = () => {
442
+ if (!view) return
443
+ const data = view.getData()
444
+ pointOptions.value = data.points.map((item, index) => ({
445
+ value: pointKey(item) || String(index),
446
+ label: `${pointLabel(item)}(${pointKey(item)})`
447
+ }))
448
+ const selection = view.getSelectionData()
449
+ selectedPoints.value = selection.points ?? []
450
+ selectionCounts.value = { points: selection.pointCount, lines: selection.lineCount, channels: selection.channelCount }
451
+ selectionText.value = selection.count
452
+ ? `${selection.pointCount} 节点 / ${selection.lineCount} 线路 / ${selection.channelCount} 通道:${(selection.items ?? []).slice(0, 5).map(entry => pointLabel(entry.item)).join(' / ')}`
453
+ : '未选中'
454
+ if (!mergeTargetOptions.value.some(item => item.value === mergeTargetId.value)) {
455
+ mergeTargetId.value = mergeTargetOptions.value[0]?.value ?? ''
456
+ }
457
+ }
458
+
459
+ const describeBatchCopy = payload => {
460
+ const sourceNames = (payload?.sources ?? []).slice(0, 4).map(entry => pointLabel(entry.item)).join(' / ')
461
+ const supportCount = payload?.supportSources?.length ?? 0
462
+ return `phase=${payload?.phase ?? '—'} · via=${payload?.via ?? '—'} · count=${payload?.count ?? 0} · 来源 ${sourceNames || '—'} · 节点 ${payload?.items?.length ?? 0}${supportCount ? `(补端点 ${supportCount})` : ''} / 线路 ${payload?.lines?.length ?? 0} / 通道 ${payload?.channels?.length ?? 0}`
463
+ }
464
+
463
465
  const rememberCopyPayload = payload => {
464
466
  copyEvent.value = payload
465
467
  copyLineChannelsText.value = describeLineChannels(payload)
466
468
  const sources = payload.sources ?? (payload.source ? [payload.source] : [])
467
- const items = payload.items ?? (payload.item ? [payload.item] : [])
468
- copyEventText.value = `phase=${payload.phase ?? '—'} · via=${payload.via ?? '—'} · count=${payload.count ?? items.length}`
469
- copySourcesText.value = sources.length
470
- ? sources.map(entry => `${pointLabel(entry.item)}(${pointKey(entry.item)}${entry.support ? ' · 补端点' : ''})`).join(' / ')
471
- : '—'
472
- copyItemsText.value = items.length ? items.map(pointLabel).join(' / ') : '—'
473
- }
474
-
475
- const handlePointCopy = (payload) => {
476
- rememberCopyPayload(payload)
477
- if (payload.phase === 'batch') {
478
- if (payload.ok === false) {
479
- copyText.value = `批量复制失败:${payload.reason}`
480
- actionText.value = copyText.value
481
- copyToast.value = payload
482
- pushLog('pointCopy/batch', copyText.value)
483
- return
484
- }
485
- lastBatch.value = payload
486
- copyText.value = `${payload.via === 'paste' ? 'pastePoints' : 'copySelectedPoints'}:${describeBatchCopy(payload)}`
487
- actionText.value = copyText.value
469
+ const items = payload.items ?? (payload.item ? [payload.item] : [])
470
+ copyEventText.value = `phase=${payload.phase ?? '—'} · via=${payload.via ?? '—'} · count=${payload.count ?? items.length}`
471
+ copySourcesText.value = sources.length
472
+ ? sources.map(entry => `${pointLabel(entry.item)}(${pointKey(entry.item)}${entry.support ? ' · 补端点' : ''})`).join(' / ')
473
+ : '—'
474
+ copyItemsText.value = items.length ? items.map(pointLabel).join(' / ') : '—'
475
+ }
476
+
477
+ const handlePointCopy = (payload) => {
478
+ rememberCopyPayload(payload)
479
+ if (payload.phase === 'batch') {
480
+ if (payload.ok === false) {
481
+ copyText.value = `批量复制失败:${payload.reason}`
482
+ actionText.value = copyText.value
483
+ copyToast.value = payload
484
+ pushLog('pointCopy/batch', copyText.value)
485
+ return
486
+ }
487
+ lastBatch.value = payload
488
+ copyText.value = `${payload.via === 'paste' ? 'pastePoints' : 'copySelectedPoints'}:${describeBatchCopy(payload)}`
489
+ actionText.value = copyText.value
488
490
  copyToast.value = payload
489
491
  pushLog('pointCopy/batch', copyText.value)
490
492
  refreshState()
491
493
  startCopyBackend(payload)
492
494
  return
493
- }
494
- if (payload.phase === 'clipboard') {
495
- clipboardItem.value = view?.getPointClipboard() ?? null
496
- clipboardText.value = `剪贴板:count=${payload.count ?? 0} · 节点 ${payload.items?.length ?? 0} / 线路 ${payload.lines?.length ?? 0} / 通道 ${payload.channels?.length ?? 0} · sources=${(payload.sources ?? []).map(entry => pointLabel(entry.item)).join(' / ')}`
497
- actionText.value = clipboardText.value
498
- copyToast.value = payload
499
- pushLog('pointCopy/clipboard', clipboardText.value)
500
- return
501
- }
502
- if (payload.phase === 'start' || payload.phase === 'commit') {
503
- copyText.value = `复制预览 · 来源 ${pointLabel(payload.source?.item)} → ${pointLabel(payload.item)}`
504
- copyToast.value = payload
505
- pushLog(`pointCopy/${payload.phase}`, copyText.value)
506
- return
507
- }
508
- if (payload.phase === 'end') {
509
- copyText.value = `复制落地 · ${payload.via} · 来源 ${pointLabel(payload.source?.item)} → ${pointLabel(payload.item)}(${pointKey(payload.item)})`
510
- actionText.value = copyText.value
495
+ }
496
+ if (payload.phase === 'clipboard') {
497
+ clipboardItem.value = view?.getPointClipboard() ?? null
498
+ clipboardText.value = `剪贴板:count=${payload.count ?? 0} · 节点 ${payload.items?.length ?? 0} / 线路 ${payload.lines?.length ?? 0} / 通道 ${payload.channels?.length ?? 0} · sources=${(payload.sources ?? []).map(entry => pointLabel(entry.item)).join(' / ')}`
499
+ actionText.value = clipboardText.value
500
+ copyToast.value = payload
501
+ pushLog('pointCopy/clipboard', clipboardText.value)
502
+ return
503
+ }
504
+ if (payload.phase === 'start' || payload.phase === 'commit') {
505
+ copyText.value = `复制预览 · 来源 ${pointLabel(payload.source?.item)} → ${pointLabel(payload.item)}`
506
+ copyToast.value = payload
507
+ pushLog(`pointCopy/${payload.phase}`, copyText.value)
508
+ return
509
+ }
510
+ if (payload.phase === 'end') {
511
+ copyText.value = `复制落地 · ${payload.via} · 来源 ${pointLabel(payload.source?.item)} → ${pointLabel(payload.item)}(${pointKey(payload.item)})`
512
+ actionText.value = copyText.value
511
513
  copyToast.value = payload
512
514
  pushLog('pointCopy/end', copyText.value)
513
515
  refreshState()
514
516
  startCopyBackend(payload)
515
517
  return
516
- }
517
- if (payload.phase === 'cancel') {
518
- copyText.value = payload.invalidated ? '复制作废:数据已换过' : '复制已取消,预览没有落地'
519
- actionText.value = copyText.value
520
- copyToast.value = null
521
- pushLog('pointCopy/cancel', copyText.value)
522
- }
523
- }
524
-
525
- const sourceIds = () => {
526
- const points = view?.getData().points ?? []
527
- const indexes = [0, 7, 12, 5]
528
- const picked = indexes.map(index => points[index % points.length]?.id).filter(id => id !== undefined && id !== null)
529
- return [...new Set(picked)]
530
- }
531
-
532
- const selectBatchSources = () => {
533
- const ids = sourceIds()
534
- view.select(ids)
535
- refreshState()
536
- actionText.value = `已选中 ${ids.length} 个来源:${ids.join(' / ')}(画布上也已高亮)`
537
- pushLog('select', actionText.value)
538
- return actionText.value
539
- }
540
-
541
- const clearSelection = () => {
542
- view.select(null)
543
- refreshState()
544
- actionText.value = '已清空选中;现在可拖动单个节点撞向另一个节点'
545
- return actionText.value
546
- }
547
-
548
- const applySelectionKinds = () => {
549
- view?.updateOptions({ selectionKinds: selectionKinds.value })
550
- actionText.value = `框选 / Ctrl 多选类型:${selectionKindsText.value}`
551
- pushLog('selectionKinds', actionText.value)
552
- return actionText.value
553
- }
554
-
555
- const selectSampleLine = () => {
556
- view.selectFeatures({ type: 'line', index: 0 })
557
- refreshState()
558
- actionText.value = '已选中示例独立线路(不含两端节点);复制会自动补两个端点节点并带上线路'
559
- pushLog('select/line', actionText.value)
560
- return actionText.value
561
- }
562
-
563
- const selectSampleChannel = () => {
564
- view.selectFeatures({ type: 'channel', channelId: 'channel-a' })
565
- refreshState()
566
- actionText.value = '已选中甲通道整条(全部回线);复制会同时补端点、线路与通道'
567
- pushLog('select/channel', actionText.value)
568
- return actionText.value
569
- }
570
-
571
- const ensureBatchSelection = () => {
572
- const current = view?.getSelectionData().points ?? []
573
- if (current.length) return current
574
- view.select(sourceIds())
575
- refreshState()
576
- return selectedPoints.value
577
- }
578
-
579
- const copySelectedByApi = () => {
580
- if (!view.getSelectionData().count) ensureBatchSelection()
581
- copyRound += 1
582
- const result = view.copySelectedPoints({
583
- offset: [64 + copyRound * 12, 46 + copyRound * 8],
584
- select: true,
585
- label: `demo 框选复制 ${copyRound}`
586
- })
587
- if (result.ok) {
588
- lastBatch.value = result
589
- copyText.value = `copySelectedPoints:${describeBatchCopy(result)}`
590
- actionText.value = copyText.value
591
- }
592
- else {
593
- copyText.value = `copySelectedPoints 失败:${result.reason}`
594
- actionText.value = copyText.value
595
- }
596
- refreshState()
597
- return copyText.value
598
- }
599
-
600
- const copyPointsByApi = () => {
601
- copyRound += 1
602
- const result = view.copyPoints(sourceIds(), {
603
- offset: [74 + copyRound * 12, 58 + copyRound * 8],
604
- select: true,
605
- label: `demo copyPoints ${copyRound}`
606
- })
607
- if (result.ok) {
608
- lastBatch.value = result
609
- copyText.value = `copyPoints:${describeBatchCopy(result)}`
610
- actionText.value = copyText.value
611
- }
612
- else {
613
- copyText.value = `copyPoints 失败:${result.reason}`
614
- actionText.value = copyText.value
615
- }
616
- refreshState()
617
- return copyText.value
618
- }
619
-
620
- const copyClipboardByApi = () => {
621
- if (!view.getSelectionData().count) ensureBatchSelection()
622
- const item = view.copyPointsToClipboard(undefined, { via: 'demo-copy-clipboard' })
623
- clipboardItem.value = item
624
- clipboardText.value = item
625
- ? `剪贴板:count=${item.count} · 节点 ${item.items.length}${item.supportSources?.length ? `(补端点 ${item.supportSources.length})` : ''} / 线路 ${item.lines.length} / 通道 ${item.channels.length} · sources=${item.sources.map(entry => `${pointLabel(entry.item)}${entry.support ? '·补端点' : ''}`).join(' / ')}`
626
- : '剪贴板为空'
627
- actionText.value = item ? `${clipboardText.value};焦点已交给画布,按 Ctrl+V 或点 pastePoints` : '剪贴板为空'
628
- focusCanvas()
629
- refreshState()
630
- return clipboardText.value
631
- }
632
-
633
- const pasteFromClipboard = () => {
634
- clipboardItem.value = view.getPointClipboard() ?? clipboardItem.value
635
- if (!clipboardItem.value) return 'pastePoints:先复制到剪贴板'
636
- pasteRound += 1
637
- const anchor = clipboardItem.value.anchor ?? [0, 0]
638
- const result = view.pastePoints({
639
- coordinate: [anchor[0] + 1.05 + pasteRound * 0.18, anchor[1] - 0.72 - pasteRound * 0.12],
640
- select: true,
641
- label: `demo 粘贴 ${pasteRound}`
642
- })
643
- if (result?.ok) {
644
- lastBatch.value = result
645
- copyText.value = `pastePoints:${describeBatchCopy(result)}`
646
- actionText.value = copyText.value
647
- }
648
- else {
649
- copyText.value = `pastePoints 失败:${result?.reason ?? '剪贴板为空或复制已关闭'}`
650
- actionText.value = copyText.value
651
- }
652
- refreshState()
653
- return copyText.value
654
- }
655
-
656
- const revertLastCopy = () => {
657
- const payload = copyToast.value ?? copyEvent.value
658
- if (!payload) return '没有可取消 / 回滚的复制'
659
- let message = '复制已经不在数据里'
660
- if (payload.phase === 'clipboard') {
661
- payload.cancel?.()
662
- clipboardItem.value = null
663
- message = '已清空剪贴板'
664
- }
665
- else if (typeof payload.revert === 'function') {
666
- const outcome = payload.revert()
667
- message = outcome?.ok
668
- ? `${payload.phase === 'batch' ? '已撤销整组复制' : '已回滚本次复制'} · 收回 ${outcome.items?.length ?? outcome.count ?? 0} 个节点`
669
- : '复制已经不在数据里'
670
- }
671
- else if (typeof payload.cancel === 'function') {
672
- payload.cancel()
673
- message = '已取消这次复制预览,数据没有落地'
674
- }
675
- copyToast.value = null
676
- if (lastBatch.value === payload) lastBatch.value = null
677
- actionText.value = message
678
- pushLog('copy/revert', message)
679
- refreshState()
680
- return message
681
- }
682
-
683
- const manualAddPointCopy = () => {
684
- const previous = lastBatch.value ?? (copyToast.value?.phase === 'batch' || copyToast.value?.phase === 'end' ? copyToast.value : null)
685
- let reverted = 0
686
- if (previous && typeof previous.revert === 'function') {
687
- const outcome = previous.revert()
688
- reverted = outcome?.items?.length ?? outcome?.count ?? 0
689
- if (lastBatch.value === previous) lastBatch.value = null
690
- if (copyToast.value === previous) copyToast.value = null
691
- }
692
- if (!view.getSelectionData().pointCount) ensureBatchSelection()
693
- if (!view.getSelectionData().pointCount) return '手动 addPoint 只接管节点复制:先选中至少一个节点'
694
- addPointRound += 1
695
- // 先让内置复制算出一份真实落点(同时不带上选中线路 / 通道),拿到 items 后立刻整批撤销,
696
- // 再按这些落点逐个 addPoint:最终数据里只有手动副本,不会残留自动副本。
697
- const probe = view.copySelectedPoints({
698
- offset: [84 + addPointRound * 10, 54 + addPointRound * 8],
699
- withLines: false,
700
- withChannels: false,
701
- select: false,
702
- via: 'manual-addpoint-probe',
703
- label: 'demo 手动复制落点'
704
- })
705
- if (!probe?.ok) {
706
- manualText.value = `手动 addPoint 准备失败:${probe?.reason ?? '复制没有落地'}`
707
- actionText.value = manualText.value
708
- return manualText.value
709
- }
710
- const placements = probe.items.map(item => ({ ...item }))
711
- const probeSources = probe.sources.filter(entry => !entry.support)
712
- probe.revert()
713
- lastBatch.value = null
714
- copyToast.value = null
715
- const created = []
716
- for (let index = 0; index < placements.length; index += 1) {
717
- const placement = placements[index]
718
- const source = probeSources[index]
719
- const { id: _oldId, ...rest } = placement
720
- const result = view.addPoint({
721
- ...rest,
722
- id: `addpoint-copy-${addPointRound}-${index + 1}`,
723
- name: `${pointLabel(source?.item ?? placement)} · addPoint 副本`,
724
- zone_name: 'addPoint 手动复制',
725
- demo_copy: true,
726
- demo_copy_via: 'addPoint'
727
- })
728
- if (result.ok && result.item) created.push(result.item)
729
- }
730
- view.select(created.map(item => item.id))
731
- manualText.value = `addPoint ×${created.length}:${created.map(item => `${pointLabel(item)}(${pointKey(item)})`).join(' / ') || '没有落地'}${reverted ? ` · 已删自动副本 ${reverted} 个` : ''} · 最终只多一份`
732
- actionText.value = manualText.value
733
- pushLog('addPoint', manualText.value)
734
- refreshState()
735
- return manualText.value
736
- }
737
-
518
+ }
519
+ if (payload.phase === 'cancel') {
520
+ copyText.value = payload.invalidated ? '复制作废:数据已换过' : '复制已取消,预览没有落地'
521
+ actionText.value = copyText.value
522
+ copyToast.value = null
523
+ pushLog('pointCopy/cancel', copyText.value)
524
+ }
525
+ }
526
+
527
+ const sourceIds = () => {
528
+ const points = view?.getData().points ?? []
529
+ const indexes = [0, 7, 12, 5]
530
+ const picked = indexes.map(index => points[index % points.length]?.id).filter(id => id !== undefined && id !== null)
531
+ return [...new Set(picked)]
532
+ }
533
+
534
+ const selectBatchSources = () => {
535
+ const ids = sourceIds()
536
+ view.selectFeatures(ids)
537
+ refreshState()
538
+ actionText.value = `已选中 ${ids.length} 个来源:${ids.join(' / ')}(画布上也已高亮)`
539
+ pushLog('select', actionText.value)
540
+ return actionText.value
541
+ }
542
+
543
+ const clearSelection = () => {
544
+ view.selectFeatures(null)
545
+ refreshState()
546
+ actionText.value = '已清空选中;现在可拖动单个节点撞向另一个节点'
547
+ return actionText.value
548
+ }
549
+
550
+ const applySelectionKinds = () => {
551
+ view?.updateOptions({ selectionKinds: selectionKinds.value })
552
+ actionText.value = `框选 / Ctrl 多选类型:${selectionKindsText.value}`
553
+ pushLog('selectionKinds', actionText.value)
554
+ return actionText.value
555
+ }
556
+
557
+ const selectSampleLine = () => {
558
+ view.selectFeatures({ type: 'line', index: 0 })
559
+ refreshState()
560
+ actionText.value = '已选中示例独立线路(不含两端节点);复制会自动补两个端点节点并带上线路'
561
+ pushLog('select/line', actionText.value)
562
+ return actionText.value
563
+ }
564
+
565
+ const selectSampleChannel = () => {
566
+ view.selectFeatures({ type: 'channel', channelId: 'channel-a' })
567
+ refreshState()
568
+ actionText.value = '已选中甲通道整条(全部回线);复制会同时补端点、线路与通道'
569
+ pushLog('select/channel', actionText.value)
570
+ return actionText.value
571
+ }
572
+
573
+ const ensureBatchSelection = () => {
574
+ const current = view?.getSelectionData().points ?? []
575
+ if (current.length) return current
576
+ view.selectFeatures(sourceIds())
577
+ refreshState()
578
+ return selectedPoints.value
579
+ }
580
+
581
+ const copySelectedByApi = () => {
582
+ if (!view.getSelectionData().count) ensureBatchSelection()
583
+ copyRound += 1
584
+ const result = view.copySelectedPoints({
585
+ offset: [64 + copyRound * 12, 46 + copyRound * 8],
586
+ select: true,
587
+ label: `demo 框选复制 ${copyRound}`
588
+ })
589
+ if (result.ok) {
590
+ lastBatch.value = result
591
+ copyText.value = `copySelectedPoints:${describeBatchCopy(result)}`
592
+ actionText.value = copyText.value
593
+ }
594
+ else {
595
+ copyText.value = `copySelectedPoints 失败:${result.reason}`
596
+ actionText.value = copyText.value
597
+ }
598
+ refreshState()
599
+ return copyText.value
600
+ }
601
+
602
+ const copyPointsByApi = () => {
603
+ copyRound += 1
604
+ const result = view.copyPoints(sourceIds(), {
605
+ offset: [74 + copyRound * 12, 58 + copyRound * 8],
606
+ select: true,
607
+ label: `demo copyPoints ${copyRound}`
608
+ })
609
+ if (result.ok) {
610
+ lastBatch.value = result
611
+ copyText.value = `copyPoints:${describeBatchCopy(result)}`
612
+ actionText.value = copyText.value
613
+ }
614
+ else {
615
+ copyText.value = `copyPoints 失败:${result.reason}`
616
+ actionText.value = copyText.value
617
+ }
618
+ refreshState()
619
+ return copyText.value
620
+ }
621
+
622
+ const copyClipboardByApi = () => {
623
+ if (!view.getSelectionData().count) ensureBatchSelection()
624
+ const item = view.copyPointsToClipboard(undefined, { via: 'demo-copy-clipboard' })
625
+ clipboardItem.value = item
626
+ clipboardText.value = item
627
+ ? `剪贴板:count=${item.count} · 节点 ${item.items.length}${item.supportSources?.length ? `(补端点 ${item.supportSources.length})` : ''} / 线路 ${item.lines.length} / 通道 ${item.channels.length} · sources=${item.sources.map(entry => `${pointLabel(entry.item)}${entry.support ? '·补端点' : ''}`).join(' / ')}`
628
+ : '剪贴板为空'
629
+ actionText.value = item ? `${clipboardText.value};焦点已交给画布,按 Ctrl+V 或点 pastePoints` : '剪贴板为空'
630
+ focusCanvas()
631
+ refreshState()
632
+ return clipboardText.value
633
+ }
634
+
635
+ const pasteFromClipboard = () => {
636
+ clipboardItem.value = view.getPointClipboard() ?? clipboardItem.value
637
+ if (!clipboardItem.value) return 'pastePoints:先复制到剪贴板'
638
+ pasteRound += 1
639
+ const anchor = clipboardItem.value.anchor ?? [0, 0]
640
+ const result = view.pastePoints({
641
+ coordinate: [anchor[0] + 1.05 + pasteRound * 0.18, anchor[1] - 0.72 - pasteRound * 0.12],
642
+ select: true,
643
+ label: `demo 粘贴 ${pasteRound}`
644
+ })
645
+ if (result?.ok) {
646
+ lastBatch.value = result
647
+ copyText.value = `pastePoints:${describeBatchCopy(result)}`
648
+ actionText.value = copyText.value
649
+ }
650
+ else {
651
+ copyText.value = `pastePoints 失败:${result?.reason ?? '剪贴板为空或复制已关闭'}`
652
+ actionText.value = copyText.value
653
+ }
654
+ refreshState()
655
+ return copyText.value
656
+ }
657
+
658
+ const revertLastCopy = () => {
659
+ const payload = copyToast.value ?? copyEvent.value
660
+ if (!payload) return '没有可取消 / 回滚的复制'
661
+ let message = '复制已经不在数据里'
662
+ if (payload.phase === 'clipboard') {
663
+ payload.cancel?.()
664
+ clipboardItem.value = null
665
+ message = '已清空剪贴板'
666
+ }
667
+ else if (typeof payload.revert === 'function') {
668
+ const outcome = payload.revert()
669
+ message = outcome?.ok
670
+ ? `${payload.phase === 'batch' ? '已撤销整组复制' : '已回滚本次复制'} · 收回 ${outcome.items?.length ?? outcome.count ?? 0} 个节点`
671
+ : '复制已经不在数据里'
672
+ }
673
+ else if (typeof payload.cancel === 'function') {
674
+ payload.cancel()
675
+ message = '已取消这次复制预览,数据没有落地'
676
+ }
677
+ copyToast.value = null
678
+ if (lastBatch.value === payload) lastBatch.value = null
679
+ actionText.value = message
680
+ pushLog('copy/revert', message)
681
+ refreshState()
682
+ return message
683
+ }
684
+
685
+ const manualAddPointCopy = () => {
686
+ const previous = lastBatch.value ?? (copyToast.value?.phase === 'batch' || copyToast.value?.phase === 'end' ? copyToast.value : null)
687
+ let reverted = 0
688
+ if (previous && typeof previous.revert === 'function') {
689
+ const outcome = previous.revert()
690
+ reverted = outcome?.items?.length ?? outcome?.count ?? 0
691
+ if (lastBatch.value === previous) lastBatch.value = null
692
+ if (copyToast.value === previous) copyToast.value = null
693
+ }
694
+ if (!view.getSelectionData().pointCount) ensureBatchSelection()
695
+ if (!view.getSelectionData().pointCount) return '手动 addPoint 只接管节点复制:先选中至少一个节点'
696
+ addPointRound += 1
697
+ // 先让内置复制算出一份真实落点(同时不带上选中线路 / 通道),拿到 items 后立刻整批撤销,
698
+ // 再按这些落点逐个 addPoint:最终数据里只有手动副本,不会残留自动副本。
699
+ const probe = view.copySelectedPoints({
700
+ offset: [84 + addPointRound * 10, 54 + addPointRound * 8],
701
+ withLines: false,
702
+ withChannels: false,
703
+ select: false,
704
+ via: 'manual-addpoint-probe',
705
+ label: 'demo 手动复制落点'
706
+ })
707
+ if (!probe?.ok) {
708
+ manualText.value = `手动 addPoint 准备失败:${probe?.reason ?? '复制没有落地'}`
709
+ actionText.value = manualText.value
710
+ return manualText.value
711
+ }
712
+ const placements = probe.items.map(item => ({ ...item }))
713
+ const probeSources = probe.sources.filter(entry => !entry.support)
714
+ probe.revert()
715
+ lastBatch.value = null
716
+ copyToast.value = null
717
+ const created = []
718
+ for (let index = 0; index < placements.length; index += 1) {
719
+ const placement = placements[index]
720
+ const source = probeSources[index]
721
+ const { id: _oldId, ...rest } = placement
722
+ const result = view.addPoint({
723
+ ...rest,
724
+ id: `addpoint-copy-${addPointRound}-${index + 1}`,
725
+ name: `${pointLabel(source?.item ?? placement)} · addPoint 副本`,
726
+ zone_name: 'addPoint 手动复制',
727
+ demo_copy: true,
728
+ demo_copy_via: 'addPoint'
729
+ })
730
+ if (result.ok && result.item) created.push(result.item)
731
+ }
732
+ view.selectFeatures(created.map(item => item.id))
733
+ manualText.value = `addPoint ×${created.length}:${created.map(item => `${pointLabel(item)}(${pointKey(item)})`).join(' / ') || '没有落地'}${reverted ? ` · 已删自动副本 ${reverted} 个` : ''} · 最终只多一份`
734
+ actionText.value = manualText.value
735
+ pushLog('addPoint', manualText.value)
736
+ refreshState()
737
+ return manualText.value
738
+ }
739
+
738
740
  const prepareMerge = () => {
739
- if (!selectedPoints.value.length) selectBatchSources()
740
- if (!mergeTargetOptions.value.some(item => item.value === mergeTargetId.value)) {
741
- mergeTargetId.value = mergeTargetOptions.value[0]?.value ?? ''
742
- }
743
- refreshState()
744
- actionText.value = `已准备 ${selectedPoints.value.length} 个选中节点;保留目标 ${mergeTargetId.value || '—'},可在画布继续 Ctrl 点选`
745
- pushLog('merge/prepare', actionText.value)
741
+ if (!selectedPoints.value.length) selectBatchSources()
742
+ if (!mergeTargetOptions.value.some(item => item.value === mergeTargetId.value)) {
743
+ mergeTargetId.value = mergeTargetOptions.value[0]?.value ?? ''
744
+ }
745
+ refreshState()
746
+ actionText.value = `已准备 ${selectedPoints.value.length} 个选中节点;保留目标 ${mergeTargetId.value || '—'},可在画布继续 Ctrl 点选`
747
+ pushLog('merge/prepare', actionText.value)
746
748
  return actionText.value
747
749
  }
748
750
 
749
- const mergePatch = () => {
750
- const patch = {
751
- name: mergedName.value.trim() || '合并后节点',
752
- zone_name: mergedZone.value.trim() || '合并后(item 改写)',
753
- demo_merged: true
754
- }
755
- const voltage = Number(mergedVoltage.value)
756
- if (Number.isFinite(voltage)) patch.vn_kv = voltage
757
- const size = Number(mergedSize.value)
758
- if (Number.isFinite(size) && size > 0) patch.size = size
759
- if (mergedColor.value.trim()) patch.color = mergedColor.value.trim()
760
- if (mergedSymbol.value.trim()) patch.symbol = mergedSymbol.value.trim()
761
- if (mergedStrokeColor.value.trim()) patch.strokeColor = mergedStrokeColor.value.trim()
762
- return patch
763
- }
764
-
765
- const mergeSelectedByApi = () => {
766
- const points = selectedPoints.value
767
- if (points.length < 2) return 'mergeSelectedPoints:至少选中两个节点'
768
- const targetId = mergeTargetOptions.value.some(item => item.value === mergeTargetId.value)
769
- ? mergeTargetId.value
770
- : mergeTargetOptions.value[0].value
771
- const target = points.find(item => pointKey(item) === targetId)
772
- const sources = points.filter(item => pointKey(item) !== targetId).map(item => item.id)
773
- const result = view.mergeSelectedPoints({
774
- target: { id: target?.id ?? targetId },
775
- sources,
776
- item: mergePatch(),
777
- label: 'demo 合并选中节点'
778
- })
779
- if (result.ok) {
780
- view.select(target?.id ?? targetId)
781
- mergeText.value = `mergeSelectedPoints · 保留 ${pointLabel(result.target?.item)} · 并掉 ${result.sourcesMerged} 个 · 改线 ${result.linesChanged} / 删线 ${result.removedLines} / 并通道 ${result.mergedChannels ?? 0} · 最终 ${pointLabel(result.target?.item)}`
782
- pendingText.value = '这次走 API 直接合并,没有 pending 阶段'
783
- }
784
- else {
785
- mergeText.value = `mergeSelectedPoints 失败:${result.reason}`
751
+ /** 节点拆分:把连在来源节点上的线路隔一条分给第二份,通道端点默认跟随成员线路 */
752
+ const splitSelectedPoint = () => {
753
+ const data = view?.getData()
754
+ if (!data?.points?.length) return '没有可拆分的节点'
755
+ const source = selectedPoints.value[0] ?? data.points[0]
756
+ const linked = data.lines
757
+ .map((line, index) => ({ line, index }))
758
+ .filter(({ line }) => String(line.from) === String(source.id) || String(line.to) === String(source.id))
759
+ const movedLines = linked.filter((_entry, index) => index % 2 === 1).map(entry => entry.line.id ?? entry.index)
760
+ splitRound += 1
761
+ const offset = 0.42 + splitRound * 0.04
762
+ const result = view.splitPoint(source.id, [
763
+ {
764
+ x: Number(source.x) - offset,
765
+ y: Number(source.y) - offset * 0.55,
766
+ name: `${pointLabel(source)} · A`,
767
+ zone_name: '拆分后 · A'
768
+ },
769
+ {
770
+ x: Number(source.x) + offset,
771
+ y: Number(source.y) + offset * 0.55,
772
+ name: `${pointLabel(source)} · B`,
773
+ zone_name: '拆分后 · B',
774
+ lines: movedLines
775
+ }
776
+ ])
777
+ if (!result.ok) {
778
+ splitText.value = `拆分失败:${result.reason}`
779
+ actionText.value = splitText.value
780
+ pushLog('splitPoint', splitText.value)
781
+ return splitText.value
786
782
  }
787
- actionText.value = mergeText.value
788
- pushLog('mergeSelectedPoints', mergeText.value)
783
+ splitText.value = `splitPoint:${pointLabel(source)} → ${result.pointIds.join(' / ')} · 改线 ${result.linesChanged} / 删线 ${result.removedLines} · 改通道 ${result.channelsChanged} / 删通道 ${result.removedChannels} · 第 2 份接回线 ${movedLines.length} 条`
784
+ actionText.value = splitText.value
785
+ pushLog('splitPoint', splitText.value)
786
+ view.selectFeatures(result.pointIds)
789
787
  refreshState()
790
- return mergeText.value
791
- }
792
-
793
- const handlePointMerge = (payload) => {
788
+ return splitText.value
789
+ }
790
+
791
+ const mergePatch = () => {
792
+ const patch = {
793
+ name: mergedName.value.trim() || '合并后节点',
794
+ zone_name: mergedZone.value.trim() || '合并后(item 改写)',
795
+ demo_merged: true
796
+ }
797
+ const voltage = Number(mergedVoltage.value)
798
+ if (Number.isFinite(voltage)) patch.vn_kv = voltage
799
+ const size = Number(mergedSize.value)
800
+ if (Number.isFinite(size) && size > 0) patch.size = size
801
+ if (mergedColor.value.trim()) patch.color = mergedColor.value.trim()
802
+ if (mergedSymbol.value.trim()) patch.symbol = mergedSymbol.value.trim()
803
+ if (mergedStrokeColor.value.trim()) patch.strokeColor = mergedStrokeColor.value.trim()
804
+ return patch
805
+ }
806
+
807
+ const mergeSelectedByApi = () => {
808
+ const points = selectedPoints.value
809
+ if (points.length < 2) return 'mergeSelectedPoints:至少选中两个节点'
810
+ const targetId = mergeTargetOptions.value.some(item => item.value === mergeTargetId.value)
811
+ ? mergeTargetId.value
812
+ : mergeTargetOptions.value[0].value
813
+ const target = points.find(item => pointKey(item) === targetId)
814
+ const sources = points.filter(item => pointKey(item) !== targetId).map(item => item.id)
815
+ const result = view.mergeSelectedPoints({
816
+ target: { id: target?.id ?? targetId },
817
+ sources,
818
+ item: mergePatch(),
819
+ label: 'demo 合并选中节点'
820
+ })
821
+ if (result.ok) {
822
+ view.selectFeatures(target?.id ?? targetId)
823
+ mergeText.value = `mergeSelectedPoints · 保留 ${pointLabel(result.target?.item)} · 并掉 ${result.sourcesMerged} 个 · 改线 ${result.linesChanged} / 删线 ${result.removedLines} / 并通道 ${result.mergedChannels ?? 0} · 最终 ${pointLabel(result.target?.item)}`
824
+ pendingText.value = '这次走 API 直接合并,没有 pending 阶段'
825
+ }
826
+ else {
827
+ mergeText.value = `mergeSelectedPoints 失败:${result.reason}`
828
+ }
829
+ actionText.value = mergeText.value
830
+ pushLog('mergeSelectedPoints', mergeText.value)
831
+ refreshState()
832
+ return mergeText.value
833
+ }
834
+
835
+ const handlePointMerge = (payload) => {
794
836
  if (payload.phase === 'pending') {
795
837
  pendingMerge.value = payload
796
838
  mergeBackendText.value = '合并变更 → 后端事务:数据还没动;确认落地后 changes 会给出完整事务入参'
797
- mergeDetailText.value = '合并影响详情:点「查看影响详情」按 id 调 getLine / getChannel'
798
- pendingText.value = `待确认:拖动来源 ${pointLabel(payload.source.item)} → 碰撞目标 ${pointLabel(payload.target.item)} · ${describeMergeImpact(payload.impact)} · 数据还没动`
799
- mergeText.value = pendingText.value
800
- actionText.value = mergeText.value
801
- pushLog('pointMerge/pending', pendingText.value)
802
- return
803
- }
804
- if (payload.phase === 'confirm') {
805
- if (rewriteOnConfirm.value && payload.item) {
806
- payload.item = { ...payload.item, ...mergePatch() }
807
- }
808
- pendingMerge.value = payload
809
- mergeText.value = `confirm · 保留 ${payload.keep === 'source' ? '拖动来源' : '碰撞目标'} · ${describeMergeImpact(payload.impact)} · 来源 ${pointLabel(payload.source.item)} / 目标 ${pointLabel(payload.target.item)} · 最终节点 ${pointLabel(payload.item)} · 色 ${payload.item?.color ?? '—'} / 尺寸 ${payload.item?.size ?? '—'} / 图标 ${String(payload.item?.symbol ?? '—')}`
810
- actionText.value = mergeText.value
811
- pushLog('pointMerge/confirm', mergeText.value)
812
- return
813
- }
839
+ mergeDetailText.value = '合并影响详情:点「查看影响详情」按 id 调 getLine / getChannel'
840
+ pendingText.value = `待确认:拖动来源 ${pointLabel(payload.source.item)} → 碰撞目标 ${pointLabel(payload.target.item)} · ${describeMergeImpact(payload.impact)} · 数据还没动`
841
+ mergeText.value = pendingText.value
842
+ actionText.value = mergeText.value
843
+ pushLog('pointMerge/pending', pendingText.value)
844
+ return
845
+ }
846
+ if (payload.phase === 'confirm') {
847
+ if (rewriteOnConfirm.value && payload.item) {
848
+ payload.item = { ...payload.item, ...mergePatch() }
849
+ }
850
+ pendingMerge.value = payload
851
+ mergeText.value = `confirm · 保留 ${payload.keep === 'source' ? '拖动来源' : '碰撞目标'} · ${describeMergeImpact(payload.impact)} · 来源 ${pointLabel(payload.source.item)} / 目标 ${pointLabel(payload.target.item)} · 最终节点 ${pointLabel(payload.item)} · 色 ${payload.item?.color ?? '—'} / 尺寸 ${payload.item?.size ?? '—'} / 图标 ${String(payload.item?.symbol ?? '—')}`
852
+ actionText.value = mergeText.value
853
+ pushLog('pointMerge/confirm', mergeText.value)
854
+ return
855
+ }
814
856
  if (payload.phase === 'mergeSelected') {
815
857
  const mergedNames = (payload.merged ?? []).map(result => pointLabel(result.source.item)).join(' / ') || '—'
816
858
  mergeBackendText.value = describeMergeChangeSets((payload.merged ?? []).map(result => result.changes))
817
859
  mergeText.value = payload.ok
818
- ? `mergeSelected · 保留 ${pointLabel(payload.target?.item)} · 并掉 ${mergedNames} · 最终 ${pointLabel(payload.target?.item)} · 改线 ${payload.linesChanged} / 删线 ${payload.removedLines} / 通道 ${payload.channelsChanged}`
819
- : `mergeSelected 失败:${payload.reason}`
860
+ ? `mergeSelected · 保留 ${pointLabel(payload.target?.item)} · 并掉 ${mergedNames} · 最终 ${pointLabel(payload.target?.item)} · 改线 ${payload.linesChanged} / 删线 ${payload.removedLines} / 通道 ${payload.channelsChanged}`
861
+ : `mergeSelected 失败:${payload.reason}`
820
862
  actionText.value = mergeText.value
821
863
  pushLog('pointMerge/mergeSelected', mergeText.value)
822
864
  cancelScheduledMergeBackend()
@@ -826,337 +868,346 @@ const handlePointMerge = (payload) => {
826
868
  if (payload.phase === 'merge') {
827
869
  mergeBackendText.value = payload.ok ? describeMergeChanges(payload.changes) : '合并变更 → 后端事务:合并失败,没有变更集'
828
870
  mergeText.value = payload.ok
829
- ? `merge · 被并 ${pointLabel(payload.source?.item)} → 保留 ${pointLabel(payload.target?.item)} · 最终 ${pointLabel(payload.item)} · 色 ${payload.item?.color ?? '—'} / 尺寸 ${payload.item?.size ?? '—'} / 图标 ${String(payload.item?.symbol ?? '—')} · 改线 ${payload.linesChanged} / 删线 ${payload.removedLines}`
830
- : `merge 失败:${payload.reason}`
831
- pendingMerge.value = null
832
- pendingText.value = payload.ok ? '合并已落地,拖动来源节点已经收进保留目标' : `合并失败:${payload.reason}`
871
+ ? `merge · 被并 ${pointLabel(payload.source?.item)} → 保留 ${pointLabel(payload.target?.item)} · 最终 ${pointLabel(payload.item)} · 色 ${payload.item?.color ?? '—'} / 尺寸 ${payload.item?.size ?? '—'} / 图标 ${String(payload.item?.symbol ?? '—')} · 改线 ${payload.linesChanged} / 删线 ${payload.removedLines}`
872
+ : `merge 失败:${payload.reason}`
873
+ pendingMerge.value = null
874
+ pendingText.value = payload.ok ? '合并已落地,拖动来源节点已经收进保留目标' : `合并失败:${payload.reason}`
833
875
  actionText.value = mergeText.value
834
876
  pushLog('pointMerge/merge', mergeText.value)
835
877
  refreshState()
836
878
  if (payload.ok) scheduleMergeBackend(payload)
837
879
  return
838
- }
880
+ }
839
881
  if (payload.phase === 'cancel') {
840
882
  pendingMerge.value = null
841
883
  mergeBackendText.value = '合并变更 → 后端事务:合并已取消,没有变更集'
842
- pendingText.value = '已取消:拖动的节点回到原位,数据没动'
843
- mergeText.value = pendingText.value
844
- actionText.value = mergeText.value
845
- pushLog('pointMerge/cancel', pendingText.value)
846
- }
847
- }
848
-
849
- const confirmPendingMerge = () => {
850
- const options = { keep: mergeKeep.value }
851
- if (rewriteOnConfirm.value) options.item = mergePatch()
852
- const result = view.confirmPointMerge(options)
853
- if (!result) return '当前没有待确认的合并'
854
- mergeText.value = `confirmPointMerge({ keep: '${mergeKeep.value}', item }) · ${rewriteOnConfirm.value ? '名称 / 分区 / 电压 / 样式已随 item 写回' : '使用引擎缺省合并结果'}`
855
- actionText.value = mergeText.value
856
- pushLog('confirmPointMerge', mergeText.value)
857
- return mergeText.value
858
- }
859
-
860
- const cancelPendingMerge = () => {
861
- const result = view.cancelPointMerge()
862
- pendingMerge.value = null
863
- return result ? '已取消:拖动的节点回到原位,数据没动' : '当前没有待确认的合并'
864
- }
865
-
866
- const resetScene = () => {
867
- const fresh = buildDemoData({ count: 14, channels: 2 })
868
- view.updateData({ points: fresh.points, lines: fresh.lines, channels: fresh.channels })
869
- view.select(null)
870
- view.clearHistory()
871
- view.fitView(50)
872
- lastBatch.value = null
873
- clipboardItem.value = null
874
- pendingMerge.value = null
875
- copyEvent.value = null
876
- copyToast.value = null
877
- pendingText.value = '拖动一个节点撞到另一个节点上,这里会出现待确认载荷'
884
+ pendingText.value = '已取消:拖动的节点回到原位,数据没动'
885
+ mergeText.value = pendingText.value
886
+ actionText.value = mergeText.value
887
+ pushLog('pointMerge/cancel', pendingText.value)
888
+ }
889
+ }
890
+
891
+ const confirmPendingMerge = () => {
892
+ const options = { keep: mergeKeep.value }
893
+ if (rewriteOnConfirm.value) options.item = mergePatch()
894
+ const result = view.resolvePointMerge(true, options)
895
+ if (!result) return '当前没有待确认的合并'
896
+ mergeText.value = `resolvePointMerge(true, { keep: '${mergeKeep.value}', item }) · ${rewriteOnConfirm.value ? '名称 / 分区 / 电压 / 样式已随 item 写回' : '使用引擎缺省合并结果'}`
897
+ actionText.value = mergeText.value
898
+ pushLog('resolvePointMerge(true)', mergeText.value)
899
+ return mergeText.value
900
+ }
901
+
902
+ const cancelPendingMerge = () => {
903
+ const result = view.resolvePointMerge(false)
904
+ pendingMerge.value = null
905
+ return result ? '已取消:拖动的节点回到原位,数据没动' : '当前没有待确认的合并'
906
+ }
907
+
908
+ const resetScene = () => {
909
+ const fresh = buildDemoData({ count: 14, channels: 2 })
910
+ view.updateData({ points: fresh.points, lines: fresh.lines, channels: fresh.channels })
911
+ view.selectFeatures(null)
912
+ view.clearHistory()
913
+ view.fitView(50)
914
+ lastBatch.value = null
915
+ clipboardItem.value = null
916
+ pendingMerge.value = null
917
+ copyEvent.value = null
918
+ copyToast.value = null
919
+ pendingText.value = '拖动一个节点撞到另一个节点上,这里会出现待确认载荷'
878
920
  mergeText.value = '合并:—'
879
921
  copyText.value = '复制:—'
880
922
  copyLineChannelsText.value = '—'
881
923
  mergeBackendText.value = '合并变更 → 后端事务:—'
882
- clipboardText.value = '剪贴板:—'
883
- copyEventText.value = '还没有 pointCopy 事件'
884
- copySourcesText.value = '—'
885
- copyItemsText.value = '—'
924
+ splitText.value = '拆分:先选中一个节点(不选就取第一个),再点拆分'
925
+ clipboardText.value = '剪贴板:—'
926
+ copyEventText.value = '还没有 pointCopy 事件'
927
+ copySourcesText.value = '—'
928
+ copyItemsText.value = '—'
886
929
  manualText.value = '—'
887
930
  mergeKeep.value = 'target'
888
931
  resetBackendDemo()
889
932
  copyRound = 0
890
- pasteRound = 0
933
+ pasteRound = 0
891
934
  addPointRound = 0
892
- refreshState()
893
- actionText.value = `已恢复 ${fresh.points.length} 节点 / ${fresh.lines.length} 线路 / ${fresh.channels.length} 通道`
894
- return actionText.value
895
- }
896
-
897
- onMounted(() => {
898
- const dataset = buildDemoData({ count: 14, channels: 2 })
899
- view = createGisView(container.value, {
900
- points: dataset.points,
901
- lines: dataset.lines,
902
- channels: dataset.channels,
903
- channelWidth: 2.2,
904
- channelInset: 1.3,
905
- pointStyle: { size: 9, color: '#ffffff', strokeColor: '#4c8dff', strokeWidth: 1.6 },
906
- lineStyle: { width: 1.5, color: 'rgba(120, 168, 226, 0.66)' },
907
- pointSelectStyle: { sizeScale: 1.5, color: '#ffd166', strokeColor: '#a33c00', strokeWidth: 3 },
908
- pointMultiSelectStyle: { sizeScale: 1.45, color: '#ffb703', strokeColor: '#7a2d00', strokeWidth: 2.5 },
909
- pointBoxSelectStyle: { sizeScale: 1.55, color: '#ff9f1c', strokeColor: '#7a2d00', strokeWidth: 3 },
910
- lineSelectStyle: { width: 3.2, color: '#ffb703' },
911
- lineMultiSelectStyle: { width: 2.6, color: '#ff9f1c' },
912
- selectionBoxStyle: { fill: '#3678bf', fillOpacity: 0.18, stroke: '#1d4ed8', strokeWidth: 2 },
913
- selectionOnTop: true,
914
- label: {
915
- content: '{name}',
916
- position: 'top',
917
- gap: 7,
918
- maxCount: 26,
919
- avoidOverlap: true,
920
- style: { color: '#27405f', fontSize: '11px' }
921
- },
922
- showGrid: true,
923
- fitPadding: 52,
924
- history: { limit: 60 },
925
- interactive: true,
926
- clickable: true,
927
- draggable: true,
928
- boxSelect: true,
929
- multiSelect: true,
930
- selectionKinds: ['point', 'line', 'channel'],
931
- channelSelectMode: 'channel',
932
- features: {
933
- pan: true,
934
- zoom: true,
935
- hover: true,
936
- select: true,
937
- boxSelect: true,
938
- drag: true,
939
- copy: true,
940
- merge: true,
941
- keyboard: true,
942
- waypoint: false,
943
- remove: false,
944
- channelDraft: false,
945
- channelMenu: false,
946
- contextMenu: false
947
- },
935
+ splitRound = 0
936
+ refreshState()
937
+ actionText.value = `已恢复 ${fresh.points.length} 节点 / ${fresh.lines.length} 线路 / ${fresh.channels.length} 通道`
938
+ return actionText.value
939
+ }
940
+
941
+ onMounted(() => {
942
+ const dataset = buildDemoData({ count: 14, channels: 2 })
943
+ view = createGisView(container.value, {
944
+ points: dataset.points,
945
+ lines: dataset.lines,
946
+ channels: dataset.channels,
947
+ channelWidth: 2.2,
948
+ channelInset: 1.3,
949
+ pointStyle: { size: 9, color: '#ffffff', strokeColor: '#4c8dff', strokeWidth: 1.6 },
950
+ lineStyle: { width: 1.5, color: 'rgba(120, 168, 226, 0.66)' },
951
+ pointSelectStyle: { sizeScale: 1.5, color: '#ffd166', strokeColor: '#a33c00', strokeWidth: 3 },
952
+ pointMultiSelectStyle: { sizeScale: 1.45, color: '#ffb703', strokeColor: '#7a2d00', strokeWidth: 2.5 },
953
+ pointBoxSelectStyle: { sizeScale: 1.55, color: '#ff9f1c', strokeColor: '#7a2d00', strokeWidth: 3 },
954
+ lineSelectStyle: { width: 3.2, color: '#ffb703' },
955
+ lineMultiSelectStyle: { width: 2.6, color: '#ff9f1c' },
956
+ selectionBoxStyle: { fill: '#3678bf', fillOpacity: 0.18, stroke: '#1d4ed8', strokeWidth: 2 },
957
+ selectionOnTop: true,
958
+ label: {
959
+ content: '{name}',
960
+ position: 'top',
961
+ gap: 7,
962
+ maxCount: 26,
963
+ avoidOverlap: true,
964
+ style: { color: '#27405f', fontSize: '11px' }
965
+ },
966
+ showGrid: true,
967
+ fitPadding: 52,
968
+ history: { limit: 60 },
969
+ interactive: true,
970
+ clickable: true,
971
+ draggable: true,
972
+ boxSelect: true,
973
+ multiSelect: true,
974
+ selectionKinds: ['point', 'line', 'channel'],
975
+ channelSelectMode: 'channel',
976
+ features: {
977
+ pan: true,
978
+ zoom: true,
979
+ hover: true,
980
+ select: true,
981
+ boxSelect: true,
982
+ drag: true,
983
+ copy: true,
984
+ merge: true,
985
+ keyboard: true,
986
+ waypoint: false,
987
+ remove: false,
988
+ channelDraft: false,
989
+ channelMenu: false,
990
+ contextMenu: false
991
+ },
948
992
  copy: {
949
- enabled: true,
950
- offset: [64, 46],
951
- createItem: (item, context) => ({
952
- ...item,
953
- name: `${pointLabel(context?.sourceItem ?? item)} · 副本`,
954
- zone_name: '内置复制副本',
955
- demo_copy: true
956
- })
993
+ enabled: true,
994
+ offset: [64, 46],
995
+ createItem: (item, context) => ({
996
+ ...item,
997
+ name: `${pointLabel(context?.sourceItem ?? item)} · 副本`,
998
+ zone_name: '内置复制副本',
999
+ demo_copy: true
1000
+ })
957
1001
  },
958
1002
  // Ctrl+C / Ctrl+V 监听的三件套:copy 打开 + keyboard 打开 + 快捷键映射(不写也是这两个缺省值)
959
1003
  shortcuts: { copy: 'ctrl+c', paste: 'ctrl+v' },
960
1004
  merge: { enabled: true, distance: 12, channelGroups: 'keep' },
961
- onReady: () => {
962
- nextTick(() => {
963
- view?.fitView(50)
964
- refreshState()
965
- })
1005
+ onReady: () => {
1006
+ nextTick(() => {
1007
+ view?.fitView(50)
1008
+ refreshState()
1009
+ })
966
1010
  emit('notice', '批量复制与合并:先点一下画布取得焦点,再框选 / Ctrl+C / Ctrl+V;也可拖动节点相撞合并')
967
- },
968
- onPointCopy: handlePointCopy,
969
- onPointMerge: handlePointMerge,
970
- onSelectChange: () => refreshState(),
971
- onDataChange: payload => {
972
- pushLog(payload.type, `${payload.counts?.points ?? 0} 点 / ${payload.counts?.lines ?? 0} 线 / ${payload.counts?.channels ?? 0} 通道`)
973
- refreshState()
974
- }
975
- })
976
- offEvents = view.on('*', (name, payload) => {
977
- if (name === 'dataChange' && payload?.type === 'batch') {
978
- pushLog('batch', `一次批量收口 · ${payload.changes?.length ?? 0} 笔`)
979
- }
980
- })
981
- })
982
-
1011
+ },
1012
+ onPointCopy: handlePointCopy,
1013
+ onPointMerge: handlePointMerge,
1014
+ onSelectChange: () => refreshState(),
1015
+ onDataChange: payload => {
1016
+ pushLog(payload.type, `${payload.counts?.points ?? 0} 点 / ${payload.counts?.lines ?? 0} 线 / ${payload.counts?.channels ?? 0} 通道`)
1017
+ refreshState()
1018
+ }
1019
+ })
1020
+ offEvents = view.on('*', (name, payload) => {
1021
+ if (name === 'dataChange' && payload?.type === 'batch') {
1022
+ pushLog('batch', `一次批量收口 · ${payload.changes?.length ?? 0} 笔`)
1023
+ }
1024
+ })
1025
+ })
1026
+
983
1027
  onBeforeUnmount(() => {
984
1028
  disposed = true
985
1029
  cancelScheduledMergeBackend()
986
1030
  offEvents?.()
987
- offEvents = null
988
- view?.destroy()
989
- view = null
990
- })
991
- </script>
992
-
993
- <template>
994
- <section class="dv-demo cm-demo">
995
- <div class="dv-stage">
996
- <div ref="container" class="dv-canvas"></div>
997
- <div class="dv-toolbar">
998
- <button type="button" class="dv-button" @click="view?.fitView(50)">适应全部</button>
999
- <button type="button" class="dv-button" @click="resetScene">恢复初始数据</button>
1000
- </div>
1001
- <div class="cm-guide">
1031
+ offEvents = null
1032
+ view?.destroy()
1033
+ view = null
1034
+ })
1035
+ </script>
1036
+
1037
+ <template>
1038
+ <section class="dv-demo cm-demo">
1039
+ <div class="dv-stage">
1040
+ <div ref="container" class="dv-canvas"></div>
1041
+ <div class="dv-toolbar">
1042
+ <button type="button" class="dv-button" @click="view?.fitView(50)">适应全部</button>
1043
+ <button type="button" class="dv-button" @click="resetScene">恢复初始数据</button>
1044
+ </div>
1045
+ <div class="cm-guide">
1002
1046
  <span><b>1</b><kbd>Shift</kbd> + 拖动框选节点 / 线路 / 通道(按右侧类型过滤)</span>
1003
- <span><b>2</b><kbd>Alt</kbd> + 点击节点复制;<kbd>Ctrl</kbd> + <kbd>V</kbd> 粘贴</span>
1004
- <span><b>3</b>拖动一个节点撞向另一个节点:可保留来源 / 目标并改写信息与样式</span>
1005
- </div>
1006
- <div v-if="copyToast" class="cm-toast cm-toast--copy">
1007
- <div class="cm-toast__head">
1008
- <strong>{{ copyToastTitle }}</strong>
1009
- <span class="cm-tag">{{ copyToast.phase }} · {{ copyToast.via }}</span>
1010
- </div>
1011
- <p>{{ copyToastText }}</p>
1012
- <div class="cm-toast__actions">
1013
- <button v-if="copyToast.phase === 'clipboard'" type="button" @click="pasteFromClipboard">粘贴整组</button>
1014
- <button type="button" @click="manualAddPointCopy">手动 addPoint</button>
1015
- <button type="button" @click="revertLastCopy">取消 / 回滚</button>
1016
- </div>
1017
- </div>
1018
- <div v-if="pendingMerge" class="cm-toast cm-toast--merge">
1019
- <div class="cm-toast__head">
1020
- <strong>合并待确认</strong>
1021
- <span class="cm-tag">{{ pendingMerge.phase }}</span>
1022
- </div>
1023
- <p>拖动来源 {{ pointLabel(pendingMerge.source.item) }} → 碰撞目标 {{ pointLabel(pendingMerge.target.item) }} · 数据还没动</p>
1024
- <p class="cm-toast__hint">保留 {{ mergeKeep === 'source' ? '拖动来源' : '碰撞目标' }};确认时把名称 / 分区 / 电压 / 颜色 / 尺寸 / 图标写进 payload.item</p>
1025
- <div class="cm-toast__actions">
1026
- <button v-if="pendingMerge.phase === 'pending'" type="button" class="is-primary" @click="confirmPendingMerge">确认并改写</button>
1027
- <button type="button" @click="showMergeImpactDetails">查看影响详情</button>
1028
- <button type="button" @click="cancelPendingMerge">取消并复位</button>
1029
- </div>
1030
- </div>
1031
- <div class="cm-dock">
1032
- <span>选中 <b>{{ selectionCounts.points }}</b> 节点 / <b>{{ selectionCounts.lines }}</b> 线路 / <b>{{ selectionCounts.channels }}</b> 通道</span>
1033
- <button type="button" @click="selectBatchSources">选中示例组</button>
1034
- <button type="button" @click="copySelectedByApi">复制选中</button>
1035
- <button type="button" @click="prepareMerge">准备合并</button>
1036
- <button type="button" @click="clearSelection">清空选中</button>
1037
- </div>
1038
- </div>
1039
- <aside class="dv-panel">
1040
- <div class="dv-group">
1041
- <h3>1 · 框选来源</h3>
1042
- <p class="dv-readout" data-demo="copy-merge-selection">{{ selectionText }}</p>
1047
+ <span><b>2</b><kbd>Alt</kbd> + 点击节点复制;<kbd>Ctrl</kbd> + <kbd>V</kbd> 粘贴</span>
1048
+ <span><b>3</b>拖动一个节点撞向另一个节点:可保留来源 / 目标并改写信息与样式</span>
1049
+ </div>
1050
+ <div v-if="copyToast" class="cm-toast cm-toast--copy">
1051
+ <div class="cm-toast__head">
1052
+ <strong>{{ copyToastTitle }}</strong>
1053
+ <span class="cm-tag">{{ copyToast.phase }} · {{ copyToast.via }}</span>
1054
+ </div>
1055
+ <p>{{ copyToastText }}</p>
1056
+ <div class="cm-toast__actions">
1057
+ <button v-if="copyToast.phase === 'clipboard'" type="button" @click="pasteFromClipboard">粘贴整组</button>
1058
+ <button type="button" @click="manualAddPointCopy">手动 addPoint</button>
1059
+ <button type="button" @click="revertLastCopy">取消 / 回滚</button>
1060
+ </div>
1061
+ </div>
1062
+ <div v-if="pendingMerge" class="cm-toast cm-toast--merge">
1063
+ <div class="cm-toast__head">
1064
+ <strong>合并待确认</strong>
1065
+ <span class="cm-tag">{{ pendingMerge.phase }}</span>
1066
+ </div>
1067
+ <p>拖动来源 {{ pointLabel(pendingMerge.source.item) }} → 碰撞目标 {{ pointLabel(pendingMerge.target.item) }} · 数据还没动</p>
1068
+ <p class="cm-toast__hint">保留 {{ mergeKeep === 'source' ? '拖动来源' : '碰撞目标' }};确认时把名称 / 分区 / 电压 / 颜色 / 尺寸 / 图标写进 payload.item</p>
1069
+ <div class="cm-toast__actions">
1070
+ <button v-if="pendingMerge.phase === 'pending'" type="button" class="is-primary" @click="confirmPendingMerge">确认并改写</button>
1071
+ <button type="button" @click="showMergeImpactDetails">查看影响详情</button>
1072
+ <button type="button" @click="cancelPendingMerge">取消并复位</button>
1073
+ </div>
1074
+ </div>
1075
+ <div class="cm-dock">
1076
+ <span>选中 <b>{{ selectionCounts.points }}</b> 节点 / <b>{{ selectionCounts.lines }}</b> 线路 / <b>{{ selectionCounts.channels }}</b> 通道</span>
1077
+ <button type="button" @click="selectBatchSources">选中示例组</button>
1078
+ <button type="button" @click="copySelectedByApi">复制选中</button>
1079
+ <button type="button" @click="prepareMerge">准备合并</button>
1080
+ <button type="button" @click="clearSelection">清空选中</button>
1081
+ </div>
1082
+ </div>
1083
+ <aside class="dv-panel">
1084
+ <div class="dv-group">
1085
+ <h3>1 · 框选来源</h3>
1086
+ <p class="dv-readout" data-demo="copy-merge-selection">{{ selectionText }}</p>
1043
1087
  <p class="dv-readout" data-demo="selection-kinds-readout">框选(Shift)/ Ctrl 多选:{{ selectionKindsText }}</p>
1044
- <div class="dv-row cm-checks">
1045
- <label><input v-model="selectionFilters.point" data-demo="selection-point" type="checkbox" @change="applySelectionKinds" /> 节点</label>
1046
- <label><input v-model="selectionFilters.line" data-demo="selection-line" type="checkbox" @change="applySelectionKinds" /> 线路</label>
1047
- <label><input v-model="selectionFilters.channel" data-demo="selection-channel" type="checkbox" @change="applySelectionKinds" /> 通道</label>
1048
- </div>
1049
- <div class="dv-row">
1050
- <button type="button" class="dv-button dv-button--primary" data-demo="copy-select-sources" @click="selectBatchSources">选中示例组</button>
1051
- <button type="button" class="dv-button" data-demo="merge-select-sources" @click="prepareMerge">选中并准备合并</button>
1052
- <button type="button" class="dv-button" @click="clearSelection">清空</button>
1053
- </div>
1054
- <div class="dv-row">
1055
- <button type="button" class="dv-button" data-demo="select-sample-line" @click="selectSampleLine">选中示例线路</button>
1056
- <button type="button" class="dv-button" data-demo="select-sample-channel" @click="selectSampleChannel">选中示例通道</button>
1057
- </div>
1088
+ <div class="dv-row cm-checks">
1089
+ <label><input v-model="selectionFilters.point" data-demo="selection-point" type="checkbox" @change="applySelectionKinds" /> 节点</label>
1090
+ <label><input v-model="selectionFilters.line" data-demo="selection-line" type="checkbox" @change="applySelectionKinds" /> 线路</label>
1091
+ <label><input v-model="selectionFilters.channel" data-demo="selection-channel" type="checkbox" @change="applySelectionKinds" /> 通道</label>
1092
+ </div>
1093
+ <div class="dv-row">
1094
+ <button type="button" class="dv-button dv-button--primary" data-demo="copy-select-sources" @click="selectBatchSources">选中示例组</button>
1095
+ <button type="button" class="dv-button" data-demo="merge-select-sources" @click="prepareMerge">选中并准备合并</button>
1096
+ <button type="button" class="dv-button" @click="clearSelection">清空</button>
1097
+ </div>
1098
+ <div class="dv-row">
1099
+ <button type="button" class="dv-button" data-demo="select-sample-line" @click="selectSampleLine">选中示例线路</button>
1100
+ <button type="button" class="dv-button" data-demo="select-sample-channel" @click="selectSampleChannel">选中示例通道</button>
1101
+ </div>
1058
1102
  <p class="dv-hint">画布上按住 Shift 从空白处拖动框选;按住 Ctrl 点击仍是累加多选。复选框决定两种选中方式能收哪些类型。只选节点时复制只新增节点,选中线路 / 通道时复制会同时补端点、线路与通道。</p>
1059
- </div>
1060
- <div class="dv-group">
1061
- <h3>2 · 复制事件</h3>
1103
+ </div>
1104
+ <div class="dv-group">
1105
+ <h3>2 · 复制事件</h3>
1062
1106
  <p class="dv-readout" data-demo="copy-event-readout">{{ copyEventText }}</p>
1063
1107
  <p class="dv-readout" data-demo="copy-sources-readout">来源:{{ copySourcesText }}</p>
1064
1108
  <p class="dv-readout" data-demo="copy-items-readout">结果:{{ copyItemsText }}</p>
1065
1109
  <p class="dv-readout" data-demo="copy-line-channels-readout">线路 → 通道:{{ copyLineChannelsText }}</p>
1066
- <div class="dv-row">
1067
- <button type="button" class="dv-button dv-button--primary" data-demo="copy-selected" @click="copySelectedByApi">copySelectedPoints</button>
1068
- <button type="button" class="dv-button" data-demo="copy-points" @click="copyPointsByApi">copyPoints(sources)</button>
1069
- <button type="button" class="dv-button" data-demo="copy-clipboard" @click="copyClipboardByApi">copyPointsToClipboard</button>
1070
- <button type="button" class="dv-button" data-demo="paste-points" :disabled="!clipboardItem" @click="pasteFromClipboard">pastePoints</button>
1071
- </div>
1072
- <div class="dv-row">
1073
- <button type="button" class="dv-button" data-demo="manual-add-point" @click="manualAddPointCopy">手动 addPoint 复制</button>
1074
- <button type="button" class="dv-button dv-button--danger" data-demo="copy-revert" @click="revertLastCopy">取消 / 回滚本次复制</button>
1075
- </div>
1076
- <p class="dv-readout" data-demo="copy-readout">{{ copyText }}</p>
1077
- <p class="dv-readout" data-demo="clipboard-readout">{{ clipboardText }}</p>
1078
- <p class="dv-readout" data-demo="copy-action">{{ actionText }}</p>
1079
- <p class="dv-readout" data-demo="manual-add-point-readout">{{ manualText }}</p>
1110
+ <div class="dv-row">
1111
+ <button type="button" class="dv-button dv-button--primary" data-demo="copy-selected" @click="copySelectedByApi">copySelectedPoints</button>
1112
+ <button type="button" class="dv-button" data-demo="copy-points" @click="copyPointsByApi">copyPoints(sources)</button>
1113
+ <button type="button" class="dv-button" data-demo="copy-clipboard" @click="copyClipboardByApi">copyPointsToClipboard</button>
1114
+ <button type="button" class="dv-button" data-demo="paste-points" :disabled="!clipboardItem" @click="pasteFromClipboard">pastePoints</button>
1115
+ </div>
1116
+ <div class="dv-row">
1117
+ <button type="button" class="dv-button" data-demo="manual-add-point" @click="manualAddPointCopy">手动 addPoint 复制</button>
1118
+ <button type="button" class="dv-button dv-button--danger" data-demo="copy-revert" @click="revertLastCopy">取消 / 回滚本次复制</button>
1119
+ </div>
1120
+ <p class="dv-readout" data-demo="copy-readout">{{ copyText }}</p>
1121
+ <p class="dv-readout" data-demo="clipboard-readout">{{ clipboardText }}</p>
1122
+ <p class="dv-readout" data-demo="copy-action">{{ actionText }}</p>
1123
+ <p class="dv-readout" data-demo="manual-add-point-readout">{{ manualText }}</p>
1080
1124
  <p class="dv-hint">「手动 addPoint」先拿到内置复制的真实落点,立刻撤销自动副本,再在同一坐标逐个 addPoint;最终只多一份手动副本。Ctrl+C / Ctrl+V 需要 copy 打开、features.keyboard: true,并保持画布焦点。复制事件里的 lineChannels 与 lines 一一对应;support 项是线路为了独立成立而补的端点。</p>
1081
- </div>
1082
- <div class="dv-group">
1083
- <h3>3 · 合并事件</h3>
1084
- <div class="dv-row cm-form">
1085
- <label>
1086
- 保留节点
1087
- <select v-model="mergeTargetId" data-demo="merge-target" :disabled="!mergeTargetOptions.length">
1088
- <option v-for="item in mergeTargetOptions" :key="item.value" :value="item.value">{{ item.label }}</option>
1089
- </select>
1090
- </label>
1091
- <label>
1092
- 最终名称
1093
- <input v-model="mergedName" data-demo="merge-name" type="text" />
1094
- </label>
1095
- </div>
1096
- <div class="dv-row cm-form">
1097
- <label>
1098
- 最终分区
1099
- <input v-model="mergedZone" data-demo="merge-zone" type="text" />
1100
- </label>
1101
- <label>
1102
- 最终电压
1103
- <input v-model="mergedVoltage" type="number" step="0.1" />
1104
- </label>
1105
- </div>
1106
- <div class="dv-row cm-form">
1107
- <label>
1108
- 最终颜色
1109
- <input v-model="mergedColor" data-demo="merge-color" type="text" />
1110
- </label>
1111
- <label>
1112
- 最终尺寸
1113
- <input v-model="mergedSize" data-demo="merge-size" type="number" min="1" step="1" />
1114
- </label>
1115
- </div>
1116
- <div class="dv-row cm-form">
1117
- <label>
1118
- 最终图标
1119
- <input v-model="mergedSymbol" data-demo="merge-symbol" type="text" />
1120
- </label>
1121
- <label>
1122
- 描边颜色
1123
- <input v-model="mergedStrokeColor" data-demo="merge-stroke-color" type="text" />
1124
- </label>
1125
- </div>
1126
- <div class="dv-row">
1127
- <label><input v-model="rewriteOnConfirm" type="checkbox" /> 手动拖拽合并确认时改写 payload.item</label>
1128
- </div>
1129
- <div class="dv-row">
1130
- <button type="button" class="dv-button dv-button--primary" data-demo="merge-selected" :disabled="!canMerge" @click="mergeSelectedByApi">合并选中(指定保留节点)</button>
1131
- </div>
1125
+ </div>
1126
+ <div class="dv-group">
1127
+ <h3>3 · 合并事件</h3>
1128
+ <div class="dv-row cm-form">
1129
+ <label>
1130
+ 保留节点
1131
+ <select v-model="mergeTargetId" data-demo="merge-target" :disabled="!mergeTargetOptions.length">
1132
+ <option v-for="item in mergeTargetOptions" :key="item.value" :value="item.value">{{ item.label }}</option>
1133
+ </select>
1134
+ </label>
1135
+ <label>
1136
+ 最终名称
1137
+ <input v-model="mergedName" data-demo="merge-name" type="text" />
1138
+ </label>
1139
+ </div>
1140
+ <div class="dv-row cm-form">
1141
+ <label>
1142
+ 最终分区
1143
+ <input v-model="mergedZone" data-demo="merge-zone" type="text" />
1144
+ </label>
1145
+ <label>
1146
+ 最终电压
1147
+ <input v-model="mergedVoltage" type="number" step="0.1" />
1148
+ </label>
1149
+ </div>
1150
+ <div class="dv-row cm-form">
1151
+ <label>
1152
+ 最终颜色
1153
+ <input v-model="mergedColor" data-demo="merge-color" type="text" />
1154
+ </label>
1155
+ <label>
1156
+ 最终尺寸
1157
+ <input v-model="mergedSize" data-demo="merge-size" type="number" min="1" step="1" />
1158
+ </label>
1159
+ </div>
1160
+ <div class="dv-row cm-form">
1161
+ <label>
1162
+ 最终图标
1163
+ <input v-model="mergedSymbol" data-demo="merge-symbol" type="text" />
1164
+ </label>
1165
+ <label>
1166
+ 描边颜色
1167
+ <input v-model="mergedStrokeColor" data-demo="merge-stroke-color" type="text" />
1168
+ </label>
1169
+ </div>
1170
+ <div class="dv-row">
1171
+ <label><input v-model="rewriteOnConfirm" type="checkbox" /> 手动拖拽合并确认时改写 payload.item</label>
1172
+ </div>
1173
+ <div class="dv-row">
1174
+ <button type="button" class="dv-button dv-button--primary" data-demo="merge-selected" :disabled="!canMerge" @click="mergeSelectedByApi">合并选中(指定保留节点)</button>
1175
+ </div>
1132
1176
  <p class="dv-readout" data-demo="merge-readout">{{ mergeText }}</p>
1133
1177
  <p class="dv-readout" data-demo="merge-backend-readout">{{ mergeBackendText }}</p>
1134
1178
  <p class="dv-hint">mergeSelectedPoints 只并选中集;target 可以指定保留谁,item 只改最终节点的字段。拖点相撞走 pending / confirm 两站,确认前也能改写。合并落地后的 changes 可直接作为后端事务:删除来源节点、更新保留节点、更新 / 删除线路与通道。</p>
1135
- </div>
1179
+ </div>
1136
1180
  <div class="dv-group">
1137
- <h3>4 · 待确认合并</h3>
1138
- <p class="dv-readout" data-demo="merge-pending-readout">{{ pendingText }}</p>
1139
- <div class="dv-row cm-form">
1140
- <label>
1141
- 拖拽合并保留
1142
- <select v-model="mergeKeep" data-demo="merge-keep">
1143
- <option value="target">保留碰撞目标(缺省)</option>
1144
- <option value="source">保留拖动来源</option>
1145
- </select>
1146
- </label>
1147
- </div>
1181
+ <h3>4 · 拆分节点</h3>
1148
1182
  <div class="dv-row">
1149
- <button type="button" class="dv-button dv-button--primary" data-demo="merge-confirm" :disabled="!pendingMerge" @click="confirmPendingMerge">confirmPointMerge({ keep, item })</button>
1150
- <button type="button" class="dv-button" data-demo="merge-cancel" :disabled="!pendingMerge" @click="cancelPendingMerge">cancelPointMerge()</button>
1183
+ <button type="button" class="dv-button dv-button--primary" data-demo="split-selected" @click="splitSelectedPoint">拆分选中节点</button>
1151
1184
  </div>
1152
- <div class="dv-row">
1153
- <button type="button" class="dv-button" data-demo="merge-impact-details" :disabled="!pendingMerge?.impact" @click="showMergeImpactDetails">按 id 查线路 / 通道详情</button>
1154
- </div>
1155
- <p class="dv-readout" data-demo="merge-impact-detail-readout">{{ mergeDetailText }}</p>
1185
+ <p class="dv-readout" data-demo="split-readout">{{ splitText }}</p>
1186
+ </div>
1187
+ <div class="dv-group">
1188
+ <h3>5 · 待确认合并</h3>
1189
+ <p class="dv-readout" data-demo="merge-pending-readout">{{ pendingText }}</p>
1190
+ <div class="dv-row cm-form">
1191
+ <label>
1192
+ 拖拽合并保留
1193
+ <select v-model="mergeKeep" data-demo="merge-keep">
1194
+ <option value="target">保留碰撞目标(缺省)</option>
1195
+ <option value="source">保留拖动来源</option>
1196
+ </select>
1197
+ </label>
1198
+ </div>
1199
+ <div class="dv-row">
1200
+ <button type="button" class="dv-button dv-button--primary" data-demo="merge-confirm" :disabled="!pendingMerge" @click="confirmPendingMerge">resolvePointMerge(true, { keep, item })</button>
1201
+ <button type="button" class="dv-button" data-demo="merge-cancel" :disabled="!pendingMerge" @click="cancelPendingMerge">resolvePointMerge(false)</button>
1202
+ </div>
1203
+ <div class="dv-row">
1204
+ <button type="button" class="dv-button" data-demo="merge-impact-details" :disabled="!pendingMerge?.impact" @click="showMergeImpactDetails">按 id 查线路 / 通道详情</button>
1205
+ </div>
1206
+ <p class="dv-readout" data-demo="merge-impact-detail-readout">{{ mergeDetailText }}</p>
1156
1207
  <p class="dv-hint">清空选中后,把画布上的一个节点拖到另一个节点上松手;选保留拖动来源 / 碰撞目标,再点确认。pending 载荷的 impact 已按「保留碰撞目标」预演影响线路数,confirm 时按最终 keep 重算;名称、分区、电压和样式都随 item 写回最终节点。</p>
1157
1208
  </div>
1158
1209
  <div class="dv-group">
1159
- <h3>5 · 复制 / 合并假接口<span class="dv-chip">{{ backendBusy ? '请求中' : '空闲' }}</span></h3>
1210
+ <h3>6 · 复制 / 合并假接口<span class="dv-chip">{{ backendBusy ? '请求中' : '空闲' }}</span></h3>
1160
1211
  <div class="dv-row cm-form">
1161
1212
  <label>
1162
1213
  下一次响应
@@ -1186,188 +1237,188 @@ onBeforeUnmount(() => {
1186
1237
  <pre class="cm-request" data-demo="copy-merge-backend-request">{{ backendPreview }}</pre>
1187
1238
  </div>
1188
1239
  <div class="dv-group">
1189
- <h3>事件日志</h3>
1190
- <ul class="dv-log" data-demo="copy-merge-log">
1191
- <li v-for="line in logLines" :key="line.id"><strong>{{ line.name }}</strong> · {{ line.text }}</li>
1192
- <li v-if="!logLines.length">还没有事件</li>
1193
- </ul>
1194
- </div>
1195
- </aside>
1196
- </section>
1197
- </template>
1198
-
1199
- <style scoped>
1200
- .cm-demo .dv-stage {
1201
- isolation: isolate;
1202
- }
1203
-
1204
- .cm-guide {
1205
- position: absolute;
1206
- top: 10px;
1207
- left: 10px;
1208
- z-index: 4;
1209
- display: flex;
1210
- flex-direction: column;
1211
- gap: 5px;
1212
- padding: 8px 10px;
1213
- border: 1px solid rgba(74, 121, 177, 0.18);
1214
- border-radius: 8px;
1215
- background: rgba(255, 255, 255, 0.88);
1216
- box-shadow: 0 5px 18px rgba(31, 62, 105, 0.1);
1217
- backdrop-filter: blur(6px);
1218
- pointer-events: none;
1219
- color: #526b8d;
1220
- font-size: 11px;
1221
- line-height: 1.5;
1222
- }
1223
-
1224
- .cm-guide span {
1225
- display: flex;
1226
- align-items: center;
1227
- gap: 5px;
1228
- }
1229
-
1230
- .cm-guide b {
1231
- display: inline-grid;
1232
- place-items: center;
1233
- width: 16px;
1234
- height: 16px;
1235
- border-radius: 50%;
1236
- background: #3678bf;
1237
- color: #fff;
1238
- font-size: 10px;
1239
- }
1240
-
1241
- .cm-guide kbd {
1242
- padding: 0 4px;
1243
- border: 1px solid #cfdcec;
1244
- border-bottom-width: 2px;
1245
- border-radius: 4px;
1246
- background: #f7faff;
1247
- color: #2f5e94;
1248
- font: inherit;
1249
- }
1250
-
1251
- .cm-toast {
1252
- position: absolute;
1253
- left: 10px;
1254
- z-index: 5;
1255
- width: min(440px, calc(100% - 150px));
1256
- padding: 10px 12px;
1257
- border: 1px solid #b9d2ee;
1258
- border-radius: 9px;
1259
- background: rgba(249, 252, 255, 0.96);
1260
- box-shadow: 0 8px 24px rgba(31, 62, 105, 0.16);
1261
- backdrop-filter: blur(8px);
1262
- }
1263
-
1264
- .cm-toast--copy {
1265
- top: 104px;
1266
- }
1267
-
1268
- .cm-toast--merge {
1269
- bottom: 62px;
1270
- border-color: #efc98f;
1271
- background: rgba(255, 251, 244, 0.97);
1272
- }
1273
-
1274
- .cm-toast__head {
1275
- display: flex;
1276
- align-items: center;
1277
- justify-content: space-between;
1278
- gap: 8px;
1279
- }
1280
-
1281
- .cm-toast__head strong {
1282
- color: #173a63;
1283
- font-size: 12px;
1284
- }
1285
-
1286
- .cm-toast p {
1287
- margin: 6px 0 0;
1288
- color: #526b8d;
1289
- font-size: 11px;
1290
- line-height: 1.55;
1291
- }
1292
-
1293
- .cm-toast .cm-toast__hint {
1294
- color: #8b6a35;
1295
- }
1296
-
1297
- .cm-tag {
1298
- padding: 1px 6px;
1299
- border-radius: 999px;
1300
- background: #eaf2fc;
1301
- color: #2f5e94;
1302
- font-size: 10px;
1303
- white-space: nowrap;
1304
- }
1305
-
1306
- .cm-toast__actions {
1307
- display: flex;
1308
- flex-wrap: wrap;
1309
- gap: 6px;
1310
- margin-top: 8px;
1311
- }
1312
-
1313
- .cm-toast__actions button,
1314
- .cm-dock button {
1315
- padding: 4px 8px;
1316
- border: 1px solid #cfdcec;
1317
- border-radius: 5px;
1318
- background: #fff;
1319
- color: #2f5e94;
1320
- font-size: 11px;
1321
- cursor: pointer;
1322
- }
1323
-
1324
- .cm-toast__actions button:hover,
1325
- .cm-dock button:hover {
1326
- background: #e9f1fb;
1327
- }
1328
-
1329
- .cm-toast__actions .is-primary {
1330
- border-color: #3678bf;
1331
- background: #3678bf;
1332
- color: #fff;
1333
- }
1334
-
1335
- .cm-dock {
1336
- position: absolute;
1337
- left: 10px;
1338
- bottom: 10px;
1339
- z-index: 5;
1340
- display: flex;
1341
- align-items: center;
1342
- flex-wrap: wrap;
1343
- gap: 6px;
1344
- padding: 5px 7px;
1345
- border: 1px solid rgba(74, 121, 177, 0.16);
1346
- border-radius: 7px;
1347
- background: rgba(255, 255, 255, 0.9);
1348
- box-shadow: 0 4px 16px rgba(31, 62, 105, 0.12);
1349
- color: #526b8d;
1350
- font-size: 11px;
1351
- }
1352
-
1353
- .cm-dock b {
1354
- color: #173a63;
1355
- }
1356
-
1357
- .cm-form {
1358
- align-items: flex-end;
1359
- }
1360
-
1361
- .cm-form input[type='text'],
1362
- .cm-form input[type='number'] {
1363
- width: 132px;
1364
- padding: 4px 6px;
1365
- border: 1px solid #d6e0ec;
1366
- border-radius: 6px;
1367
- color: #1f2a44;
1368
- font-size: 12px;
1369
- }
1370
-
1240
+ <h3>事件日志</h3>
1241
+ <ul class="dv-log" data-demo="copy-merge-log">
1242
+ <li v-for="line in logLines" :key="line.id"><strong>{{ line.name }}</strong> · {{ line.text }}</li>
1243
+ <li v-if="!logLines.length">还没有事件</li>
1244
+ </ul>
1245
+ </div>
1246
+ </aside>
1247
+ </section>
1248
+ </template>
1249
+
1250
+ <style scoped>
1251
+ .cm-demo .dv-stage {
1252
+ isolation: isolate;
1253
+ }
1254
+
1255
+ .cm-guide {
1256
+ position: absolute;
1257
+ top: 10px;
1258
+ left: 10px;
1259
+ z-index: 4;
1260
+ display: flex;
1261
+ flex-direction: column;
1262
+ gap: 5px;
1263
+ padding: 8px 10px;
1264
+ border: 1px solid rgba(74, 121, 177, 0.18);
1265
+ border-radius: 8px;
1266
+ background: rgba(255, 255, 255, 0.88);
1267
+ box-shadow: 0 5px 18px rgba(31, 62, 105, 0.1);
1268
+ backdrop-filter: blur(6px);
1269
+ pointer-events: none;
1270
+ color: #526b8d;
1271
+ font-size: 11px;
1272
+ line-height: 1.5;
1273
+ }
1274
+
1275
+ .cm-guide span {
1276
+ display: flex;
1277
+ align-items: center;
1278
+ gap: 5px;
1279
+ }
1280
+
1281
+ .cm-guide b {
1282
+ display: inline-grid;
1283
+ place-items: center;
1284
+ width: 16px;
1285
+ height: 16px;
1286
+ border-radius: 50%;
1287
+ background: #3678bf;
1288
+ color: #fff;
1289
+ font-size: 10px;
1290
+ }
1291
+
1292
+ .cm-guide kbd {
1293
+ padding: 0 4px;
1294
+ border: 1px solid #cfdcec;
1295
+ border-bottom-width: 2px;
1296
+ border-radius: 4px;
1297
+ background: #f7faff;
1298
+ color: #2f5e94;
1299
+ font: inherit;
1300
+ }
1301
+
1302
+ .cm-toast {
1303
+ position: absolute;
1304
+ left: 10px;
1305
+ z-index: 5;
1306
+ width: min(440px, calc(100% - 150px));
1307
+ padding: 10px 12px;
1308
+ border: 1px solid #b9d2ee;
1309
+ border-radius: 9px;
1310
+ background: rgba(249, 252, 255, 0.96);
1311
+ box-shadow: 0 8px 24px rgba(31, 62, 105, 0.16);
1312
+ backdrop-filter: blur(8px);
1313
+ }
1314
+
1315
+ .cm-toast--copy {
1316
+ top: 104px;
1317
+ }
1318
+
1319
+ .cm-toast--merge {
1320
+ bottom: 62px;
1321
+ border-color: #efc98f;
1322
+ background: rgba(255, 251, 244, 0.97);
1323
+ }
1324
+
1325
+ .cm-toast__head {
1326
+ display: flex;
1327
+ align-items: center;
1328
+ justify-content: space-between;
1329
+ gap: 8px;
1330
+ }
1331
+
1332
+ .cm-toast__head strong {
1333
+ color: #173a63;
1334
+ font-size: 12px;
1335
+ }
1336
+
1337
+ .cm-toast p {
1338
+ margin: 6px 0 0;
1339
+ color: #526b8d;
1340
+ font-size: 11px;
1341
+ line-height: 1.55;
1342
+ }
1343
+
1344
+ .cm-toast .cm-toast__hint {
1345
+ color: #8b6a35;
1346
+ }
1347
+
1348
+ .cm-tag {
1349
+ padding: 1px 6px;
1350
+ border-radius: 999px;
1351
+ background: #eaf2fc;
1352
+ color: #2f5e94;
1353
+ font-size: 10px;
1354
+ white-space: nowrap;
1355
+ }
1356
+
1357
+ .cm-toast__actions {
1358
+ display: flex;
1359
+ flex-wrap: wrap;
1360
+ gap: 6px;
1361
+ margin-top: 8px;
1362
+ }
1363
+
1364
+ .cm-toast__actions button,
1365
+ .cm-dock button {
1366
+ padding: 4px 8px;
1367
+ border: 1px solid #cfdcec;
1368
+ border-radius: 5px;
1369
+ background: #fff;
1370
+ color: #2f5e94;
1371
+ font-size: 11px;
1372
+ cursor: pointer;
1373
+ }
1374
+
1375
+ .cm-toast__actions button:hover,
1376
+ .cm-dock button:hover {
1377
+ background: #e9f1fb;
1378
+ }
1379
+
1380
+ .cm-toast__actions .is-primary {
1381
+ border-color: #3678bf;
1382
+ background: #3678bf;
1383
+ color: #fff;
1384
+ }
1385
+
1386
+ .cm-dock {
1387
+ position: absolute;
1388
+ left: 10px;
1389
+ bottom: 10px;
1390
+ z-index: 5;
1391
+ display: flex;
1392
+ align-items: center;
1393
+ flex-wrap: wrap;
1394
+ gap: 6px;
1395
+ padding: 5px 7px;
1396
+ border: 1px solid rgba(74, 121, 177, 0.16);
1397
+ border-radius: 7px;
1398
+ background: rgba(255, 255, 255, 0.9);
1399
+ box-shadow: 0 4px 16px rgba(31, 62, 105, 0.12);
1400
+ color: #526b8d;
1401
+ font-size: 11px;
1402
+ }
1403
+
1404
+ .cm-dock b {
1405
+ color: #173a63;
1406
+ }
1407
+
1408
+ .cm-form {
1409
+ align-items: flex-end;
1410
+ }
1411
+
1412
+ .cm-form input[type='text'],
1413
+ .cm-form input[type='number'] {
1414
+ width: 132px;
1415
+ padding: 4px 6px;
1416
+ border: 1px solid #d6e0ec;
1417
+ border-radius: 6px;
1418
+ color: #1f2a44;
1419
+ font-size: 12px;
1420
+ }
1421
+
1371
1422
  .cm-form input[type='number'] {
1372
1423
  width: 76px;
1373
1424
  }
@@ -1387,13 +1438,13 @@ onBeforeUnmount(() => {
1387
1438
  }
1388
1439
 
1389
1440
  @media (max-width: 720px) {
1390
- .cm-toast,
1391
- .cm-dock {
1392
- width: calc(100% - 20px);
1393
- }
1394
-
1395
- .cm-toast--copy {
1396
- top: 132px;
1397
- }
1398
- }
1399
- </style>
1441
+ .cm-toast,
1442
+ .cm-dock {
1443
+ width: calc(100% - 20px);
1444
+ }
1445
+
1446
+ .cm-toast--copy {
1447
+ top: 132px;
1448
+ }
1449
+ }
1450
+ </style>