@arronqzy/react-view 1.0.3 → 1.0.7

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 (43) hide show
  1. package/dist/styles.css +555 -15
  2. package/package.json +14 -7
  3. package/readme.md +74 -5
  4. package/src/core/template.tsx +12 -2
  5. package/src/panel/ReactViewOnlinePreview.tsx +18 -8
  6. package/src/panel/ReactViewPanel.tsx +403 -177
  7. package/src/panel/ai/AssistantChatPanel.tsx +895 -0
  8. package/src/panel/ai/AssistantMarkdown.tsx +256 -0
  9. package/src/panel/ai/applyAssistantAction.ts +652 -0
  10. package/src/panel/ai/revealConfigField.ts +137 -0
  11. package/src/panel/components/ConfigHintIcon.tsx +50 -0
  12. package/src/panel/components/ElementsLayer.tsx +39 -22
  13. package/src/panel/components/MaterialSidebar.tsx +157 -109
  14. package/src/panel/components/MoveableLayer.tsx +75 -61
  15. package/src/panel/components/PanelCanvas.tsx +81 -64
  16. package/src/panel/components/PanelConfigSidebar.tsx +471 -448
  17. package/src/panel/components/SelectLayer.tsx +1 -0
  18. package/src/panel/components/ViewElementScopePanel.tsx +13 -12
  19. package/src/panel/components/WorkspaceConfigSidebar.tsx +5 -2
  20. package/src/panel/components/WorkspaceProjectNav.tsx +25 -14
  21. package/src/panel/components/WorkspaceStageSplit.tsx +3 -1
  22. package/src/panel/components/scope-config/ScopeConfigContext.tsx +9 -0
  23. package/src/panel/components/scope-config/ScopeTemplatePreviewHost.tsx +242 -0
  24. package/src/panel/components/scope-config/ScopeTemplateUsageHint.tsx +12 -0
  25. package/src/panel/components/scope-config/ScopeTemplateWarningsPanel.tsx +6 -3
  26. package/src/panel/components/table/ConditionEditor.tsx +205 -0
  27. package/src/panel/components/table/JsonCodeEditor.tsx +115 -0
  28. package/src/panel/components/table/PanelConfigTableSection.tsx +2192 -0
  29. package/src/panel/components/table/TableNodeContent.tsx +544 -0
  30. package/src/panel/constants/messages.ts +76 -34
  31. package/src/panel/hooks/usePanelElements.ts +64 -52
  32. package/src/panel/hooks/useRafThrottledScroll.ts +30 -0
  33. package/src/panel/hooks/useWorkspaceProjects.ts +11 -8
  34. package/src/panel/types.ts +6 -0
  35. package/src/panel/utils/chartOptionBuilder.ts +12 -1
  36. package/src/panel/utils/panelElementDefaults.ts +69 -39
  37. package/src/panel/utils/scope-field-labels.ts +51 -37
  38. package/src/panel/utils/scope-template-chart.ts +92 -0
  39. package/src/panel/utils/scope-template-preview.ts +128 -0
  40. package/src/panel/utils/scope-template-spread.ts +252 -0
  41. package/src/panel/utils/scope-template-warnings.ts +131 -11
  42. package/src/panel/utils/scope-template.ts +105 -3
  43. package/src/tailwind.css +36 -15
