@arronqzy/vue-view 0.1.0

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 (75) hide show
  1. package/README.md +50 -0
  2. package/package.json +49 -0
  3. package/src/env.d.ts +62 -0
  4. package/src/index.ts +4 -0
  5. package/src/panel/VueViewOnlinePreview.vue +276 -0
  6. package/src/panel/VueViewPanel.vue +871 -0
  7. package/src/panel/components/ConfigHintIcon.vue +34 -0
  8. package/src/panel/components/ElementsLayer.vue +165 -0
  9. package/src/panel/components/MaterialPreview.vue +135 -0
  10. package/src/panel/components/MaterialSidebar.vue +526 -0
  11. package/src/panel/components/MaterialSidebarTreeNode.vue +305 -0
  12. package/src/panel/components/MoveableLayer.vue +859 -0
  13. package/src/panel/components/PanelCanvas.vue +630 -0
  14. package/src/panel/components/PanelConfigSidebar.vue +397 -0
  15. package/src/panel/components/PanelRulers.vue +177 -0
  16. package/src/panel/components/SelectLayer.vue +115 -0
  17. package/src/panel/components/ViewElementScopePanel.vue +76 -0
  18. package/src/panel/components/WorkspaceConfigSidebar.vue +147 -0
  19. package/src/panel/components/WorkspaceProjectNav.vue +192 -0
  20. package/src/panel/components/WorkspaceStageSplit.vue +258 -0
  21. package/src/panel/components/config/ConfigColorField.vue +52 -0
  22. package/src/panel/components/config/ConfigFieldGroup.vue +20 -0
  23. package/src/panel/components/config/ConfigSection.vue +50 -0
  24. package/src/panel/components/config/PanelConfigAudioSection.vue +256 -0
  25. package/src/panel/components/config/PanelConfigChartSection.vue +650 -0
  26. package/src/panel/components/config/PanelConfigGeometrySection.vue +209 -0
  27. package/src/panel/components/config/PanelConfigGridChildSpan.vue +68 -0
  28. package/src/panel/components/config/PanelConfigGridSection.vue +103 -0
  29. package/src/panel/components/config/PanelConfigImageSection.vue +136 -0
  30. package/src/panel/components/config/PanelConfigMultiSelect.vue +434 -0
  31. package/src/panel/components/config/PanelConfigNodeInfo.vue +165 -0
  32. package/src/panel/components/config/PanelConfigReferenceSection.vue +77 -0
  33. package/src/panel/components/config/PanelConfigStyleSections.vue +208 -0
  34. package/src/panel/components/config/PanelConfigTextSection.vue +195 -0
  35. package/src/panel/components/config/PanelConfigVideoSection.vue +107 -0
  36. package/src/panel/components/config/shared.ts +74 -0
  37. package/src/panel/components/elementsLayerNodes.ts +830 -0
  38. package/src/panel/components/materialSidebarData.ts +85 -0
  39. package/src/panel/components/scope-config/ScopeConfigProvider.vue +153 -0
  40. package/src/panel/components/scope-config/ScopeTemplateAutocompleteHost.vue +234 -0
  41. package/src/panel/components/scope-config/ScopeTemplatePreviewHost.vue +192 -0
  42. package/src/panel/components/scope-config/ScopeTemplatePreviewPanel.vue +42 -0
  43. package/src/panel/components/scope-config/ScopeTemplateUsageHint.vue +20 -0
  44. package/src/panel/components/scope-config/ScopeTemplateWarningsPanel.vue +63 -0
  45. package/src/panel/components/scope-config/scopeConfigContext.ts +17 -0
  46. package/src/panel/components/scope-config/useScopeConfig.ts +11 -0
  47. package/src/panel/constants/messages.ts +34 -0
  48. package/src/panel/constants/zIndex.ts +6 -0
  49. package/src/panel/hooks/usePanelElements.ts +1075 -0
  50. package/src/panel/hooks/useRafThrottledScroll.ts +25 -0
  51. package/src/panel/hooks/useWorkspaceProjects.ts +240 -0
  52. package/src/panel/lib/panel-ruler-canvas.ts +139 -0
  53. package/src/panel/library/workspace-project-cache.ts +23 -0
  54. package/src/panel/library/workspace-project-db.ts +111 -0
  55. package/src/panel/library/workspace-project-sync.ts +41 -0
  56. package/src/panel/library/workspace-snapshot.ts +30 -0
  57. package/src/panel/parseOnlinePreviewSearchParams.ts +13 -0
  58. package/src/panel/scope/view-scope-store.ts +82 -0
  59. package/src/panel/types.ts +127 -0
  60. package/src/panel/utils/chartOptionBuilder.ts +327 -0
  61. package/src/panel/utils/gridPlacement.ts +189 -0
  62. package/src/panel/utils/mappingLayerOps.ts +142 -0
  63. package/src/panel/utils/panelElementDefaults.ts +161 -0
  64. package/src/panel/utils/panelElementNodes.ts +35 -0
  65. package/src/panel/utils/panelStateIO.ts +124 -0
  66. package/src/panel/utils/scope-autocomplete.ts +114 -0
  67. package/src/panel/utils/scope-field-labels.ts +46 -0
  68. package/src/panel/utils/scope-template-chart.ts +92 -0
  69. package/src/panel/utils/scope-template-preview.ts +124 -0
  70. package/src/panel/utils/scope-template-spread.ts +229 -0
  71. package/src/panel/utils/scope-template-warnings.ts +243 -0
  72. package/src/panel/utils/scope-template.ts +97 -0
  73. package/src/panel/utils/updateElementDraft.ts +221 -0
  74. package/src/panel/viewportZoom.ts +26 -0
  75. package/src/tailwind.css +43 -0
