@arronqzy/vue-view 0.1.20 → 0.1.22

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.
package/README.md CHANGED
@@ -1,16 +1,22 @@
1
1
  # @arronqzy/vue-view
2
2
 
3
- Vue 3 版 Abuilder **视图编辑器**,使用 **Ant Design Vue** 作为 UI 组件库,功能与 `@arronqzy/react-view` 对齐。
3
+ Vue 3 视图编辑器,功能对齐 `@arronqzy/react-view`,组件用 Ant Design Vue。大多数 Vue 宿主应直接用 [`@arronqzy/abuilder-vue`](../abuilder-vue/README.md)。
4
4
 
5
- ## 功能
5
+ ![物料、画布、配置和图层](https://github.com/qianzhuoyao/APanel5/raw/v5/docs/guide-assets/live-zh/09-table-full.png)
6
6
 
7
- - **画布**:无限平移/缩放(Infinite Viewer)、Moveable 拖拽/缩放/旋转、Selecto 框选
8
- - **物料**:图表(ECharts)、文本、图片、音视频、几何、网格、引用节点
9
- - **图层**:多图层、映射图层、主图层、锁定与合并
10
- - **配置侧栏**:按物料类型的完整属性编辑、Scope 模版与预览
11
- - **工作区**:IndexedDB 多项目、导入导出、跨标签页同步
12
- - **蓝图集成**:分屏蓝图编辑器、调试 Scope 绑定
13
- - **在线预览**:`VueViewOnlinePreview` 独立预览页
7
+ ## 能力
8
+
9
+ - 无限平移 / 缩放(Infinite Viewer)、Moveable、Selecto 框选
10
+ - 图表、文本、表格、图片、音视频、几何、网格、三维、引用节点
11
+ - 多图层、映射图层、主图层、锁定与合并
12
+ - 按物料类型的配置侧栏,支持 Scope 模版
13
+ - IndexedDB 多项目、导入导出、跨标签同步
14
+ - 分屏蓝图(`@arronqzy/vue-blueprint`)和调试时的 Scope 回写
15
+ - `VueViewOnlinePreview` 只读预览
16
+
17
+ 打开蓝图后的分屏和 React 版一样:上面视图,下面节点:
18
+
19
+ ![分屏蓝图](https://github.com/qianzhuoyao/APanel5/raw/v5/docs/guide-assets/live-zh/40-blueprint-open.png)
14
20
 
15
21
  ## 使用
16
22
 
@@ -29,7 +35,7 @@ createApp(VueViewPanel).use(Antd).mount("#app");
29
35
  import { VueViewOnlinePreview, parseOnlinePreviewSearchParams } from "@arronqzy/vue-view";
30
36
  ```
31
37
 
32
- URL 参数:`?preview=online&projectId=<id>&pid=<instanceId>`。也可传入 `workspace` 直接预览宿主数据(优先于 `projectId`)。
38
+ URL:`?preview=online&projectId=<id>&pid=<instanceId>`。传入 `workspace` 时优先于 `projectId`。
33
39
 
34
40
  ### 宿主集成
35
41
 
@@ -43,21 +49,19 @@ import {
43
49
  } from "@arronqzy/vue-view";
44
50
  ```
45
51
 
46
- 创建 / 同步工作区会发出 `workspace:add` / `workspace:sync`。`parseWorkspaceData` 的 `value` 可作 `initialWorkspace`。IndexedDB CRUD 与 `getPreviewSnapshot` **不导出**(后者仅 React 实现)。
52
+ 创建 / 同步发出 `workspace:add` / `workspace:sync`。`parseWorkspaceData` 的 `value` 可作 `initialWorkspace`。
47
53
 
48
- ## 依赖
54
+ IndexedDB CRUD 与 `getPreviewSnapshot` **不导出**。缩略图只在 React 包实现。
55
+
56
+ ## 和 React 版的差别
49
57
 
50
- - `@arronqzy/rx-store` — 画布状态
51
- - `@arronqzy/vue-rx-store` — Vue composables
52
- - `@arronqzy/vue-blueprint` — 蓝图编辑器
53
- - `@arronqzy/blueprint-dsl` — 蓝图 DSL
54
- - `ant-design-vue`、`echarts`、`moveable`、`selecto`、`infinite-viewer`
58
+ - UI 是 Ant Design Vue
59
+ - 标尺是 canvas 实现(可换成 `@scena/ruler`)
60
+ - 画布状态仍是共享的 `@arronqzy/rx-store`
55
61
 
56
- ## 与 React 版差异
62
+ ## 依赖
57
63
 
58
- - UI 使用 **Ant Design Vue**,非 shadcn/Radix
59
- - 标尺使用 canvas 兼容实现(可替换为 `@scena/ruler`)
60
- - 共享 `@arronqzy/rx-store` 与 `panel/utils` 数据逻辑
64
+ `@arronqzy/rx-store`、`@arronqzy/vue-rx-store`、`@arronqzy/vue-blueprint`、`@arronqzy/blueprint-dsl`、`ant-design-vue`、`echarts`、`moveable`、`selecto`、`infinite-viewer`。
61
65
 
62
66
  ## 许可证
63
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arronqzy/vue-view",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "Vue 3 visual view editor for Abuilder (Ant Design Vue)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,13 +23,13 @@
23
23
  "selecto": "^1.26.3",
24
24
  "uuid": "^13.0.0",
25
25
  "vue": "^3.5.13",
26
- "@arronqzy/blueprint-dsl": "1.0.15",
27
- "@arronqzy/i18n": "0.1.14",
26
+ "@arronqzy/blueprint-dsl": "1.0.17",
27
+ "@arronqzy/i18n": "0.1.17",
28
28
  "@arronqzy/view-table": "0.1.8",
29
- "@arronqzy/view-scene3d": "0.1.5",
30
- "@arronqzy/vue-blueprint": "0.1.14",
29
+ "@arronqzy/rx-store": "1.0.8",
30
+ "@arronqzy/vue-blueprint": "0.1.17",
31
31
  "@arronqzy/vue-rx-store": "0.1.6",
32
- "@arronqzy/rx-store": "1.0.8"
32
+ "@arronqzy/view-scene3d": "0.1.5"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "vue": "^3.4.0"
package/src/index.ts CHANGED
@@ -22,6 +22,8 @@ export type {
22
22
  AbuilderEventPayloadMap,
23
23
  WorkspaceAddEventPayload,
24
24
  WorkspaceSyncEventPayload,
25
+ ApiCollectionUploadStartEventPayload,
26
+ ApiCollectionUploadSuccessEventPayload,
25
27
  WorkspaceData,
26
28
  } from "./panel/library/event-subscription";
27
29
  export type {
@@ -2,6 +2,7 @@
2
2
  import { useI18n } from "@arronqzy/i18n/vue";
3
3
  import { computed, onMounted, onUnmounted, ref, watch } from "vue";
4
4
  import {
5
+ useBlueprintDslLocaleSync,
5
6
  BlueprintGraph,
6
7
  documentToRunnableGraph,
7
8
  getBlueprintLibraryRecord,
@@ -22,6 +23,7 @@ import {
22
23
  useViewScopeStoreVersion,
23
24
  } from "./scope/view-scope-store";
24
25
  import { resolvePanelElementScope } from "./utils/scope-template";
26
+ import { localizePanelElement } from "./utils/panelElementDefaults";
25
27
  import {
26
28
  getWorkspaceProject,
27
29
  type WorkspaceProjectRecord,
@@ -40,6 +42,7 @@ import {
40
42
  import { applyPreviewSceneFill, readOutputScale } from "./utils/outputScale";
41
43
 
42
44
  const { t, locale } = useI18n();
45
+ useBlueprintDslLocaleSync();
43
46
 
44
47
  const PREVIEW_BOOT_PHASES: PageLifecyclePhase[] = ["mounted"];
45
48
 
@@ -194,7 +197,9 @@ const scopedLayerElements = computed(() => {
194
197
  const sceneBounds = computed(() => computePanelSceneBounds(scopedLayerElements.value));
195
198
 
196
199
  const displayElements = computed(() =>
197
- scopedLayerElements.value.map((el) => {
200
+ scopedLayerElements.value.map((source) => {
201
+ void locale.value;
202
+ const el = localizePanelElement(source, t);
198
203
  const x = el.x - sceneBounds.value.minX;
199
204
  const y = el.y - sceneBounds.value.minY;
200
205
  if (outputScale.value) return { ...el, x, y };
@@ -2,6 +2,8 @@
2
2
  import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from "vue";
3
3
  import type { State } from "@arronqzy/rx-store";
4
4
  import {
5
+ useBlueprintDslLocaleSync,
6
+ ApiCollectionManagerDialog,
5
7
  BlueprintGraph,
6
8
  abortClockNode,
7
9
  blueprintDocumentsEqual,
@@ -12,6 +14,7 @@ import {
12
14
  documentToRunnableGraph,
13
15
  downloadBlueprintExport,
14
16
  getBlueprintLibraryRecord,
17
+ hydrateApiCollections,
15
18
  libraryRecordFromImport,
16
19
  listBlueprintLibrary,
17
20
  parseBlueprintImportFile,
@@ -27,7 +30,7 @@ import {
27
30
  resolveRunnableNodeType,
28
31
  resolveViewElementIds,
29
32
  } from "@arronqzy/vue-blueprint";
30
- import { collectArmedViewEventBindings, EVENT_NODE_TYPE, LIFECYCLE_NODE_TYPE, type LibraryBlueprintResolver } from "@arronqzy/blueprint-dsl";
33
+ import { collectArmedViewEventBindings, EVENT_NODE_TYPE, LIFECYCLE_NODE_TYPE, buildApiCollectionSyncPatches, toApiCollectionSyncResult, type LibraryBlueprintResolver } from "@arronqzy/blueprint-dsl";
31
34
  import {
32
35
  Button,
33
36
  Checkbox,
@@ -49,6 +52,7 @@ import {
49
52
  useWorkspaceProjects,
50
53
  } from "./hooks/useWorkspaceProjects";
51
54
  import type { WorkspaceProjectRecord } from "./library/workspace-project-db";
55
+ import { AbuilderEvents, emitAbuilderEvent } from "./library/event-subscription";
52
56
  import { type ViewportZoom } from "./viewportZoom";
53
57
  import PanelCanvas from "./components/PanelCanvas.vue";
54
58
  import ElementsLayer from "./components/ElementsLayer.vue";
@@ -69,12 +73,14 @@ import {
69
73
  import { resolvePanelElementScope } from "./utils/scope-template";
70
74
  import { useRafThrottledScroll } from "./hooks/useRafThrottledScroll";
71
75
  import { useI18n } from "@arronqzy/i18n/vue";
76
+ import { relocalizeMessage } from "@arronqzy/i18n";
72
77
  import { getPanelMessages } from "./constants/messages";
73
78
  import BusyOverlay from "./components/BusyOverlay.vue";
74
79
  import { assertFileSize, parseJsonText, runBusyTask } from "./utils/async-work";
75
80
  import "../tailwind.css";
76
81
 
77
82
  const { t, locale, setLocale } = useI18n();
83
+ useBlueprintDslLocaleSync();
78
84
  const panelMessages = () => getPanelMessages(t);
79
85
 
80
86
  const props = withDefaults(
@@ -149,6 +155,18 @@ const workspaceBlueprintRef = shallowRef<{
149
155
  } | null>(null);
150
156
  const configFocus = ref<WorkspaceConfigFocus>("view");
151
157
  const productName = ref(t("panel.defaults.unnamedProduct"));
158
+ const displayProductName = computed(() => {
159
+ void locale.value;
160
+ return relocalizeMessage(productName.value, "panel.defaults.unnamedProduct", t);
161
+ });
162
+ const displayBlueprintName = computed(() => {
163
+ void locale.value;
164
+ return relocalizeMessage(
165
+ blueprintMeta.value.name,
166
+ ["panel.defaults.unnamedBlueprint", "blueprint.dialog.unnamedBlueprint"],
167
+ t
168
+ );
169
+ });
152
170
  const titleIconDataUrl = ref("");
153
171
  const editingLayerId = ref<string | null>(null);
154
172
  const editingLayerName = ref("");
@@ -158,6 +176,7 @@ const leftWidth = ref(240);
158
176
  const rightWidth = ref(300);
159
177
  const importInputRef = ref<HTMLInputElement | null>(null);
160
178
  const blueprintImportInputRef = ref<HTMLInputElement | null>(null);
179
+ const apiCollectionManagerOpen = ref(false);
161
180
 
162
181
  const viewportEl = ref<HTMLDivElement | null>(null);
163
182
  const canvasEl = ref<HTMLDivElement | null>(null);
@@ -342,7 +361,7 @@ function handleViewScopeUpdate(viewElementIds: string[], scope: unknown) {
342
361
  const blueprintDebugSession = useBlueprintDebugSession({
343
362
  graph: blueprintGraph,
344
363
  blueprintId: activeBlueprintLibraryId,
345
- blueprintName: computed(() => { void locale.value; return blueprintMeta.value.name || t("panel.defaults.unnamedBlueprint"); }),
364
+ blueprintName: computed(() => displayBlueprintName.value || t("panel.defaults.unnamedBlueprint")),
346
365
  resolveLibraryBlueprint,
347
366
  libraryNameById: blueprintLibraryNameById,
348
367
  onExecutionBlocked: handleBlueprintExecutionBlocked,
@@ -391,6 +410,16 @@ async function refreshBlueprintLibrary() {
391
410
  blueprintLibraryItems.value = await listBlueprintLibrary(props.nameSpace);
392
411
  }
393
412
 
413
+ watch(
414
+ () => props.nameSpace,
415
+ (nameSpace) => {
416
+ void hydrateApiCollections(nameSpace).catch((error) => {
417
+ console.error("[abuilder] hydrate api collections failed", error);
418
+ });
419
+ },
420
+ { immediate: true }
421
+ );
422
+
394
423
  onMounted(() => {
395
424
  void refreshBlueprintLibrary();
396
425
  });
@@ -604,6 +633,50 @@ function openBlueprintExport() {
604
633
  });
605
634
  }
606
635
 
636
+ function handleApiCollectionUploadStart(info: {
637
+ fileNames: string[];
638
+ count: number;
639
+ }) {
640
+ emitAbuilderEvent(AbuilderEvents.apiCollectionUploadStart, info);
641
+ }
642
+
643
+ function handleApiCollectionUploadSuccess(info: {
644
+ collections: Array<{
645
+ id: string;
646
+ name: string;
647
+ apiCount: number;
648
+ persistLocal: boolean;
649
+ }>;
650
+ }) {
651
+ emitAbuilderEvent(AbuilderEvents.apiCollectionUploadSuccess, info);
652
+ }
653
+
654
+ function handleApiCollectionSync(record: {
655
+ id: string;
656
+ document: import("@arronqzy/blueprint-dsl").ApiCollectionDocument;
657
+ }) {
658
+ const patches = buildApiCollectionSyncPatches({
659
+ collectionId: record.id,
660
+ document: record.document,
661
+ nodes: blueprintGraph.value.document.nodes
662
+ .filter((node) => node.role === "fetch" || Boolean(node.fetchConfig))
663
+ .map((node) => ({
664
+ id: node.id,
665
+ label: node.label,
666
+ fetchConfig: node.fetchConfig,
667
+ })),
668
+ });
669
+ if (patches.length === 0) return toApiCollectionSyncResult([]);
670
+ let next = blueprintGraph.value;
671
+ for (const patch of patches) {
672
+ next = next.updateNode(patch.nodeId, {
673
+ fetchConfig: patch.fetchConfigPatch as import("@arronqzy/blueprint-dsl").FetchRequestConfig,
674
+ });
675
+ }
676
+ blueprintGraph.value = next;
677
+ return toApiCollectionSyncResult(patches);
678
+ }
679
+
607
680
  async function handleBlueprintImportFile(file: File) {
608
681
  try {
609
682
  await runBusyTask(t("common.importingBlueprint"), async () => {
@@ -757,6 +830,8 @@ onUnmounted(() => window.removeEventListener("keydown", onKeyDown));
757
830
  <Menu>
758
831
  <Menu.Item @click="openBlueprintExport">{{ t("panel.menubar.exportBlueprint") }}</Menu.Item>
759
832
  <Menu.Item @click="blueprintImportInputRef?.click()">{{ t("panel.menubar.importBlueprint") }}</Menu.Item>
833
+ <Menu.Divider />
834
+ <Menu.Item @click="apiCollectionManagerOpen = true">{{ t("panel.menubar.apiCollections") }}</Menu.Item>
760
835
  </Menu>
761
836
  </template>
762
837
  </Dropdown>
@@ -832,8 +907,9 @@ onUnmounted(() => window.removeEventListener("keydown", onKeyDown));
832
907
  <div class="flex h-full min-h-0 flex-col">
833
908
  <div class="flex flex-wrap items-center gap-2 border-b bg-white px-3 py-2">
834
909
  <Input
835
- v-model:value="productName"
910
+ :value="displayProductName"
836
911
  size="small"
912
+ @update:value="(value: string) => (productName = value)"
837
913
  class="w-[200px]"
838
914
  :placeholder="t('panel.menubar.productName')"
839
915
  />
@@ -859,7 +935,7 @@ onUnmounted(() => window.removeEventListener("keydown", onKeyDown));
859
935
  :blueprint-props="blueprintCanvasProps"
860
936
  :blueprint-library-items="blueprintLibraryItems"
861
937
  :active-blueprint-library-id="activeBlueprintLibraryId"
862
- :current-blueprint-label="blueprintMeta.name"
938
+ :current-blueprint-label="displayBlueprintName"
863
939
  :can-sync-blueprint="blueprintLibraryDirty"
864
940
  :blueprint-debug="blueprintDebugToolbar"
865
941
  :on-select-blueprint-library-item="(id) => void handleSelectBlueprintLibraryItem(id)"
@@ -1021,6 +1097,14 @@ onUnmounted(() => window.removeEventListener("keydown", onKeyDown));
1021
1097
  <Input v-model:value="mergeLayerName" :placeholder="t('panel.layers.mergedNamePlaceholderOptional')" />
1022
1098
  <div class="mt-2 text-xs text-gray-500">{{ t("panel.layers.mergeSelectedCount", { count: mergeSelectedCount }) }}</div>
1023
1099
  </Modal>
1100
+
1101
+ <ApiCollectionManagerDialog
1102
+ :open="apiCollectionManagerOpen"
1103
+ :on-open-change="(open) => (apiCollectionManagerOpen = open)"
1104
+ :on-upload-start="handleApiCollectionUploadStart"
1105
+ :on-upload-success="handleApiCollectionUploadSuccess"
1106
+ :on-sync-collection="handleApiCollectionSync"
1107
+ />
1024
1108
  </Layout>
1025
1109
  </template>
1026
1110
 
@@ -26,11 +26,24 @@ const resolvedLabel = computed(() => props.label || t("common.hint"));
26
26
  type="button"
27
27
  :class="
28
28
  buttonClass ??
29
- 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-border text-[10px] leading-none text-muted-foreground hover:bg-accent/50'
29
+ 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring'
30
30
  "
31
31
  :aria-label="t('common.hintAria', { label: resolvedLabel })"
32
32
  >
33
- ?
33
+ <svg
34
+ viewBox="0 0 24 24"
35
+ fill="none"
36
+ stroke="currentColor"
37
+ stroke-width="2"
38
+ stroke-linecap="round"
39
+ stroke-linejoin="round"
40
+ class="h-3.5 w-3.5"
41
+ aria-hidden="true"
42
+ >
43
+ <circle cx="12" cy="12" r="10" />
44
+ <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
45
+ <path d="M12 17h.01" />
46
+ </svg>
34
47
  </button>
35
48
  </Tooltip>
36
49
  </template>
@@ -141,7 +141,12 @@ function onPosterFileChange(e: Event) {
141
141
  >
142
142
  <ConfigFieldGroup :title="t('panel.config.groupAudioSource')">
143
143
  <label class="block space-y-1" data-config-field="audioRemoteUrl">
144
- <div>{{ t("panel.config.audioUrl") }}</div>
144
+ <div class="flex items-center gap-1">
145
+ {{ t("panel.config.audioUrl") }}
146
+ <ConfigHintIcon :label="t('panel.config.audioUrl')">
147
+ {{ t("panel.config.urlScopeHint") }}
148
+ </ConfigHintIcon>
149
+ </div>
145
150
  <Input
146
151
  size="small"
147
152
  class="font-mono text-[11px]"
@@ -152,9 +157,7 @@ function onPosterFileChange(e: Event) {
152
157
  audioRemoteUrl: v || undefined,
153
158
  audioSrc: v || element.audioSrc,
154
159
  })"
155
- />
156
- <p class="text-[10px] text-gray-500">{{ t("panel.config.urlScopeHint") }}</p>
157
- </label>
160
+ /> </label>
158
161
  <div class="flex items-center gap-2">
159
162
  <label
160
163
  class="inline-flex cursor-pointer items-center rounded border border-gray-200 px-2 py-1 text-[11px] hover:bg-gray-50"
@@ -146,27 +146,33 @@ function onOptionJsonChange(v: string) {
146
146
  </ConfigFieldGroup>
147
147
  <ConfigFieldGroup :title="t('panel.config.groupData')">
148
148
  <label class="block space-y-1" data-config-field="chart.labelsText">
149
- <div>{{ t("panel.config.labelsCsv") }}</div>
149
+ <div class="flex items-center gap-1">
150
+ {{ t("panel.config.labelsCsv") }}
151
+ <ConfigHintIcon :label="t('panel.config.labelsCsv')">
152
+ {{ t("panel.config.scopeTemplateHint") }}
153
+ </ConfigHintIcon>
154
+ </div>
150
155
  <Input
151
156
  size="small"
152
157
  :value="getChartLabelsDisplayText(element.chart)"
153
158
  :disabled="!isEditable"
154
159
  :placeholder="t('panel.config.labelsCsvPlaceholder')"
155
160
  @update:value="(v: string) => updateChart({ labelsText: v })"
156
- />
157
- <p class="text-[10px] text-gray-500">{{ t("panel.config.scopeTemplateHint") }}</p>
158
- </label>
161
+ /> </label>
159
162
  <label class="block space-y-1" data-config-field="chart.valuesText">
160
- <div>{{ t("panel.config.valuesCsv") }}</div>
163
+ <div class="flex items-center gap-1">
164
+ {{ t("panel.config.valuesCsv") }}
165
+ <ConfigHintIcon :label="t('panel.config.valuesCsv')">
166
+ {{ t("panel.config.scopeTemplateHint") }}
167
+ </ConfigHintIcon>
168
+ </div>
161
169
  <Input
162
170
  size="small"
163
171
  :value="getChartValuesDisplayText(element.chart)"
164
172
  :disabled="!isEditable"
165
173
  :placeholder="t('panel.config.valuesCsvPlaceholder')"
166
174
  @update:value="(v: string) => updateChart({ valuesText: v })"
167
- />
168
- <p class="text-[10px] text-gray-500">{{ t("panel.config.scopeTemplateHint") }}</p>
169
- </label>
175
+ /> </label>
170
176
  </ConfigFieldGroup>
171
177
 
172
178
  <ConfigFieldGroup
@@ -74,7 +74,8 @@ function onFileChange(e: Event) {
74
74
  <div class="flex items-center gap-1">
75
75
  <span>{{ t("panel.config.imageUrlCss") }}</span>
76
76
  <ConfigHintIcon :label="t('panel.config.imageUrlHintLabel')">
77
- {{ t("panel.config.imageUrlHint") }}
77
+ <p>{{ t("panel.config.imageUrlHint") }}</p>
78
+ <p class="mt-1">{{ t("panel.config.urlScopeHint") }}</p>
78
79
  </ConfigHintIcon>
79
80
  </div>
80
81
  <Input
@@ -85,7 +86,6 @@ function onFileChange(e: Event) {
85
86
  :placeholder="t('panel.config.urlScopePlaceholder')"
86
87
  @update:value="(v: string) => patchStyle(patchBackgroundImageFromInput(String(v ?? '')))"
87
88
  />
88
- <p class="text-[10px] text-gray-500">{{ t("panel.config.urlScopeHint") }}</p>
89
89
  </label>
90
90
  <div class="flex items-center gap-2">
91
91
  <label
@@ -10,6 +10,7 @@ import {
10
10
  type ViewportOverflowMode,
11
11
  } from "../../utils/viewportPlacement";
12
12
  import { patchBackgroundImageFromInput } from "../../utils/background-image-style";
13
+ import ConfigHintIcon from "../ConfigHintIcon.vue";
13
14
 
14
15
  const { t, locale } = useI18n();
15
16
  const props = defineProps<{
@@ -434,7 +435,12 @@ function setNodeCardExpanded(id: string, open: boolean) {
434
435
 
435
436
  <template v-if="el.materialType === 'image'">
436
437
  <label class="block space-y-1" data-config-field="style.backgroundImageRemoteUrl">
437
- <div>{{ t("panel.config.backgroundImageUrl") }}</div>
438
+ <div class="flex items-center gap-1">
439
+ {{ t("panel.config.backgroundImageUrl") }}
440
+ <ConfigHintIcon :label="t('panel.config.backgroundImageUrl')">
441
+ {{ t("panel.config.urlScopeHint") }}
442
+ </ConfigHintIcon>
443
+ </div>
438
444
  <Input
439
445
  size="small"
440
446
  class="font-mono text-[11px]"
@@ -447,7 +453,6 @@ function setNodeCardExpanded(id: string, open: boolean) {
447
453
  },
448
454
  })"
449
455
  />
450
- <p class="text-[10px] text-gray-500">{{ t("panel.config.urlScopeHint") }}</p>
451
456
  </label>
452
457
  <label class="block space-y-1">
453
458
  <div>{{ t("panel.config.objectFit") }}</div>
@@ -344,6 +344,7 @@ function onTagHtmlInput(modelId: string, objectName: string, event: Event) {
344
344
  @update:open="emit('update:open', $event)"
345
345
  >
346
346
  <ConfigFieldGroup :title="t('panel.config.groupScene3dPurpose')">
347
+ <template #hint>{{ t("panel.config.scene3dPurposeHint") }}</template>
347
348
  <Select
348
349
  size="small"
349
350
  class="w-full"
@@ -356,7 +357,6 @@ function onTagHtmlInput(modelId: string, objectName: string, event: Event) {
356
357
  ]"
357
358
  @update:value="(v) => patch(purposePresetPatch(String(v) as Scene3dPurpose))"
358
359
  />
359
- <div class="text-[10px] text-gray-500">{{ t("panel.config.scene3dPurposeHint") }}</div>
360
360
  </ConfigFieldGroup>
361
361
 
362
362
  <ConfigFieldGroup :title="t('panel.config.groupScene3dModels')">
@@ -374,7 +374,12 @@ function onTagHtmlInput(modelId: string, objectName: string, event: Event) {
374
374
  />
375
375
  </div>
376
376
  <label class="block space-y-1" data-config-field="scene3d.modelUrl">
377
- <div class="text-[10px] text-gray-500">{{ t("panel.config.scene3dModelUrl") }}</div>
377
+ <div class="flex items-center gap-1 text-[10px] text-gray-500">
378
+ {{ t("panel.config.scene3dModelUrl") }}
379
+ <ConfigHintIcon :label="t('panel.config.scene3dModelUrl')">
380
+ {{ t("panel.config.urlScopeHint") }}
381
+ </ConfigHintIcon>
382
+ </div>
378
383
  <div class="flex gap-1.5">
379
384
  <Input
380
385
  size="small"
@@ -392,7 +397,6 @@ function onTagHtmlInput(modelId: string, objectName: string, event: Event) {
392
397
  {{ t("common.add") }}
393
398
  </Button>
394
399
  </div>
395
- <p class="text-[10px] text-gray-500">{{ t("panel.config.urlScopeHint") }}</p>
396
400
  </label>
397
401
  <div v-if="config.models.length === 0" class="text-[11px] text-gray-500">
398
402
  {{ t("panel.config.scene3dNoModels") }}
@@ -477,7 +481,7 @@ function onTagHtmlInput(modelId: string, objectName: string, event: Event) {
477
481
  </ConfigFieldGroup>
478
482
 
479
483
  <ConfigFieldGroup :title="t('panel.config.groupScene3dCamera')">
480
- <div class="text-[10px] text-gray-500">{{ t("panel.config.scene3dCameraEditDesc") }}</div>
484
+ <template #hint>{{ t("panel.config.scene3dCameraEditDesc") }}</template>
481
485
  <div class="grid grid-cols-3 gap-1">
482
486
  <Input
483
487
  v-for="(axis, index) in ['x', 'y', 'z']"
@@ -9,6 +9,7 @@ import { getPanelMessages } from "../../constants/messages";
9
9
  import ConfigColorField from "./ConfigColorField.vue";
10
10
  import ConfigFieldGroup from "./ConfigFieldGroup.vue";
11
11
  import ConfigSection from "./ConfigSection.vue";
12
+ import ConfigHintIcon from "../ConfigHintIcon.vue";
12
13
 
13
14
  const { t, locale } = useI18n();
14
15
  const msgs = () => getPanelMessages(t);
@@ -89,7 +90,12 @@ function onFileChange(e: Event) {
89
90
  :default-open="false"
90
91
  >
91
92
  <label class="block space-y-1" data-config-field="style.backgroundImage">
92
- <div>{{ t("panel.config.backgroundImage") }}</div>
93
+ <div class="flex items-center gap-1">
94
+ {{ t("panel.config.backgroundImage") }}
95
+ <ConfigHintIcon :label="t('panel.config.backgroundImage')">
96
+ {{ t("panel.config.urlScopeHint") }}
97
+ </ConfigHintIcon>
98
+ </div>
93
99
  <Input
94
100
  size="small"
95
101
  class="font-mono text-[11px]"
@@ -97,9 +103,7 @@ function onFileChange(e: Event) {
97
103
  :disabled="!isEditable"
98
104
  :placeholder="t('panel.config.urlScopePlaceholder')"
99
105
  @update:value="(v: string) => patchStyle(patchBackgroundImageFromInput(String(v ?? '')))"
100
- />
101
- <p class="text-[10px] text-gray-500">{{ t("panel.config.urlScopeHint") }}</p>
102
- </label>
106
+ /> </label>
103
107
  <div class="flex items-center gap-2">
104
108
  <label
105
109
  class="inline-flex cursor-pointer items-center rounded border border-gray-200 px-2 py-1 text-[11px] hover:bg-gray-50"
@@ -7,6 +7,7 @@ import { cssTextLineHeight, cssTextAlignStyle } from "../../utils/panelElementDe
7
7
  import ConfigColorField from "./ConfigColorField.vue";
8
8
  import ConfigFieldGroup from "./ConfigFieldGroup.vue";
9
9
  import ConfigSection from "./ConfigSection.vue";
10
+ import ConfigHintIcon from "../ConfigHintIcon.vue";
10
11
 
11
12
  const { t, locale } = useI18n();
12
13
  const props = defineProps<{
@@ -106,7 +107,12 @@ watch(() => [props.element.id, props.element.textHtml], syncEditorHtml);
106
107
  }"
107
108
  />
108
109
  <label class="block space-y-1">
109
- <div>{{ t("panel.config.textHtmlSource") }}</div>
110
+ <div class="flex items-center gap-1">
111
+ {{ t("panel.config.textHtmlSource") }}
112
+ <ConfigHintIcon :label="t('panel.config.textHtmlSource')">
113
+ {{ t("panel.config.scopeTemplateHint") }}
114
+ </ConfigHintIcon>
115
+ </div>
110
116
  <textarea
111
117
  data-config-field="textHtml"
112
118
  class="w-full rounded border border-gray-200 bg-white px-2 py-1.5 font-mono text-[11px] leading-relaxed outline-none"
@@ -116,9 +122,7 @@ watch(() => [props.element.id, props.element.textHtml], syncEditorHtml);
116
122
  :disabled="!isEditable"
117
123
  :placeholder="t('panel.config.textHtmlSourcePlaceholder')"
118
124
  @input="(e) => patch({ textHtml: (e.target as HTMLTextAreaElement).value || '<p><br/></p>' })"
119
- />
120
- <p class="text-[10px] text-gray-500">{{ t("panel.config.scopeTemplateHint") }}</p>
121
- </label>
125
+ /> </label>
122
126
  </ConfigFieldGroup>
123
127
  <ConfigFieldGroup :title="t('panel.config.groupTextStyle')">
124
128
  <label class="block space-y-1">
@@ -7,6 +7,7 @@ import { readFileAsDataUrl, uploadFileToRemote } from "./shared";
7
7
  import { getPanelMessages } from "../../constants/messages";
8
8
  import ConfigFieldGroup from "./ConfigFieldGroup.vue";
9
9
  import ConfigSection from "./ConfigSection.vue";
10
+ import ConfigHintIcon from "../ConfigHintIcon.vue";
10
11
 
11
12
  const { t, locale } = useI18n();
12
13
  const msgs = () => getPanelMessages(t);
@@ -67,7 +68,12 @@ function onVideoFileChange(e: Event) {
67
68
  >
68
69
  <ConfigFieldGroup :title="t('panel.config.groupVideoSource')">
69
70
  <label class="block space-y-1" data-config-field="videoRemoteUrl">
70
- <div>{{ t("panel.config.videoUrl") }}</div>
71
+ <div class="flex items-center gap-1">
72
+ {{ t("panel.config.videoUrl") }}
73
+ <ConfigHintIcon :label="t('panel.config.videoUrl')">
74
+ {{ t("panel.config.urlScopeHint") }}
75
+ </ConfigHintIcon>
76
+ </div>
71
77
  <Input
72
78
  size="small"
73
79
  class="font-mono text-[11px]"
@@ -78,9 +84,7 @@ function onVideoFileChange(e: Event) {
78
84
  videoRemoteUrl: v || undefined,
79
85
  videoSrc: v || element.videoSrc,
80
86
  })"
81
- />
82
- <p class="text-[10px] text-gray-500">{{ t("panel.config.urlScopeHint") }}</p>
83
- </label>
87
+ /> </label>
84
88
  <div class="flex items-center gap-2">
85
89
  <label
86
90
  class="inline-flex cursor-pointer items-center rounded border border-gray-200 px-2 py-1 text-[11px] hover:bg-gray-50"
@@ -69,17 +69,13 @@ function closeDropdown() {
69
69
  }
70
70
 
71
71
  function refreshForInput(input: HTMLInputElement | HTMLTextAreaElement) {
72
- if (props.scope === undefined) {
73
- closeDropdown();
74
- return;
75
- }
76
72
  const cursor = input.selectionStart ?? input.value.length;
77
- const parsed = parseScopeAutocomplete(input.value, cursor, props.scope);
73
+ const parsed = parseScopeAutocomplete(input.value, cursor, props.scope ?? {});
78
74
  if (!parsed) {
79
75
  closeDropdown();
80
76
  return;
81
77
  }
82
- const suggestions = getScopeAutocompleteSuggestions(props.scope, parsed);
78
+ const suggestions = getScopeAutocompleteSuggestions(props.scope ?? {}, parsed);
83
79
  if (suggestions.length === 0) {
84
80
  closeDropdown();
85
81
  return;
@@ -173,7 +169,7 @@ function onPointerDown(event: MouseEvent) {
173
169
  watch(
174
170
  () => toValue(props.containerRef),
175
171
  (container, _prev, onCleanup) => {
176
- if (!container || props.scope === undefined) return;
172
+ if (!container) return;
177
173
 
178
174
  container.addEventListener("focusin", onFocusIn);
179
175
  container.addEventListener("input", onInput, true);
@@ -197,13 +193,14 @@ watch(
197
193
  watch(
198
194
  () => props.scope,
199
195
  () => {
200
- if (props.scope === undefined) closeDropdown();
196
+ const current = dropdown.value;
197
+ if (current) refreshForInput(current.input);
201
198
  }
202
199
  );
203
200
  </script>
204
201
 
205
202
  <template>
206
- <Teleport v-if="dropdown && scope !== undefined" to="body">
203
+ <Teleport v-if="dropdown" to="body">
207
204
  <div
208
205
  data-scope-ac-list
209
206
  class="fixed z-[10120] max-h-44 min-w-[140px] overflow-auto rounded-md border border-border bg-popover py-1 text-popover-foreground shadow-md"
@@ -7,5 +7,6 @@ const { t } = useI18n();
7
7
  <div class="space-y-2">
8
8
  <p>{{ t("panel.scope.usageHint1") }}</p>
9
9
  <p>{{ t("panel.scope.usageHint2") }}</p>
10
+ <p>{{ t("panel.scope.usageHintSystem") }}</p>
10
11
  </div>
11
12
  </template>
@@ -13,6 +13,7 @@ import {
13
13
  import { useI18n } from "@arronqzy/i18n/vue";
14
14
  import { Button, Input, Select } from "ant-design-vue";
15
15
  import { computed } from "vue";
16
+ import ConfigHintIcon from "../ConfigHintIcon.vue";
16
17
 
17
18
  const props = withDefaults(
18
19
  defineProps<{
@@ -92,7 +93,12 @@ function patchLeaf(index: number, patch: Partial<ConditionLeaf>) {
92
93
  <div class="space-y-2">
93
94
  <div class="flex items-center justify-between gap-2">
94
95
  <label class="min-w-0 flex-1 space-y-1">
95
- <div class="text-[11px] text-gray-500">{{ t("panel.config.tableConditionLogic") }}</div>
96
+ <div class="flex items-center gap-1 text-[11px] text-gray-500">
97
+ {{ t("panel.config.tableConditionLogic") }}
98
+ <ConfigHintIcon :label="t('panel.config.tableConditionLogic')">
99
+ {{ t("panel.config.tableConditionLogicHint") }}
100
+ </ConfigHintIcon>
101
+ </div>
96
102
  <Select
97
103
  size="small"
98
104
  class="w-full"
@@ -114,7 +120,6 @@ function patchLeaf(index: number, patch: Partial<ConditionLeaf>) {
114
120
  {{ t("panel.config.tableConditionAddItem") }}
115
121
  </Button>
116
122
  </div>
117
- <div class="text-[10px] text-gray-500">{{ t("panel.config.tableConditionLogicHint") }}</div>
118
123
  <div
119
124
  v-for="(item, index) in items"
120
125
  :key="index"
@@ -504,10 +504,16 @@ function patchProgressRuleWhen(ruleIndex: number, when: Condition) {
504
504
  </label>
505
505
  <details class="rounded-md border border-gray-200 bg-gray-50/60 px-2 py-1.5">
506
506
  <summary class="cursor-pointer text-[11px] font-medium text-gray-600">
507
- {{ t("panel.config.tableAdvancedData") }}
507
+ <span class="inline-flex items-center gap-1 align-middle">
508
+ {{ t("panel.config.tableAdvancedData") }}
509
+ <span class="inline-flex" @click.prevent>
510
+ <ConfigHintIcon :label="t('panel.config.tableAdvancedData')">
511
+ {{ t("panel.config.tableAdvancedDataHint") }}
512
+ </ConfigHintIcon>
513
+ </span>
514
+ </span>
508
515
  </summary>
509
516
  <div class="mt-2 space-y-2 border-t border-gray-100 pt-2">
510
- <p class="text-[10px] text-gray-500">{{ t("panel.config.tableAdvancedDataHint") }}</p>
511
517
  <div class="grid grid-cols-2 gap-2">
512
518
  <label class="block space-y-1.5">
513
519
  <div class="flex items-center gap-1">
@@ -1338,7 +1344,7 @@ function patchProgressRuleWhen(ruleIndex: number, when: Condition) {
1338
1344
  <Input
1339
1345
  size="small"
1340
1346
  :value="String(selectedColumn.column.widgetProps?.progressStatic ?? '')"
1341
- placeholder="80 或 {scope?.pct}"
1347
+ :placeholder="t('panel.config.tableProgressStaticPlaceholder')"
1342
1348
  :disabled="!isEditable"
1343
1349
  @update:value="
1344
1350
  (v: unknown) =>
@@ -1756,7 +1762,7 @@ function patchProgressRuleWhen(ruleIndex: number, when: Condition) {
1756
1762
  size="small"
1757
1763
  :value="selectedColumn.column.displayTemplate ?? ''"
1758
1764
  :disabled="!isEditable"
1759
- placeholder="{current}分"
1765
+ :placeholder="t('panel.config.tableDisplayTemplatePlaceholder')"
1760
1766
  @update:value="
1761
1767
  (v: unknown) => {
1762
1768
  const s = asSelectString(v).trim();
@@ -22,6 +22,8 @@ import {
22
22
  getDefaultSizeByMaterial,
23
23
  getDefaultTableConfig,
24
24
  getDefaultTextContent,
25
+ localizeLayers,
26
+ localizePanelElement,
25
27
  normalizePrimaryLayer,
26
28
  randomId,
27
29
  } from "../utils/panelElementDefaults";
@@ -122,7 +124,8 @@ export function usePanelElements() {
122
124
  const layers = computed(() => {
123
125
  const rawLayers =
124
126
  (stateRef.value.variables?.layers as PanelLayer[] | undefined) ?? [DEFAULT_LAYER];
125
- return normalizePrimaryLayer(rawLayers);
127
+ void locale.value;
128
+ return localizeLayers(normalizePrimaryLayer(rawLayers), t);
126
129
  });
127
130
 
128
131
  const activeLayerId = computed(
@@ -133,13 +136,26 @@ export function usePanelElements() {
133
136
  const allElementsCache = shallowRef<PanelElement[]>([]);
134
137
  const elementsCache = shallowRef<PanelElement[]>([]);
135
138
 
139
+ let localizedCache = { locale: locale.value, map: new WeakMap<PanelElement, PanelElement>() };
140
+
136
141
  const allElements = computed(() => {
137
142
  const next = materializePanelElements(
138
143
  stateRef.value.root.children,
139
144
  allElementsCache.value
140
145
  );
141
146
  allElementsCache.value = next;
142
- return next;
147
+ if (localizedCache.locale !== locale.value) {
148
+ localizedCache = { locale: locale.value, map: new WeakMap() };
149
+ }
150
+ const { map } = localizedCache;
151
+ return next.map((el) => {
152
+ let localized = map.get(el);
153
+ if (!localized) {
154
+ localized = localizePanelElement(el, t);
155
+ map.set(el, localized);
156
+ }
157
+ return localized;
158
+ });
143
159
  });
144
160
 
145
161
  const elements = computed(() => {
@@ -5,6 +5,10 @@ export const AbuilderEvents = {
5
5
  workspaceAdd: "workspace:add",
6
6
  /** 同步工作区 */
7
7
  workspaceSync: "workspace:sync",
8
+ /** 开始上传接口集合 */
9
+ apiCollectionUploadStart: "api-collection:upload-start",
10
+ /** 上传接口集合成功 */
11
+ apiCollectionUploadSuccess: "api-collection:upload-success",
8
12
  } as const;
9
13
 
10
14
  export type AbuilderEventName = (typeof AbuilderEvents)[keyof typeof AbuilderEvents];
@@ -15,9 +19,25 @@ export type WorkspaceData = WorkspaceProjectRecord;
15
19
  export type WorkspaceAddEventPayload = WorkspaceData;
16
20
  export type WorkspaceSyncEventPayload = WorkspaceData;
17
21
 
22
+ export type ApiCollectionUploadStartEventPayload = {
23
+ fileNames: string[];
24
+ count: number;
25
+ };
26
+
27
+ export type ApiCollectionUploadSuccessEventPayload = {
28
+ collections: Array<{
29
+ id: string;
30
+ name: string;
31
+ apiCount: number;
32
+ persistLocal: boolean;
33
+ }>;
34
+ };
35
+
18
36
  export type AbuilderEventPayloadMap = {
19
37
  [AbuilderEvents.workspaceAdd]: WorkspaceAddEventPayload;
20
38
  [AbuilderEvents.workspaceSync]: WorkspaceSyncEventPayload;
39
+ [AbuilderEvents.apiCollectionUploadStart]: ApiCollectionUploadStartEventPayload;
40
+ [AbuilderEvents.apiCollectionUploadSuccess]: ApiCollectionUploadSuccessEventPayload;
21
41
  };
22
42
 
23
43
  type EventCallback = (payload: unknown) => void;
@@ -1,5 +1,5 @@
1
1
  import type { TranslateFn } from "@arronqzy/i18n";
2
- import { tForLocale } from "@arronqzy/i18n";
2
+ import { relocalizeMessage, tForLocale } from "@arronqzy/i18n";
3
3
  import { createDefaultTableConfig, type PanelTableConfig } from "@arronqzy/view-table";
4
4
  import { createDefaultScene3dConfig } from "@arronqzy/view-scene3d";
5
5
  import type { PanelChartConfig, PanelLayer, PanelElement } from "../types";
@@ -35,6 +35,52 @@ export function getDefaultNodeName(
35
35
  return key ? t(key) : materialType;
36
36
  }
37
37
 
38
+ const DEFAULT_LAYER_NAME_KEYS = [
39
+ "panel.defaults.layer1",
40
+ "panel.defaults.layerN",
41
+ "panel.defaults.mappingLayerN",
42
+ "panel.defaults.layerRandom",
43
+ ];
44
+
45
+ /** Persisted default layer names ("图层1" / "Layer 2") follow the current locale. */
46
+ export function localizeLayerName(name: string, t: TranslateFn): string {
47
+ return relocalizeMessage(name, DEFAULT_LAYER_NAME_KEYS, t);
48
+ }
49
+
50
+ export function localizeLayers(layers: PanelLayer[], t: TranslateFn): PanelLayer[] {
51
+ return layers.map((layer) => {
52
+ const name = localizeLayerName(layer.name, t);
53
+ return name === layer.name ? layer : { ...layer, name };
54
+ });
55
+ }
56
+
57
+ const DEFAULT_TEXT_HTML_RE = /^<p>(.*)<\/p>$/s;
58
+
59
+ /**
60
+ * Default node name, chart title and text content are stored in the creation locale;
61
+ * re-render them in the current one. Returns the same object when nothing changes.
62
+ */
63
+ export function localizePanelElement(element: PanelElement, t: TranslateFn): PanelElement {
64
+ const key = DEFAULT_NODE_NAME_KEYS[element.materialType ?? ""];
65
+ if (!key) return element;
66
+ const name = element.name ? relocalizeMessage(element.name, key, t) : element.name;
67
+ const title = element.chart?.title ? relocalizeMessage(element.chart.title, key, t) : undefined;
68
+ let textHtml = element.textHtml;
69
+ const textInner = textHtml ? DEFAULT_TEXT_HTML_RE.exec(textHtml)?.[1] : undefined;
70
+ if (textInner) {
71
+ const localized = relocalizeMessage(textInner, "panel.defaults.doubleClickTextHtml", t);
72
+ if (localized !== textInner) textHtml = `<p>${localized}</p>`;
73
+ }
74
+ const titleChanged = title !== undefined && title !== element.chart?.title;
75
+ if (name === element.name && !titleChanged && textHtml === element.textHtml) return element;
76
+ return {
77
+ ...element,
78
+ name,
79
+ textHtml,
80
+ ...(titleChanged && element.chart ? { chart: { ...element.chart, title } } : null),
81
+ };
82
+ }
83
+
38
84
  export function getDefaultLayer(t: TranslateFn = tForLocale("zh-CN")): PanelLayer {
39
85
  return {
40
86
  id: DEFAULT_LAYER_ID,
@@ -1,6 +1,10 @@
1
1
  import type { PanelElement } from "../types";
2
2
  import type { PanelTableConfig } from "@arronqzy/view-table";
3
3
  import { isUsableTableRawData } from "@arronqzy/view-table";
4
+ import {
5
+ createSystemRoot,
6
+ isDayjsValue,
7
+ } from "@arronqzy/blueprint-dsl";
4
8
  import { materializeChartLabelsFromScope, materializeChartValuesFromScope } from "./scope-template-chart";
5
9
  import {
6
10
  SCOPE_SPREAD_TEMPLATE_RE,
@@ -11,12 +15,12 @@ import {
11
15
  resolveSpreadScopePath,
12
16
  } from "./scope-template-spread";
13
17
 
14
- const SCOPE_TOKEN_RE = /\{(scope[^}]*)\}/g;
18
+ const TEMPLATE_TOKEN_RE = /\{((?:scope|system)[^}]*)\}/g;
15
19
 
16
20
  export function hasScopeTemplate(value: string): boolean {
17
- SCOPE_TOKEN_RE.lastIndex = 0;
21
+ TEMPLATE_TOKEN_RE.lastIndex = 0;
18
22
  SCOPE_SPREAD_TEMPLATE_RE.lastIndex = 0;
19
- return SCOPE_TOKEN_RE.test(value) || SCOPE_SPREAD_TEMPLATE_RE.test(value);
23
+ return TEMPLATE_TOKEN_RE.test(value) || SCOPE_SPREAD_TEMPLATE_RE.test(value);
20
24
  }
21
25
 
22
26
  export function evaluateScopeExpression(
@@ -26,8 +30,13 @@ export function evaluateScopeExpression(
26
30
  const trimmed = expression.trim();
27
31
  if (!trimmed) return undefined;
28
32
  try {
29
- const fn = new Function("scope", `"use strict"; return (${trimmed});`);
30
- return fn(scope);
33
+ const system = createSystemRoot();
34
+ const fn = new Function(
35
+ "scope",
36
+ "system",
37
+ `"use strict"; return (${trimmed});`
38
+ );
39
+ return fn(scope, system);
31
40
  } catch {
32
41
  return undefined;
33
42
  }
@@ -39,6 +48,9 @@ function stringifyScopeValue(value: unknown): string {
39
48
  if (typeof value === "number" || typeof value === "boolean") {
40
49
  return String(value);
41
50
  }
51
+ if (isDayjsValue(value)) {
52
+ return value.toISOString();
53
+ }
42
54
  try {
43
55
  return JSON.stringify(value);
44
56
  } catch {
@@ -60,8 +72,8 @@ export function evaluateScopeTemplate(
60
72
  }
61
73
  );
62
74
 
63
- // Only `{scope...}` tokens — keep ECharts / CSS braces like `{b}` intact.
64
- return withSpread.replace(SCOPE_TOKEN_RE, (_: string, rawExpr: string) =>
75
+ // Only `{scope...}` / `{system...}` — keep ECharts / CSS braces like `{b}` intact.
76
+ return withSpread.replace(TEMPLATE_TOKEN_RE, (_: string, rawExpr: string) =>
65
77
  stringifyScopeValue(evaluateScopeExpression(rawExpr, scope))
66
78
  );
67
79
  }
@@ -91,7 +103,7 @@ function materializeTemplateValue(template: string, scope: unknown): unknown {
91
103
  );
92
104
  }
93
105
 
94
- const singleMatch = /^\{(scope[^}]*)\}$/.exec(trimmed);
106
+ const singleMatch = /^\{((?:scope|system)[^}]*)\}$/.exec(trimmed);
95
107
  if (singleMatch) {
96
108
  return evaluateScopeExpression(singleMatch[1]!, scope);
97
109
  }