@@ -74,6 +74,7 @@ export function SelectLayer({
74
74
  if (selectedIdSet.size > 0) {
75
75
  if (
76
76
  target?.closest(".moveable-control-box") ||
77
+ target?.closest(".moveable-group") ||
77
78
  target?.closest(".moveable-line") ||
78
79
  target?.closest(".moveable-control") ||
79
80
  target?.closest(".moveable-direction")
@@ -1,6 +1,9 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
+ import { useI18n } from "@arronqzy/i18n/react";
2
3
 
3
4
  import { formatViewElementScope } from "../utils/scope-template";
5
+ import { ConfigHintIcon } from "./ConfigHintIcon";
6
+ import { ScopeTemplateUsageHint } from "./scope-config/ScopeTemplateUsageHint";
4
7
 
5
8
  const SCOPE_COLLAPSE_STORAGE_KEY = "panel:config-scope-collapsed";
6
9
 
@@ -8,7 +11,10 @@ export type ViewElementScopePanelProps = {
8
11
  scope: unknown;
9
12
  };
10
13
 
11
- export function ViewElementScopePanel({ scope }: ViewElementScopePanelProps) {
14
+ export function ViewElementScopePanel({
15
+ scope,
16
+ }: ViewElementScopePanelProps) {
17
+ const { t } = useI18n();
12
18
  const [isCollapsed, setIsCollapsed] = useState(false);
13
19
  const [scopeUpdated, setScopeUpdated] = useState(false);
14
20
  const prevScopeSerializedRef = useRef<string | null>(null);
@@ -42,10 +48,13 @@ export function ViewElementScopePanel({ scope }: ViewElementScopePanelProps) {
42
48
  <div className="mt-2 border-t border-border/50 pt-2">
43
49
  <div className="flex items-center justify-between gap-2">
44
50
  <div className="flex min-w-0 items-center gap-1.5">
45
- <div className="text-[11px] font-medium text-foreground">Scope 数据</div>
51
+ <div className="text-[11px] font-medium text-foreground">{t("panel.scope.dataTitle")}</div>
52
+ <ConfigHintIcon label={t("panel.scope.templateHintLabel")} contentClassName="max-w-[380px]">
53
+ <ScopeTemplateUsageHint />
54
+ </ConfigHintIcon>
46
55
  {scopeUpdated ? (
47
56
  <span className="shrink-0 rounded bg-sky-500/15 px-1.5 py-0.5 text-[10px] font-medium text-sky-700 dark:text-sky-300">
48
- 已更新
57
+ {t("panel.scope.updated")}
49
58
  </span>
50
59
  ) : null}
51
60
  </div>
@@ -54,7 +63,7 @@ export function ViewElementScopePanel({ scope }: ViewElementScopePanelProps) {
54
63
  className="rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-accent"
55
64
  onClick={() => setIsCollapsed((prev) => !prev)}
56
65
  >
57
- {isCollapsed ? "展开 Scope" : "收起 Scope"}
66
+ {isCollapsed ? t("panel.scope.expand") : t("panel.scope.collapse")}
58
67
  </button>
59
68
  </div>
60
69
  {!isCollapsed ? (
@@ -64,14 +73,6 @@ export function ViewElementScopePanel({ scope }: ViewElementScopePanelProps) {
64
73
  >
65
74
  {formatViewElementScope(scope)}
66
75
  </pre>
67
- <p className="mt-1.5 text-[10px] leading-relaxed text-muted-foreground">
68
- 表单中可使用模版字符串引用 scope,例如{" "}
69
- <code className="rounded bg-muted px-1">{`{scope?.a||0}`}</code>
70
- 、
71
- <code className="rounded bg-muted px-1">{`{scope?.name||''}`}</code>
72
- 。支持 <code className="rounded bg-muted px-1">?.</code> 与{" "}
73
- <code className="rounded bg-muted px-1">||</code> 占位。
74
- </p>
75
76
  </>
76
77
  ) : null}
77
78
  </div>
@@ -1,4 +1,5 @@
1
1
  import { useMemo } from "react";
2
+ import { useI18n } from "@arronqzy/i18n/react";
2
3
 
3
4
  import {
4
5
  BlueprintExecutionLogPanel,
@@ -82,6 +83,8 @@ export function WorkspaceConfigSidebar({
82
83
  allViewElements,
83
84
  ...viewPanelProps
84
85
  }: WorkspaceConfigSidebarProps) {
86
+ const { t } = useI18n();
87
+
85
88
  const viewElementOptions = useMemo(
86
89
  () =>
87
90
  allViewElements.map((el) => ({
@@ -136,9 +139,9 @@ export function WorkspaceConfigSidebar({
136
139
  <path d="M8 7l8 4M8 17l8-4" />
137
140
  </svg>
138
141
  </EmptyIcon>
139
- <EmptyTitle>未选中蓝图节点</EmptyTitle>
142
+ <EmptyTitle>{t("panel.workspace.configEmptyTitle")}</EmptyTitle>
140
143
  <EmptyDescription>
141
- 在蓝图面板中选中一个节点后,这里会显示对应的配置。
144
+ {t("panel.workspace.configEmptyDesc")}
142
145
  </EmptyDescription>
143
146
  </Empty>
144
147
  </div>
@@ -1,4 +1,5 @@
1
1
  import React, { useState } from "react";
2
+ import { useI18n } from "@arronqzy/i18n/react";
2
3
  import {
3
4
  AlertDialog,
4
5
  AlertDialogAction,
@@ -52,6 +53,8 @@ export function WorkspaceProjectNav({
52
53
  onDeleteProject,
53
54
  onPreviewProject,
54
55
  }: WorkspaceProjectNavProps) {
56
+ const { t } = useI18n();
57
+
55
58
  const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
56
59
  const [busy, setBusy] = useState(false);
57
60
 
@@ -81,11 +84,11 @@ export function WorkspaceProjectNav({
81
84
  disabled={busy}
82
85
  onClick={() => void runAction(onCreateProject)}
83
86
  >
84
- 创建工作区
87
+ {t("panel.workspace.create")}
85
88
  </Button>
86
89
  </TooltipTrigger>
87
90
  <TooltipContent className="z-[10100]">
88
- 以当前产物名称新建一条 IndexedDB 工作区记录,不会覆盖已有工作区
91
+ {t("panel.workspace.createHint")}
89
92
  </TooltipContent>
90
93
  </Tooltip>
91
94
 
@@ -100,13 +103,17 @@ export function WorkspaceProjectNav({
100
103
  disabled={busy || !dirty}
101
104
  onClick={() => void runAction(onSyncProject)}
102
105
  >
103
- 同步{dirty ? " *" : ""}
106
+ {dirty ? t("panel.workspace.syncDirty") : t("common.sync")}
104
107
  </Button>
105
108
  </TooltipTrigger>
106
109
  <TooltipContent className="z-[10100]">
107
110
  {dirty
108
- ? `同步更新「${activeProjectName ?? "当前工作区"}」到 IndexedDB`
109
- : "当前工作区已与 IndexedDB 同步"}
111
+ ? t("panel.workspace.syncUpdate", {
112
+ name:
113
+ activeProjectName ??
114
+ t("panel.workspace.currentWorkspaceFallback"),
115
+ })
116
+ : t("panel.workspace.syncUpToDate")}
110
117
  </TooltipContent>
111
118
  </Tooltip>
112
119
  ) : null}
@@ -120,12 +127,14 @@ export function WorkspaceProjectNav({
120
127
  className="h-7 max-w-[200px] truncate px-2 text-xs"
121
128
  disabled={busy}
122
129
  >
123
- {activeProjectName ? `工作区:${activeProjectName}` : "已保存工作区"}
130
+ {activeProjectName
131
+ ? t("panel.workspace.workspaceNamed", { name: activeProjectName })
132
+ : t("panel.workspace.savedWorkspaces")}
124
133
  </Button>
125
134
  </DropdownMenuTrigger>
126
135
  <DropdownMenuContent align="start" className="z-[10100] w-72">
127
136
  {projects.length === 0 ? (
128
- <DropdownMenuItem disabled>暂无已保存工作区</DropdownMenuItem>
137
+ <DropdownMenuItem disabled>{t("panel.workspace.noSavedWorkspaces")}</DropdownMenuItem>
129
138
  ) : (
130
139
  projects.map((project) => (
131
140
  <div key={project.id} className="px-1 py-0.5">
@@ -160,7 +169,7 @@ export function WorkspaceProjectNav({
160
169
  })
161
170
  }
162
171
  >
163
- 预览
172
+ {t("panel.workspace.previewDocTitle")}
164
173
  </Button>
165
174
  <Button
166
175
  type="button"
@@ -169,7 +178,7 @@ export function WorkspaceProjectNav({
169
178
  className="h-7 shrink-0 px-2 text-[10px] text-destructive hover:text-destructive"
170
179
  onClick={() => setPendingDeleteId(project.id)}
171
180
  >
172
- 删除
181
+ {t("common.delete")}
173
182
  </Button>
174
183
  </div>
175
184
  </div>
@@ -177,7 +186,7 @@ export function WorkspaceProjectNav({
177
186
  )}
178
187
  {/* <DropdownMenuSeparator />
179
188
  <DropdownMenuItem onClick={() => void runAction(onCreateProject)}>
180
- 创建工作区
189
+ {t("panel.workspace.create")}
181
190
  </DropdownMenuItem> */}
182
191
  </DropdownMenuContent>
183
192
  </DropdownMenu>
@@ -192,13 +201,15 @@ export function WorkspaceProjectNav({
192
201
  >
193
202
  <AlertDialogContent className="z-[10150]">
194
203
  <AlertDialogHeader>
195
- <AlertDialogTitle>删除工作区?</AlertDialogTitle>
204
+ <AlertDialogTitle>{t("panel.workspace.deleteConfirmTitle")}</AlertDialogTitle>
196
205
  <AlertDialogDescription>
197
- 将永久删除 IndexedDB 中的「{pendingDelete?.name ?? ""}」,此操作不可恢复。
206
+ {t("panel.workspace.deleteConfirmBody", {
207
+ name: pendingDelete?.name ?? "",
208
+ })}
198
209
  </AlertDialogDescription>
199
210
  </AlertDialogHeader>
200
211
  <AlertDialogFooter>
201
- <AlertDialogCancel>取消</AlertDialogCancel>
212
+ <AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
202
213
  <AlertDialogAction
203
214
  className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
204
215
  onClick={() => {
@@ -210,7 +221,7 @@ export function WorkspaceProjectNav({
210
221
  });
211
222
  }}
212
223
  >
213
- 删除
224
+ {t("common.delete")}
214
225
  </AlertDialogAction>
215
226
  </AlertDialogFooter>
216
227
  </AlertDialogContent>
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef, useState, type ComponentRef, type ReactNode } from "react";
2
+ import { useI18n } from "@arronqzy/i18n/react";
2
3
 
3
4
  import {
4
5
  BluePrintReactRoot,
@@ -58,6 +59,7 @@ function BlueprintPanel({
58
59
  canSyncBlueprint?: boolean;
59
60
  blueprintDebug?: BlueprintDebugToolbarProps;
60
61
  }) {
62
+ const { t } = useI18n();
61
63
  const wrapRef = useRef<HTMLDivElement>(null);
62
64
  const [layoutReady, setLayoutReady] = useState(false);
63
65
 
@@ -104,7 +106,7 @@ function BlueprintPanel({
104
106
  <BluePrintReactRoot style={{ width: "100%", height: "100%" }} {...blueprintProps} />
105
107
  ) : (
106
108
  <div className="flex h-full items-center justify-center text-[11px] text-muted-foreground">
107
- {blueprintOpen ? "画布加载中…" : ""}
109
+ {blueprintOpen ? t("panel.workspace.canvasLoading") : ""}
108
110
  </div>
109
111
  )}
110
112
  </div>
@@ -8,11 +8,13 @@ import {
8
8
  type RefObject,
9
9
  } from "react";
10
10
 
11
+ import type { PanelElement } from "../../types";
11
12
  import type { ScopeTemplateWarning } from "../../utils/scope-template-warnings";
12
13
  import { scopeFieldDomId } from "../../utils/scope-field-labels";
13
14
  import { groupScopeWarningsByField } from "../../utils/scope-template-warnings";
14
15
 
15
16
  import { ScopeTemplateAutocompleteHost } from "./ScopeTemplateAutocompleteHost";
17
+ import { ScopeTemplatePreviewHost } from "./ScopeTemplatePreviewHost";
16
18
 
17
19
  type ScopeConfigContextValue = {
18
20
  warnings: ScopeTemplateWarning[];
@@ -83,11 +85,13 @@ function attachInlineHints(
83
85
 
84
86
  export function ScopeConfigProvider({
85
87
  scope,
88
+ element,
86
89
  warnings,
87
90
  scrollContainerRef,
88
91
  children,
89
92
  }: {
90
93
  scope?: unknown;
94
+ element?: PanelElement | null;
91
95
  warnings: ScopeTemplateWarning[];
92
96
  scrollContainerRef?: RefObject<HTMLElement | null>;
93
97
  children: ReactNode;
@@ -156,6 +160,11 @@ export function ScopeConfigProvider({
156
160
  scope={scope}
157
161
  containerRef={scrollContainerRef}
158
162
  />
163
+ <ScopeTemplatePreviewHost
164
+ scope={scope}
165
+ element={element ?? null}
166
+ containerRef={scrollContainerRef}
167
+ />
159
168
  </ScopeConfigContext.Provider>
160
169
  );
161
170
  }
@@ -0,0 +1,242 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useId,
5
+ useMemo,
6
+ useState,
7
+ type RefObject,
8
+ } from "react";
9
+ import { useI18n } from "@arronqzy/i18n/react";
10
+ import { createPortal } from "react-dom";
11
+
12
+ import type { PanelElement } from "../../types";
13
+ import { hasScopeTemplate } from "../../utils/scope-template";
14
+ import {
15
+ collectElementScopeTemplateFields,
16
+ formatScopeTemplatePreview,
17
+ matchScopeTemplateFieldId,
18
+ resolveScopeTemplatePreview,
19
+ } from "../../utils/scope-template-preview";
20
+
21
+ const PREVIEW_SLOT_ATTR = "data-scope-preview-slot";
22
+ const PREVIEW_KEY_ATTR = "data-scope-preview-key";
23
+
24
+ type PreviewAnchor = {
25
+ key: string;
26
+ template: string;
27
+ fieldId?: string;
28
+ slot: HTMLElement;
29
+ };
30
+
31
+ function isPreviewTarget(
32
+ node: Element
33
+ ): node is HTMLInputElement | HTMLTextAreaElement {
34
+ if (node instanceof HTMLTextAreaElement) {
35
+ return node.dataset.scopeAutocomplete !== "off";
36
+ }
37
+ if (node instanceof HTMLInputElement) {
38
+ if (node.dataset.scopeAutocomplete === "off") return false;
39
+ const type = node.type;
40
+ return (
41
+ !type ||
42
+ type === "text" ||
43
+ type === "search" ||
44
+ type === "url" ||
45
+ type === "password"
46
+ );
47
+ }
48
+ return false;
49
+ }
50
+
51
+ function ensurePreviewKey(
52
+ input: HTMLInputElement | HTMLTextAreaElement,
53
+ keySeed: string
54
+ ): string {
55
+ const existing = input.getAttribute(PREVIEW_KEY_ATTR);
56
+ if (existing) return existing;
57
+ const key = `${keySeed}-${Math.random().toString(36).slice(2, 9)}`;
58
+ input.setAttribute(PREVIEW_KEY_ATTR, key);
59
+ return key;
60
+ }
61
+
62
+ function ensurePreviewSlot(
63
+ input: HTMLInputElement | HTMLTextAreaElement
64
+ ): HTMLElement {
65
+ const host = input.closest("label") ?? input.parentElement;
66
+ if (!host) return input;
67
+
68
+ const existing = host.querySelector(`[${PREVIEW_SLOT_ATTR}]`);
69
+ if (existing instanceof HTMLElement) return existing;
70
+
71
+ const slot = document.createElement("div");
72
+ slot.setAttribute(PREVIEW_SLOT_ATTR, "1");
73
+ input.insertAdjacentElement("afterend", slot);
74
+ return slot;
75
+ }
76
+
77
+ function removeOrphanPreviewSlots(container: HTMLElement) {
78
+ container.querySelectorAll(`[${PREVIEW_SLOT_ATTR}]`).forEach((slot) => {
79
+ const host = slot.parentElement;
80
+ const input = host?.querySelector("input, textarea");
81
+ if (
82
+ !input ||
83
+ !(input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) ||
84
+ !hasScopeTemplate(input.value)
85
+ ) {
86
+ slot.remove();
87
+ }
88
+ });
89
+ }
90
+
91
+ function inferFieldIdFromDom(
92
+ input: HTMLInputElement | HTMLTextAreaElement
93
+ ): string | undefined {
94
+ const label = input.closest("label");
95
+ const labelText = label?.textContent ?? "";
96
+ // Match both zh-CN and en-US config labels
97
+ if (/类目|categor|labels/i.test(labelText)) return "chart.labelsText";
98
+ if (/数值|values/i.test(labelText)) return "chart.valuesText";
99
+ return undefined;
100
+ }
101
+
102
+ function ScopeTemplatePreviewPanel({
103
+ template,
104
+ scope,
105
+ fieldId,
106
+ }: {
107
+ template: string;
108
+ scope: unknown;
109
+ fieldId?: string;
110
+ }) {
111
+ const { t } = useI18n();
112
+ const [isExpanded, setIsExpanded] = useState(false);
113
+ const previewValue = resolveScopeTemplatePreview(template, scope, fieldId);
114
+ const previewText = formatScopeTemplatePreview(previewValue);
115
+ const previewType = Array.isArray(previewValue)
116
+ ? t("panel.scope.previewArray", { count: previewValue.length })
117
+ : typeof previewValue;
118
+
119
+ return (
120
+ <div className="mt-1 rounded border border-border/60 bg-muted/30">
121
+ <button
122
+ type="button"
123
+ className="flex w-full items-center justify-between gap-2 px-2 py-1 text-left text-[10px] text-muted-foreground hover:bg-accent/40"
124
+ onClick={() => setIsExpanded((prev) => !prev)}
125
+ >
126
+ <span className="font-medium text-foreground/80">{t("panel.scope.previewTitle")}</span>
127
+ <span className="shrink-0 text-[10px]">
128
+ {previewType} · {isExpanded ? t("panel.scope.collapsePreview") : t("panel.scope.expandPreview")}
129
+ </span>
130
+ </button>
131
+ {isExpanded ? (
132
+ <pre className="max-h-28 overflow-auto border-t border-border/50 px-2 py-1.5 font-mono text-[10px] leading-relaxed text-foreground/90 whitespace-pre-wrap break-all">
133
+ {previewText}
134
+ </pre>
135
+ ) : null}
136
+ </div>
137
+ );
138
+ }
139
+
140
+ export function ScopeTemplatePreviewHost({
141
+ scope,
142
+ element,
143
+ containerRef,
144
+ }: {
145
+ scope: unknown;
146
+ element: PanelElement | null;
147
+ containerRef?: RefObject<HTMLElement | null>;
148
+ }) {
149
+ const { t } = useI18n();
150
+ const keySeed = useId();
151
+ const [anchors, setAnchors] = useState<PreviewAnchor[]>([]);
152
+
153
+ const templateFields = useMemo(
154
+ () => (element ? collectElementScopeTemplateFields(element, t) : []),
155
+ [element, t]
156
+ );
157
+
158
+ const scanAnchors = useCallback(() => {
159
+ const container = containerRef?.current;
160
+ if (!container || scope === undefined) {
161
+ setAnchors((prev) => (prev.length === 0 ? prev : []));
162
+ return;
163
+ }
164
+
165
+ const next: PreviewAnchor[] = [];
166
+ const inputs = container.querySelectorAll("input, textarea");
167
+ for (const node of inputs) {
168
+ if (!isPreviewTarget(node)) continue;
169
+ const template = node.value;
170
+ if (!hasScopeTemplate(template)) continue;
171
+
172
+ const fieldId =
173
+ matchScopeTemplateFieldId(template, templateFields) ??
174
+ inferFieldIdFromDom(node);
175
+ next.push({
176
+ key: ensurePreviewKey(node, keySeed),
177
+ template,
178
+ fieldId,
179
+ slot: ensurePreviewSlot(node),
180
+ });
181
+ }
182
+
183
+ removeOrphanPreviewSlots(container);
184
+ setAnchors((prev) => {
185
+ if (
186
+ prev.length === next.length &&
187
+ prev.every(
188
+ (item, index) =>
189
+ item.key === next[index]?.key &&
190
+ item.template === next[index]?.template &&
191
+ item.fieldId === next[index]?.fieldId
192
+ )
193
+ ) {
194
+ return prev;
195
+ }
196
+ return next;
197
+ });
198
+ }, [containerRef, keySeed, scope, templateFields]);
199
+
200
+ useEffect(() => {
201
+ scanAnchors();
202
+ }, [scanAnchors, element, scope]);
203
+
204
+ useEffect(() => {
205
+ const container = containerRef?.current;
206
+ if (!container || scope === undefined) return;
207
+
208
+ const onInput = () => {
209
+ window.requestAnimationFrame(scanAnchors);
210
+ };
211
+
212
+ container.addEventListener("input", onInput, true);
213
+ const observer = new MutationObserver(onInput);
214
+ observer.observe(container, { childList: true, subtree: true });
215
+
216
+ return () => {
217
+ container.removeEventListener("input", onInput, true);
218
+ observer.disconnect();
219
+ container
220
+ .querySelectorAll(`[${PREVIEW_SLOT_ATTR}]`)
221
+ .forEach((node) => node.remove());
222
+ };
223
+ }, [containerRef, scanAnchors, scope]);
224
+
225
+ if (scope === undefined || anchors.length === 0) return null;
226
+
227
+ return (
228
+ <>
229
+ {anchors.map((anchor) =>
230
+ createPortal(
231
+ <ScopeTemplatePreviewPanel
232
+ key={anchor.key}
233
+ template={anchor.template}
234
+ scope={scope}
235
+ fieldId={anchor.fieldId}
236
+ />,
237
+ anchor.slot
238
+ )
239
+ )}
240
+ </>
241
+ );
242
+ }
@@ -0,0 +1,12 @@
1
+ import { useI18n } from "@arronqzy/i18n/react";
2
+
3
+ export function ScopeTemplateUsageHint() {
4
+ const { t } = useI18n();
5
+
6
+ return (
7
+ <div className="space-y-2">
8
+ <p>{t("panel.scope.usageHint1")}</p>
9
+ <p>{t("panel.scope.usageHint2")}</p>
10
+ </div>
11
+ );
12
+ }
@@ -1,10 +1,13 @@
1
1
  import { useEffect, useState } from "react";
2
+ import { useI18n } from "@arronqzy/i18n/react";
2
3
 
3
4
  import { useScopeConfig } from "./ScopeConfigContext";
4
5
 
5
6
  const WARNINGS_COLLAPSE_STORAGE_KEY = "panel:config-scope-warnings-collapsed";
6
7
 
7
8
  export function ScopeTemplateWarningsPanel() {
9
+ const { t } = useI18n();
10
+
8
11
  const ctx = useScopeConfig();
9
12
  const [isCollapsed, setIsCollapsed] = useState(false);
10
13
 
@@ -26,7 +29,7 @@ export function ScopeTemplateWarningsPanel() {
26
29
  <div className="mt-2 border-t border-border/50 pt-2">
27
30
  <div className="flex items-center justify-between gap-2">
28
31
  <div className="text-[11px] font-medium text-amber-700 dark:text-amber-300">
29
- 异常调用警告
32
+ {t("panel.scope.warningsTitle")}
30
33
  <span className="ml-1.5 rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-semibold">
31
34
  {ctx.warnings.length}
32
35
  </span>
@@ -36,13 +39,13 @@ export function ScopeTemplateWarningsPanel() {
36
39
  className="rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-accent"
37
40
  onClick={() => setIsCollapsed((prev) => !prev)}
38
41
  >
39
- {isCollapsed ? "展开警告" : "收起警告"}
42
+ {isCollapsed ? t("panel.scope.expandWarnings") : t("panel.scope.collapseWarnings")}
40
43
  </button>
41
44
  </div>
42
45
  {!isCollapsed ? (
43
46
  <ul className="mt-2 max-h-[200px] space-y-1 overflow-auto text-[10px] leading-relaxed">
44
47
  {ctx.warnings.map((warning) => (
45
- <li key={`${warning.fieldId}-${warning.missingPath}-${warning.expression}`}>
48
+ <li key={`${warning.fieldId}-${warning.kind}-${warning.missingPath}-${warning.expression}`}>
46
49
  <button
47
50
  type="button"
48
51
  className="w-full rounded border border-amber-500/30 bg-amber-500/10 px-2 py-1.5 text-left text-amber-800 hover:bg-amber-500/15 dark:text-amber-200"