@@ -0,0 +1,526 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from "vue";
3
+ import { Card, Empty, Input, Switch, Tabs } from "ant-design-vue";
4
+ import type { PanelElement, PanelLayer, ReferenceCopyMode } from "../types";
5
+ import { PANEL_MESSAGES } from "../constants/messages";
6
+ import {
7
+ concreteGridParentIdForLayer,
8
+ logicalGridParentIdFromConcrete,
9
+ } from "../utils/mappingLayerOps";
10
+ import MaterialPreview from "./MaterialPreview.vue";
11
+ import MaterialSidebarTreeNode from "./MaterialSidebarTreeNode.vue";
12
+ import {
13
+ defaultCategories,
14
+ getNodeDisplayName,
15
+ themedScrollbarClass,
16
+ type MaterialCategoryId,
17
+ type MaterialItem,
18
+ } from "./materialSidebarData";
19
+
20
+ export type MaterialSidebarProps = {
21
+ class?: string;
22
+ onDragMaterialStart?: (material: MaterialItem) => void;
23
+ layers?: PanelLayer[];
24
+ allElements?: PanelElement[];
25
+ selectedIds?: string[];
26
+ onSelectNode?: (nodeId: string, layerId: string) => void;
27
+ onNodeContextMenu?: (payload: {
28
+ nodeId: string;
29
+ layerId: string;
30
+ x: number;
31
+ y: number;
32
+ }) => void;
33
+ onDeleteNode?: (nodeId: string) => void;
34
+ onCopyNode?: (nodeId: string, mode?: ReferenceCopyMode) => void;
35
+ onMoveNodeToLayer?: (nodeId: string, targetLayerId: string) => void;
36
+ };
37
+
38
+ const props = withDefaults(defineProps<MaterialSidebarProps>(), {
39
+ layers: () => [],
40
+ allElements: () => [],
41
+ selectedIds: () => [],
42
+ });
43
+
44
+ const leftTab = ref<"materials" | "tree">("materials");
45
+ const activeCategoryId = ref<MaterialCategoryId>("charts");
46
+ const keyword = ref("");
47
+ const treeKeyword = ref("");
48
+ const referenceOnlyTree = ref(false);
49
+ const draggingTreeNodeId = ref<string | null>(null);
50
+ const dragOverLayerId = ref<string | null>(null);
51
+ const expandedKeys = ref<Record<string, boolean>>({ root: true });
52
+
53
+ const categories = defaultCategories;
54
+ const normalizedKeyword = computed(() => keyword.value.trim().toLowerCase());
55
+ const isSearching = computed(() => normalizedKeyword.value.length > 0);
56
+ const normalizedTreeKeyword = computed(() => treeKeyword.value.trim().toLowerCase());
57
+ const isTreeSearching = computed(() => normalizedTreeKeyword.value.length > 0);
58
+
59
+ const activeCategory = computed(
60
+ () => categories.find((c) => c.id === activeCategoryId.value) ?? categories[0]
61
+ );
62
+
63
+ const matchedItems = computed(() => {
64
+ if (!isSearching.value) return [];
65
+ const result: Array<MaterialItem & { categoryTitle: string }> = [];
66
+ categories.forEach((category) => {
67
+ category.items.forEach((item) => {
68
+ const haystack = `${item.title} ${item.id} ${category.title}`.toLowerCase();
69
+ if (haystack.includes(normalizedKeyword.value)) {
70
+ result.push({ ...item, categoryTitle: category.title });
71
+ }
72
+ });
73
+ });
74
+ return result;
75
+ });
76
+
77
+ const elementsByLayer = computed(() => {
78
+ const map = new Map<string, PanelElement[]>();
79
+ for (const layer of props.layers) map.set(layer.id, []);
80
+ for (const el of props.allElements) {
81
+ const list = map.get(el.layerId) ?? [];
82
+ list.push(el);
83
+ map.set(el.layerId, list);
84
+ }
85
+ return map;
86
+ });
87
+
88
+ const elementsById = computed(() => {
89
+ const map = new Map<string, PanelElement>();
90
+ for (const el of props.allElements) map.set(el.id, el);
91
+ return map;
92
+ });
93
+
94
+ const layerById = computed(() => {
95
+ const map = new Map<string, PanelLayer>();
96
+ for (const layer of props.layers) map.set(layer.id, layer);
97
+ return map;
98
+ });
99
+
100
+ const effectiveGridParentByElementId = computed(() => {
101
+ const byId = new Map<string, PanelElement>();
102
+ for (const el of props.allElements) byId.set(el.id, el);
103
+ const map = new Map<string, string | undefined>();
104
+ for (const el of props.allElements) {
105
+ const pg = el.parentGridId;
106
+ if (!pg) {
107
+ map.set(el.id, undefined);
108
+ continue;
109
+ }
110
+ const parent = byId.get(pg);
111
+ if (parent?.layerId === el.layerId && parent.materialType === "grid") {
112
+ map.set(el.id, pg);
113
+ continue;
114
+ }
115
+ const logical = logicalGridParentIdFromConcrete(pg, byId);
116
+ if (logical !== undefined) {
117
+ const concrete = concreteGridParentIdForLayer(
118
+ logical,
119
+ el.layerId,
120
+ props.allElements
121
+ );
122
+ map.set(el.id, concrete ?? undefined);
123
+ continue;
124
+ }
125
+ map.set(el.id, undefined);
126
+ }
127
+ return map;
128
+ });
129
+
130
+ const childrenByGridByLayer = computed(() => {
131
+ const outer = new Map<string, Map<string, PanelElement[]>>();
132
+ for (const el of props.allElements) {
133
+ const gridParentId = effectiveGridParentByElementId.value.get(el.id);
134
+ if (!gridParentId) continue;
135
+ let inner = outer.get(el.layerId);
136
+ if (!inner) {
137
+ inner = new Map();
138
+ outer.set(el.layerId, inner);
139
+ }
140
+ const list = inner.get(gridParentId) ?? [];
141
+ list.push(el);
142
+ inner.set(gridParentId, list);
143
+ }
144
+ return outer;
145
+ });
146
+
147
+ function isExpanded(key: string, defaultValue = false) {
148
+ return expandedKeys.value[key] ?? defaultValue;
149
+ }
150
+
151
+ function setExpanded(key: string, next: boolean) {
152
+ expandedKeys.value = { ...expandedKeys.value, [key]: next };
153
+ }
154
+
155
+ function hasRefInSubtree(node: PanelElement, visited: Set<string>): boolean {
156
+ if (node.materialType === "reference") return true;
157
+ if (visited.has(node.id)) return false;
158
+ const nextVisited = new Set(visited);
159
+ nextVisited.add(node.id);
160
+ const children =
161
+ node.refCopyMode === "deep"
162
+ ? node.refSnapshot ?? []
163
+ : node.refLayerId
164
+ ? elementsByLayer.value.get(node.refLayerId) ?? []
165
+ : [];
166
+ return children.some((child) => hasRefInSubtree(child, nextVisited));
167
+ }
168
+
169
+ function nodeMatchesTreeSearch(node: PanelElement, visited: Set<string>): boolean {
170
+ if (!isTreeSearching.value) return true;
171
+ const selfText = `${getNodeDisplayName(node)} ${node.materialType ?? ""} ${node.id}`.toLowerCase();
172
+ if (selfText.includes(normalizedTreeKeyword.value)) return true;
173
+ if (visited.has(node.id)) return false;
174
+ const nextVisited = new Set(visited);
175
+ nextVisited.add(node.id);
176
+ const isRef = node.materialType === "reference";
177
+ const isGrid = node.materialType === "grid";
178
+ const gridChildren = isGrid
179
+ ? [...(childrenByGridByLayer.value.get(node.layerId)?.get(node.id) ?? [])]
180
+ : [];
181
+ const children = isRef
182
+ ? node.refCopyMode === "deep"
183
+ ? node.refSnapshot ?? []
184
+ : node.refLayerId
185
+ ? elementsByLayer.value.get(node.refLayerId) ?? []
186
+ : []
187
+ : gridChildren;
188
+ return children.some((child) => nodeMatchesTreeSearch(child, nextVisited));
189
+ }
190
+
191
+ function getRootNodes(layerId: string) {
192
+ const layerNodes = elementsByLayer.value.get(layerId) ?? [];
193
+ return layerNodes
194
+ .filter((node) => !effectiveGridParentByElementId.value.get(node.id))
195
+ .filter((node) => !referenceOnlyTree.value || hasRefInSubtree(node, new Set()))
196
+ .filter((node) => nodeMatchesTreeSearch(node, new Set()));
197
+ }
198
+
199
+ function getLayerDropState(layer: PanelLayer) {
200
+ const draggingNode = draggingTreeNodeId.value
201
+ ? elementsById.value.get(draggingTreeNodeId.value) ?? null
202
+ : null;
203
+ const draggingSourceLayer = draggingNode
204
+ ? layerById.value.get(draggingNode.layerId) ?? null
205
+ : null;
206
+ const dropBlockReason = !draggingNode
207
+ ? ""
208
+ : draggingNode.locked
209
+ ? PANEL_MESSAGES.nodeMoveLocked
210
+ : draggingSourceLayer?.locked
211
+ ? PANEL_MESSAGES.nodeMoveSourceLayerLocked
212
+ : layer.locked
213
+ ? PANEL_MESSAGES.nodeMoveTargetLayerLocked
214
+ : draggingNode.layerId === layer.id
215
+ ? PANEL_MESSAGES.nodeMoveSameLayer
216
+ : "";
217
+ const canDropIntoLayer = Boolean(draggingNode) && !dropBlockReason;
218
+ return { draggingNode, dropBlockReason, canDropIntoLayer };
219
+ }
220
+
221
+ function onMaterialDragStart(e: DragEvent, item: MaterialItem) {
222
+ e.dataTransfer?.setData(
223
+ "application/x-arronqzy-material",
224
+ JSON.stringify({ id: item.id, title: item.title })
225
+ );
226
+ if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy";
227
+ props.onDragMaterialStart?.(item);
228
+ }
229
+
230
+ function onLayerDragOver(e: DragEvent, layer: PanelLayer) {
231
+ const hasNodeData = e.dataTransfer?.types.includes("application/x-arronqzy-tree-node");
232
+ if (!hasNodeData) return;
233
+ const { canDropIntoLayer } = getLayerDropState(layer);
234
+ if (canDropIntoLayer) {
235
+ e.preventDefault();
236
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
237
+ } else if (e.dataTransfer) {
238
+ e.dataTransfer.dropEffect = "none";
239
+ }
240
+ if (dragOverLayerId.value !== layer.id) dragOverLayerId.value = layer.id;
241
+ }
242
+
243
+ function onLayerDrop(e: DragEvent, layer: PanelLayer) {
244
+ const payload = e.dataTransfer?.getData("application/x-arronqzy-tree-node");
245
+ if (!payload) return;
246
+ e.preventDefault();
247
+ try {
248
+ const data = JSON.parse(payload) as { nodeId?: string; sourceLayerId?: string };
249
+ const { canDropIntoLayer } = getLayerDropState(layer);
250
+ if (!data.nodeId || !layer.id) return;
251
+ if (!canDropIntoLayer) return;
252
+ if (data.sourceLayerId === layer.id) return;
253
+ props.onMoveNodeToLayer?.(data.nodeId, layer.id);
254
+ } catch {
255
+ // ignore invalid payload
256
+ } finally {
257
+ draggingTreeNodeId.value = null;
258
+ dragOverLayerId.value = null;
259
+ }
260
+ }
261
+
262
+ const treeSearchEmpty = computed(
263
+ () =>
264
+ isTreeSearching.value &&
265
+ props.layers.every((layer) => getRootNodes(layer.id).length === 0)
266
+ );
267
+ </script>
268
+
269
+ <template>
270
+ <aside
271
+ :class="[
272
+ 'grid h-full w-full border-r border-border bg-muted/30 text-foreground',
273
+ props.class ?? '',
274
+ ]"
275
+ style="grid-template-rows: auto 1fr"
276
+ >
277
+ <div class="border-b border-border bg-background/80 px-3 py-2 backdrop-blur-sm">
278
+ <Input
279
+ v-model:value="keyword"
280
+ size="small"
281
+ placeholder="搜索物料(名称 / 分类)"
282
+ class="text-xs"
283
+ />
284
+ </div>
285
+
286
+ <Tabs v-model:active-key="leftTab" class="flex min-h-0 h-full flex-col">
287
+ <Tabs.TabPane key="materials" tab="物料" class="min-h-0 flex-1">
288
+ <div class="grid h-full min-h-0 grid-cols-[110px_1fr]">
289
+ <div :class="`overflow-auto border-r border-border ${themedScrollbarClass}`">
290
+ <button
291
+ v-for="c in categories"
292
+ :key="c.id"
293
+ type="button"
294
+ :class="[
295
+ 'w-full cursor-pointer border-b border-border/40 px-2.5 py-2.5 text-left text-xs',
296
+ c.id === activeCategoryId
297
+ ? 'bg-accent text-accent-foreground'
298
+ : 'text-muted-foreground hover:bg-accent/60 hover:text-accent-foreground',
299
+ ]"
300
+ @click="activeCategoryId = c.id"
301
+ >
302
+ {{ c.title }}
303
+ </button>
304
+ </div>
305
+
306
+ <div :class="`overflow-auto ${themedScrollbarClass}`">
307
+ <div class="px-2.5 py-2.5 text-xs font-semibold">
308
+ {{ isSearching ? `搜索结果(${matchedItems.length})` : activeCategory.title }}
309
+ </div>
310
+ <div class="grid gap-2 px-2.5 pb-3">
311
+ <button
312
+ v-for="it in isSearching ? matchedItems : activeCategory.items"
313
+ :key="isSearching ? `${it.id}-${(it as any).categoryTitle}` : it.id"
314
+ type="button"
315
+ draggable="true"
316
+ class="cursor-pointer rounded-xl border border-border bg-card px-2 py-2 text-left text-xs text-card-foreground hover:bg-accent/60"
317
+ @dragstart="onMaterialDragStart($event, it)"
318
+ >
319
+ <div class="flex flex-col items-stretch gap-2">
320
+ <MaterialPreview :id="it.id" />
321
+ <div class="min-w-0">
322
+ <div class="truncate">{{ it.title }}</div>
323
+ <div
324
+ v-if="isSearching"
325
+ class="mt-1 text-[11px] text-muted-foreground"
326
+ >
327
+ {{ (it as any).categoryTitle }}
328
+ </div>
329
+ </div>
330
+ </div>
331
+ </button>
332
+ <Empty
333
+ v-if="isSearching && matchedItems.length === 0"
334
+ class="py-5"
335
+ description="未匹配到物料"
336
+ >
337
+ <template #description>
338
+ <span class="text-xs">未匹配到物料</span>
339
+ <div class="text-[11px] text-muted-foreground">
340
+ 尝试更换关键词,或切换分类后再搜索。
341
+ </div>
342
+ </template>
343
+ </Empty>
344
+ </div>
345
+ </div>
346
+ </div>
347
+ </Tabs.TabPane>
348
+
349
+ <Tabs.TabPane key="tree" tab="节点树" class="min-h-0 flex-1">
350
+ <div :class="`h-full overflow-auto px-2 py-2 text-xs ${themedScrollbarClass}`">
351
+ <div class="mb-2">
352
+ <Input
353
+ v-model:value="treeKeyword"
354
+ size="small"
355
+ placeholder="搜索节点(名称 / 类型 / ID)"
356
+ class="text-xs"
357
+ />
358
+ </div>
359
+ <div class="mb-2 flex items-center justify-between rounded border border-border bg-card px-2 py-1.5">
360
+ <span class="text-[11px] text-muted-foreground">仅看引用子树</span>
361
+ <Switch v-model:checked="referenceOnlyTree" size="small" aria-label="仅看引用子树" />
362
+ </div>
363
+
364
+ <div class="rounded border border-border bg-card">
365
+ <div class="flex items-center gap-1 px-2 py-1.5 font-medium">
366
+ <button
367
+ type="button"
368
+ class="flex h-7 w-7 items-center justify-center rounded text-sm hover:bg-accent"
369
+ @click="setExpanded('root', !isExpanded('root', true))"
370
+ >
371
+ {{ isExpanded("root", true) ? "▾" : "▸" }}
372
+ </button>
373
+ <span>root</span>
374
+ </div>
375
+
376
+ <div v-show="isExpanded('root', true)" class="space-y-1 border-t border-border/60 py-1">
377
+ <template v-for="layer in layers" :key="layer.id">
378
+ <div v-if="!(isTreeSearching && getRootNodes(layer.id).length === 0)">
379
+ <Card
380
+ :class="[
381
+ 'mb-2 overflow-hidden transition-shadow',
382
+ layer.isMapping
383
+ ? 'border-2 border-violet-500/70 bg-gradient-to-br from-violet-500/14 via-violet-600/10 to-fuchsia-500/12'
384
+ : '',
385
+ dragOverLayerId === layer.id
386
+ ? getLayerDropState(layer).canDropIntoLayer
387
+ ? 'ring-2 ring-primary/45 ring-offset-2 ring-offset-background'
388
+ : 'ring-2 ring-destructive/45 ring-offset-2 ring-offset-background'
389
+ : '',
390
+ ]"
391
+ size="small"
392
+ :body-style="{ padding: '10px' }"
393
+ >
394
+ <div class="mb-1.5 flex flex-wrap items-center gap-1">
395
+ <span
396
+ v-if="dragOverLayerId === layer.id"
397
+ :class="[
398
+ 'inline-flex h-5 w-5 shrink-0 items-center justify-center rounded text-[11px]',
399
+ getLayerDropState(layer).canDropIntoLayer
400
+ ? 'bg-primary/15 text-primary'
401
+ : 'bg-destructive/15 text-destructive',
402
+ ]"
403
+ :title="
404
+ getLayerDropState(layer).canDropIntoLayer
405
+ ? '目标图层'
406
+ : getLayerDropState(layer).dropBlockReason
407
+ "
408
+ >
409
+
410
+ </span>
411
+ <button
412
+ type="button"
413
+ class="flex h-7 w-7 shrink-0 items-center justify-center rounded text-sm text-muted-foreground hover:bg-accent"
414
+ @click="
415
+ setExpanded(`layer:${layer.id}`, !isExpanded(`layer:${layer.id}`, true))
416
+ "
417
+ >
418
+ {{
419
+ isExpanded(`layer:${layer.id}`, true) ? "▾" : "▸"
420
+ }}
421
+ </button>
422
+ <span class="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
423
+ {{ layer.name }}
424
+ <span class="font-normal text-muted-foreground">
425
+ ({{ getRootNodes(layer.id).length }})
426
+ </span>
427
+ </span>
428
+ <span
429
+ v-if="layer.isMapping"
430
+ class="shrink-0 rounded-md border-2 border-violet-600 bg-violet-500/22 px-1.5 py-0.5 text-[10px] font-semibold text-violet-950 dark:border-violet-400 dark:bg-violet-500/35 dark:text-violet-50"
431
+ >
432
+ 映射图层
433
+ </span>
434
+ <span
435
+ v-if="dragOverLayerId === layer.id"
436
+ :class="[
437
+ 'ml-auto shrink-0 rounded px-1.5 py-0.5 text-[10px]',
438
+ getLayerDropState(layer).canDropIntoLayer
439
+ ? 'bg-primary/15 text-primary'
440
+ : 'bg-destructive/15 text-destructive',
441
+ ]"
442
+ >
443
+ {{
444
+ getLayerDropState(layer).canDropIntoLayer
445
+ ? "将移动到该图层"
446
+ : getLayerDropState(layer).dropBlockReason
447
+ }}
448
+ </span>
449
+ </div>
450
+
451
+ <div
452
+ :class="[
453
+ 'mb-2 rounded-md border border-dashed px-2 py-1.5 text-[10px] transition-colors',
454
+ dragOverLayerId === layer.id
455
+ ? getLayerDropState(layer).canDropIntoLayer
456
+ ? 'border-primary/50 bg-primary/10 text-primary'
457
+ : 'border-destructive/50 bg-destructive/10 text-destructive'
458
+ : 'border-border/50 bg-muted/20 text-muted-foreground hover:border-border hover:bg-muted/35',
459
+ ]"
460
+ title="拖拽节点到此图层"
461
+ @dragover="onLayerDragOver($event, layer)"
462
+ @dragleave="dragOverLayerId === layer.id && (dragOverLayerId = null)"
463
+ @drop="onLayerDrop($event, layer)"
464
+ >
465
+ {{
466
+ draggingTreeNodeId
467
+ ? getLayerDropState(layer).canDropIntoLayer
468
+ ? "释放以移动到该图层"
469
+ : getLayerDropState(layer).dropBlockReason || "不可移动到该图层"
470
+ : "拖拽节点到该图层"
471
+ }}
472
+ </div>
473
+
474
+ <div v-show="isExpanded(`layer:${layer.id}`, true)">
475
+ <div
476
+ v-if="getRootNodes(layer.id).length === 0"
477
+ class="rounded border border-border/40 bg-muted/15 py-2 pl-3 text-[11px] text-muted-foreground"
478
+ >
479
+ 空图层
480
+ </div>
481
+ <MaterialSidebarTreeNode
482
+ v-for="node in getRootNodes(layer.id)"
483
+ :key="node.id"
484
+ :node="node"
485
+ :level="2"
486
+ :path="`${layer.id}/${node.id}`"
487
+ :visited="new Set()"
488
+ :selected-ids="selectedIds"
489
+ :layer-by-id="layerById"
490
+ :elements-by-layer="elementsByLayer"
491
+ :children-by-grid-by-layer="childrenByGridByLayer"
492
+ :expanded-keys="expandedKeys"
493
+ :normalized-tree-keyword="normalizedTreeKeyword"
494
+ :is-tree-searching="isTreeSearching"
495
+ :dragging-tree-node-id="draggingTreeNodeId"
496
+ @toggle-expanded="setExpanded"
497
+ @select-node="(id, lid) => onSelectNode?.(id, lid)"
498
+ @node-context-menu="(p) => onNodeContextMenu?.(p)"
499
+ @delete-node="(id) => onDeleteNode?.(id)"
500
+ @copy-node="(id, mode) => onCopyNode?.(id, mode)"
501
+ @drag-start="(id) => (draggingTreeNodeId = id)"
502
+ @drag-end="
503
+ draggingTreeNodeId = null;
504
+ dragOverLayerId = null;
505
+ "
506
+ />
507
+ </div>
508
+ </Card>
509
+ </div>
510
+ </template>
511
+
512
+ <Empty v-if="treeSearchEmpty" class="mx-2 my-2 py-5" description="未匹配到节点">
513
+ <template #description>
514
+ <span class="text-xs">未匹配到节点</span>
515
+ <div class="text-[11px] text-muted-foreground">
516
+ 试试节点名称、类型或 ID 关键词。
517
+ </div>
518
+ </template>
519
+ </Empty>
520
+ </div>
521
+ </div>
522
+ </div>
523
+ </Tabs.TabPane>
524
+ </Tabs>
525
+ </aside>
526
+ </template>