@arronqzy/react-view 0.0.1

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 (112) hide show
  1. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/aix-ppc64.json +1 -0
  2. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/android-arm.json +1 -0
  3. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/android-arm64.json +1 -0
  4. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/android-x64.json +1 -0
  5. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/darwin-arm64.json +1 -0
  6. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/darwin-x64.json +1 -0
  7. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/freebsd-arm64.json +1 -0
  8. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/freebsd-x64.json +1 -0
  9. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-arm.json +1 -0
  10. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-arm64.json +1 -0
  11. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-ia32.json +1 -0
  12. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-loong64.json +1 -0
  13. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-mips64el.json +1 -0
  14. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-ppc64.json +1 -0
  15. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-riscv64.json +1 -0
  16. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-s390x.json +1 -0
  17. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/linux-x64.json +1 -0
  18. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/netbsd-x64.json +1 -0
  19. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/openbsd-x64.json +1 -0
  20. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/sunos-x64.json +1 -0
  21. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/win32-arm64.json +1 -0
  22. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/win32-ia32.json +1 -0
  23. package/.pnpm-cache/metadata/registry.npmjs.org/@esbuild/win32-x64.json +1 -0
  24. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-android-arm-eabi.json +1 -0
  25. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-android-arm64.json +1 -0
  26. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-darwin-arm64.json +1 -0
  27. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-darwin-x64.json +1 -0
  28. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-freebsd-arm64.json +1 -0
  29. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-freebsd-x64.json +1 -0
  30. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf.json +1 -0
  31. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf.json +1 -0
  32. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-arm64-gnu.json +1 -0
  33. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-arm64-musl.json +1 -0
  34. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu.json +1 -0
  35. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu.json +1 -0
  36. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu.json +1 -0
  37. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-riscv64-musl.json +1 -0
  38. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-s390x-gnu.json +1 -0
  39. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-x64-gnu.json +1 -0
  40. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-linux-x64-musl.json +1 -0
  41. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-win32-arm64-msvc.json +1 -0
  42. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-win32-ia32-msvc.json +1 -0
  43. package/.pnpm-cache/metadata/registry.npmjs.org/@rollup/rollup-win32-x64-msvc.json +1 -0
  44. package/.pnpm-cache/metadata/registry.npmjs.org/eslint.json +1 -0
  45. package/.pnpm-cache/metadata/registry.npmjs.org/fsevents.json +1 -0
  46. package/.pnpm-cache/metadata/registry.npmjs.org/pnpm.json +1 -0
  47. package/.pnpm-cache/metadata/registry.npmjs.org/react-dom.json +1 -0
  48. package/.pnpm-cache/metadata/registry.npmjs.org/react-is.json +1 -0
  49. package/.pnpm-cache/metadata/registry.npmjs.org/turbo-darwin-64.json +1 -0
  50. package/.pnpm-cache/metadata/registry.npmjs.org/turbo-darwin-arm64.json +1 -0
  51. package/.pnpm-cache/metadata/registry.npmjs.org/turbo-linux-64.json +1 -0
  52. package/.pnpm-cache/metadata/registry.npmjs.org/turbo-linux-arm64.json +1 -0
  53. package/.pnpm-cache/metadata/registry.npmjs.org/turbo-windows-64.json +1 -0
  54. package/.pnpm-cache/metadata/registry.npmjs.org/turbo-windows-arm64.json +1 -0
  55. package/.pnpm-cache/metadata/registry.npmjs.org/typescript.json +1 -0
  56. package/.turbo/turbo-lint.log +4 -0
  57. package/dist/styles.css +2624 -0
  58. package/env.d.ts +11 -0
  59. package/operation-guide.md +275 -0
  60. package/package.json +43 -0
  61. package/postcss.config.cjs +7 -0
  62. package/readme.md +21 -0
  63. package/src/core/createAction.ts +5 -0
  64. package/src/core/createView.ts +28 -0
  65. package/src/core/instructions.tsx +57 -0
  66. package/src/core/nodePool.ts +127 -0
  67. package/src/core/signal.ts +130 -0
  68. package/src/core/template.tsx +25 -0
  69. package/src/index.ts +7 -0
  70. package/src/panel/ReactViewOnlinePreview.tsx +331 -0
  71. package/src/panel/ReactViewPanel.tsx +3221 -0
  72. package/src/panel/components/ElementsLayer.tsx +891 -0
  73. package/src/panel/components/MaterialSidebar.tsx +1142 -0
  74. package/src/panel/components/MoveableLayer.tsx +803 -0
  75. package/src/panel/components/PanelCanvas.tsx +660 -0
  76. package/src/panel/components/PanelConfigSidebar.tsx +3703 -0
  77. package/src/panel/components/PanelRulers.tsx +89 -0
  78. package/src/panel/components/SelectLayer.tsx +106 -0
  79. package/src/panel/components/ViewElementScopePanel.tsx +79 -0
  80. package/src/panel/components/WorkspaceConfigSidebar.tsx +158 -0
  81. package/src/panel/components/WorkspaceProjectNav.tsx +220 -0
  82. package/src/panel/components/WorkspaceStageSplit.tsx +179 -0
  83. package/src/panel/components/scope-config/ScopeConfigContext.tsx +170 -0
  84. package/src/panel/components/scope-config/ScopeTemplateAutocompleteHost.tsx +250 -0
  85. package/src/panel/components/scope-config/ScopeTemplateWarningsPanel.tsx +59 -0
  86. package/src/panel/constants/messages.ts +34 -0
  87. package/src/panel/constants/zIndex.ts +6 -0
  88. package/src/panel/hooks/usePanelElements.ts +1108 -0
  89. package/src/panel/hooks/useWorkspaceProjects.ts +278 -0
  90. package/src/panel/index.tsx +3 -0
  91. package/src/panel/library/workspace-project-cache.ts +23 -0
  92. package/src/panel/library/workspace-project-db.ts +112 -0
  93. package/src/panel/library/workspace-project-sync.ts +41 -0
  94. package/src/panel/library/workspace-snapshot.ts +27 -0
  95. package/src/panel/scope/view-scope-store.ts +55 -0
  96. package/src/panel/types.ts +125 -0
  97. package/src/panel/utils/chartOptionBuilder.ts +316 -0
  98. package/src/panel/utils/gridPlacement.ts +189 -0
  99. package/src/panel/utils/mappingLayerOps.ts +142 -0
  100. package/src/panel/utils/panelElementDefaults.ts +161 -0
  101. package/src/panel/utils/panelElementNodes.ts +35 -0
  102. package/src/panel/utils/panelStateIO.ts +124 -0
  103. package/src/panel/utils/scope-autocomplete.ts +114 -0
  104. package/src/panel/utils/scope-field-labels.ts +45 -0
  105. package/src/panel/utils/scope-template-warnings.ts +135 -0
  106. package/src/panel/utils/scope-template.ts +75 -0
  107. package/src/panel/utils/updateElementDraft.ts +221 -0
  108. package/src/panel/viewportZoom.ts +26 -0
  109. package/src/tailwind.css +58 -0
  110. package/src/types/react-infinite-viewer.d.ts +39 -0
  111. package/tailwind.config.ts +7 -0
  112. package/tsconfig.json +14 -0
@@ -0,0 +1,3703 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import {
3
+ Card,
4
+ CardContent,
5
+ CardHeader,
6
+ CardTitle,
7
+ Checkbox,
8
+ Collapsible,
9
+ CollapsibleContent,
10
+ CollapsibleTrigger,
11
+ Empty,
12
+ EmptyDescription,
13
+ EmptyIcon,
14
+ EmptyTitle,
15
+ Input,
16
+ Select,
17
+ SelectContent,
18
+ SelectItem,
19
+ SelectTrigger,
20
+ SelectValue,
21
+ Textarea,
22
+ Tooltip,
23
+ TooltipContent,
24
+ TooltipProvider,
25
+ TooltipTrigger,
26
+ } from "@arronqzy/ui";
27
+ import type {
28
+ PanelChartConfig,
29
+ PanelElement,
30
+ PanelElementStyle,
31
+ ReferenceCopyMode,
32
+ } from "../types";
33
+ import { buildChartOption, CHART_TYPES, getChartValuesDisplayText } from "../utils/chartOptionBuilder";
34
+ import type { PanelLayer } from "../types";
35
+ import { PANEL_MESSAGES } from "../constants/messages";
36
+ import { ViewElementScopePanel } from "./ViewElementScopePanel";
37
+ import { hasViewElementScope } from "../scope/view-scope-store";
38
+ import { collectElementScopeWarnings } from "../utils/scope-template-warnings";
39
+ import { ScopeConfigProvider } from "./scope-config/ScopeConfigContext";
40
+ import { ScopeTemplateWarningsPanel } from "./scope-config/ScopeTemplateWarningsPanel";
41
+
42
+ type UpdateElement = (
43
+ id: string,
44
+ patch: Partial<PanelElement>,
45
+ options?: { batchId?: string; meta?: Record<string, unknown> }
46
+ ) => void;
47
+
48
+ export type PanelConfigSidebarProps = {
49
+ selectedElement: PanelElement | null;
50
+ selectedElements?: PanelElement[];
51
+ layers: PanelLayer[];
52
+ updateElement: UpdateElement;
53
+ setReferenceCopyMode?: (id: string, mode: ReferenceCopyMode) => void;
54
+ nodeZOrderLabel?: string;
55
+ onExcludeSelectedNode?: (nodeId: string) => void;
56
+ onAdjustNodeZOrder?: (
57
+ nodeId: string,
58
+ action: "bringForward" | "sendBackward" | "bringToFront" | "sendToBack"
59
+ ) => void;
60
+ /** 蓝图视图绑定节点写入的信号 scope,用于展示与模版解析 */
61
+ viewElementScope?: unknown;
62
+ };
63
+
64
+ export function PanelConfigSidebar({
65
+ selectedElement,
66
+ selectedElements = [],
67
+ layers,
68
+ updateElement,
69
+ setReferenceCopyMode,
70
+ nodeZOrderLabel,
71
+ onExcludeSelectedNode,
72
+ onAdjustNodeZOrder,
73
+ viewElementScope,
74
+ }: PanelConfigSidebarProps) {
75
+ const [isAdvancedOptionMode, setIsAdvancedOptionMode] = useState(false);
76
+ const [optionJsonText, setOptionJsonText] = useState("{}");
77
+ const [optionJsonError, setOptionJsonError] = useState<string | null>(null);
78
+ const [uploadStatus, setUploadStatus] = useState<string>("");
79
+ const [audioStatus, setAudioStatus] = useState<string>("");
80
+ const [videoStatus, setVideoStatus] = useState<string>("");
81
+ const [geometryDrawPenColor, setGeometryDrawPenColor] = useState("#111827");
82
+ const [geometryDrawPenWidth, setGeometryDrawPenWidth] = useState(3);
83
+ const [isGeometryDrawing, setIsGeometryDrawing] = useState(false);
84
+ const [configSearch, setConfigSearch] = useState("");
85
+ const [isSearchCollapsed, setIsSearchCollapsed] = useState(false);
86
+ const SEARCH_COLLAPSE_STORAGE_KEY = "panel:config-search-collapsed";
87
+ const textEditorRef = useRef<HTMLDivElement | null>(null);
88
+ const geometryDrawCanvasRef = useRef<HTMLCanvasElement | null>(null);
89
+ const geometryLastPointRef = useRef<{ x: number; y: number } | null>(null);
90
+ const recorderRef = useRef<MediaRecorder | null>(null);
91
+ const recordStreamRef = useRef<MediaStream | null>(null);
92
+ const audioChunksRef = useRef<BlobPart[]>([]);
93
+ const [isRecordingAudio, setIsRecordingAudio] = useState(false);
94
+ const sidebarScrollRef = useRef<HTMLElement | null>(null);
95
+ const themedScrollbarClass =
96
+ "scrollbar-thin [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-muted/40 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/80 [&::-webkit-scrollbar-thumb]:hover:bg-border";
97
+ const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
98
+ nodeInfo: true,
99
+ styleBackground: true,
100
+ styleBorder: true,
101
+ chartBasic: true,
102
+ chartAdvanced: false,
103
+ chartAdvancedHighFreq: true,
104
+ chartAdvancedLayout: false,
105
+ chartAdvancedAxisPointer: false,
106
+ reference: true,
107
+ });
108
+ const [expandedNodeCards, setExpandedNodeCards] = useState<Record<string, boolean>>({});
109
+
110
+ const isChartElement = !!selectedElement && CHART_TYPES.has(selectedElement.materialType ?? "");
111
+ const selectedLayer = selectedElement
112
+ ? layers.find((layer) => layer.id === selectedElement.layerId) ?? null
113
+ : null;
114
+ const canToggleNodeLock = !!selectedElement && !selectedLayer?.locked;
115
+ const isNodeEditable = !!selectedElement && !selectedElement.locked && !selectedLayer?.locked;
116
+ const readonlyReason = !selectedElement
117
+ ? ""
118
+ : selectedElement.locked
119
+ ? PANEL_MESSAGES.nodeConfigLocked
120
+ : selectedLayer?.locked
121
+ ? PANEL_MESSAGES.nodeConfigLayerLocked
122
+ : "";
123
+ const selectedChartType = (selectedElement?.materialType ?? "") as
124
+ | "bar"
125
+ | "line"
126
+ | "pie"
127
+ | "area"
128
+ | "scatter"
129
+ | "radar"
130
+ | "gauge"
131
+ | "funnel"
132
+ | "";
133
+ const normalizedSearch = useMemo(() => configSearch.trim().toLowerCase(), [configSearch]);
134
+ const hasSearch = normalizedSearch.length > 0;
135
+ const showScopePanel =
136
+ !!selectedElement &&
137
+ viewElementScope !== undefined &&
138
+ hasViewElementScope(selectedElement.id);
139
+ const scopeWarnings = useMemo(() => {
140
+ if (!selectedElement || viewElementScope === undefined) return [];
141
+ return collectElementScopeWarnings(selectedElement, viewElementScope);
142
+ }, [selectedElement, viewElementScope]);
143
+ const effectiveSelectedElements = useMemo(() => {
144
+ if (selectedElements.length > 0) return selectedElements;
145
+ return selectedElement ? [selectedElement] : [];
146
+ }, [selectedElement, selectedElements]);
147
+ const isMultiSelectMode = effectiveSelectedElements.length > 1;
148
+
149
+ useEffect(() => {
150
+ if (!selectedElement) {
151
+ setOptionJsonText("{}");
152
+ setOptionJsonError(null);
153
+ return;
154
+ }
155
+ if (CHART_TYPES.has(selectedElement.materialType ?? "")) {
156
+ setOptionJsonText(JSON.stringify(buildChartOption(selectedElement), null, 2));
157
+ } else {
158
+ setOptionJsonText("{}");
159
+ }
160
+ setOptionJsonError(null);
161
+ }, [selectedElement]);
162
+
163
+ useEffect(() => {
164
+ if (!selectedElement) return;
165
+ if (!CHART_TYPES.has(selectedElement.materialType ?? "")) return;
166
+ setExpandedSections((prev) => ({
167
+ ...prev,
168
+ chartBasic: true,
169
+ }));
170
+ }, [selectedElement?.id, selectedElement?.materialType]);
171
+
172
+ useEffect(() => {
173
+ if (!selectedElement || selectedElement.materialType !== "text") return;
174
+ const nextHtml = selectedElement.textHtml ?? "<p>双击输入文本</p>";
175
+ if (textEditorRef.current && textEditorRef.current.innerHTML !== nextHtml) {
176
+ textEditorRef.current.innerHTML = nextHtml;
177
+ }
178
+ }, [selectedElement?.id, selectedElement?.materialType, selectedElement?.textHtml]);
179
+ useEffect(() => {
180
+ if (typeof window === "undefined") return;
181
+ const saved = window.localStorage.getItem(SEARCH_COLLAPSE_STORAGE_KEY);
182
+ if (saved === "1") setIsSearchCollapsed(true);
183
+ }, []);
184
+ useEffect(() => {
185
+ if (typeof window === "undefined") return;
186
+ window.localStorage.setItem(SEARCH_COLLAPSE_STORAGE_KEY, isSearchCollapsed ? "1" : "0");
187
+ }, [isSearchCollapsed]);
188
+ useEffect(
189
+ () => () => {
190
+ recordStreamRef.current?.getTracks().forEach((track) => track.stop());
191
+ },
192
+ []
193
+ );
194
+
195
+ const updateSelectedChart = useCallback(
196
+ (patch: Partial<PanelChartConfig>) => {
197
+ if (!selectedElement) return;
198
+ updateElement(selectedElement.id, {
199
+ chart: { ...(selectedElement.chart ?? {}), ...patch },
200
+ });
201
+ },
202
+ [selectedElement, updateElement]
203
+ );
204
+
205
+ const updateSelectedStyle = useCallback(
206
+ (patch: Partial<PanelElementStyle>) => {
207
+ if (!selectedElement) return;
208
+ updateElement(selectedElement.id, {
209
+ style: { ...(selectedElement.style ?? {}), ...patch },
210
+ });
211
+ },
212
+ [selectedElement, updateElement]
213
+ );
214
+ const updateSelectedText = useCallback(
215
+ (patch: Partial<PanelElement>) => {
216
+ if (!selectedElement || selectedElement.materialType !== "text") return;
217
+ updateElement(selectedElement.id, patch);
218
+ },
219
+ [selectedElement, updateElement]
220
+ );
221
+ const updateSelectedGeometry = useCallback(
222
+ (patch: Partial<PanelElement>) => {
223
+ if (!selectedElement || selectedElement.materialType !== "geometry") return;
224
+ updateElement(selectedElement.id, patch);
225
+ },
226
+ [selectedElement, updateElement]
227
+ );
228
+
229
+ const redrawGeometryPadFromElement = useCallback(() => {
230
+ const canvas = geometryDrawCanvasRef.current;
231
+ if (!canvas) return;
232
+ const ctx = canvas.getContext("2d");
233
+ if (!ctx) return;
234
+ const width = canvas.width;
235
+ const height = canvas.height;
236
+ ctx.clearRect(0, 0, width, height);
237
+ ctx.fillStyle = "#ffffff";
238
+ ctx.fillRect(0, 0, width, height);
239
+ const sketch = selectedElement?.geometrySketchDataUrl;
240
+ if (!sketch) return;
241
+ const img = new Image();
242
+ img.onload = () => {
243
+ ctx.drawImage(img, 0, 0, width, height);
244
+ };
245
+ img.src = sketch;
246
+ }, [selectedElement?.geometrySketchDataUrl]);
247
+
248
+ useEffect(() => {
249
+ if (!selectedElement || selectedElement.materialType !== "geometry") return;
250
+ redrawGeometryPadFromElement();
251
+ }, [redrawGeometryPadFromElement, selectedElement?.id, selectedElement?.materialType]);
252
+
253
+ const handleUploadBackgroundImage = useCallback(
254
+ async (file: File) => {
255
+ if (!selectedElement) return;
256
+ const base64 = await new Promise<string>((resolve, reject) => {
257
+ const reader = new FileReader();
258
+ reader.onload = () => resolve(String(reader.result ?? ""));
259
+ reader.onerror = () => reject(new Error(PANEL_MESSAGES.readImageFailed));
260
+ reader.readAsDataURL(file);
261
+ });
262
+ updateSelectedStyle({
263
+ backgroundImage: `url("${base64}")`,
264
+ });
265
+ setUploadStatus("已写入 base64");
266
+ try {
267
+ const form = new FormData();
268
+ form.append("file", file);
269
+ const resp = await fetch("/api/upload", { method: "POST", body: form });
270
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
271
+ const data = (await resp.json()) as { url?: string };
272
+ if (data.url) {
273
+ updateSelectedStyle({ backgroundImageRemoteUrl: data.url });
274
+ setUploadStatus("已上传服务器并写入 base64");
275
+ }
276
+ } catch {
277
+ setUploadStatus("服务器上传失败,仅保留 base64");
278
+ }
279
+ },
280
+ [selectedElement, updateSelectedStyle]
281
+ );
282
+ const updateSelectedAudio = useCallback(
283
+ (patch: Partial<PanelElement>) => {
284
+ if (!selectedElement || selectedElement.materialType !== "audio") return;
285
+ updateElement(selectedElement.id, patch);
286
+ },
287
+ [selectedElement, updateElement]
288
+ );
289
+ const handleUploadAudioFile = useCallback(
290
+ async (file: File) => {
291
+ if (!selectedElement || selectedElement.materialType !== "audio") return;
292
+ const base64 = await new Promise<string>((resolve, reject) => {
293
+ const reader = new FileReader();
294
+ reader.onload = () => resolve(String(reader.result ?? ""));
295
+ reader.onerror = () => reject(new Error(PANEL_MESSAGES.readAudioFailed));
296
+ reader.readAsDataURL(file);
297
+ });
298
+ updateSelectedAudio({ audioSrc: base64 });
299
+ setAudioStatus(PANEL_MESSAGES.audioLocalSaved);
300
+ try {
301
+ const form = new FormData();
302
+ form.append("file", file);
303
+ const resp = await fetch("/api/upload", { method: "POST", body: form });
304
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
305
+ const data = (await resp.json()) as { url?: string };
306
+ if (data.url) {
307
+ updateSelectedAudio({ audioRemoteUrl: data.url });
308
+ setAudioStatus(PANEL_MESSAGES.audioRemoteUploaded);
309
+ }
310
+ } catch {
311
+ setAudioStatus(PANEL_MESSAGES.audioServerUploadFailed);
312
+ }
313
+ },
314
+ [selectedElement, updateSelectedAudio]
315
+ );
316
+ const handleUploadAudioPoster = useCallback(
317
+ async (file: File) => {
318
+ if (!selectedElement || selectedElement.materialType !== "audio") return;
319
+ const base64 = await new Promise<string>((resolve, reject) => {
320
+ const reader = new FileReader();
321
+ reader.onload = () => resolve(String(reader.result ?? ""));
322
+ reader.onerror = () => reject(new Error(PANEL_MESSAGES.readImageFailed));
323
+ reader.readAsDataURL(file);
324
+ });
325
+ updateSelectedAudio({ audioPosterImage: base64 });
326
+ setAudioStatus(PANEL_MESSAGES.audioPosterSet);
327
+ },
328
+ [selectedElement, updateSelectedAudio]
329
+ );
330
+ const stopRecordingAudio = useCallback(() => {
331
+ recorderRef.current?.stop();
332
+ }, []);
333
+ const startRecordingAudio = useCallback(async () => {
334
+ if (!selectedElement || selectedElement.materialType !== "audio") return;
335
+ if (!navigator.mediaDevices?.getUserMedia) {
336
+ setAudioStatus(PANEL_MESSAGES.audioRecordUnsupported);
337
+ return;
338
+ }
339
+ try {
340
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
341
+ recordStreamRef.current = stream;
342
+ const recorder = new MediaRecorder(stream);
343
+ recorderRef.current = recorder;
344
+ audioChunksRef.current = [];
345
+ recorder.ondataavailable = (event) => {
346
+ if (event.data.size > 0) audioChunksRef.current.push(event.data);
347
+ };
348
+ recorder.onstop = async () => {
349
+ const blob = new Blob(audioChunksRef.current, { type: recorder.mimeType || "audio/webm" });
350
+ const dataUrl = await new Promise<string>((resolve, reject) => {
351
+ const reader = new FileReader();
352
+ reader.onload = () => resolve(String(reader.result ?? ""));
353
+ reader.onerror = () => reject(new Error(PANEL_MESSAGES.readRecordAudioFailed));
354
+ reader.readAsDataURL(blob);
355
+ });
356
+ updateSelectedAudio({ audioSrc: dataUrl });
357
+ setAudioStatus(PANEL_MESSAGES.audioRecordSaved);
358
+ recordStreamRef.current?.getTracks().forEach((track) => track.stop());
359
+ recordStreamRef.current = null;
360
+ recorderRef.current = null;
361
+ setIsRecordingAudio(false);
362
+ };
363
+ recorder.start();
364
+ setIsRecordingAudio(true);
365
+ setAudioStatus(PANEL_MESSAGES.audioRecording);
366
+ } catch {
367
+ setAudioStatus(PANEL_MESSAGES.audioRecordStartFailed);
368
+ setIsRecordingAudio(false);
369
+ }
370
+ }, [selectedElement, updateSelectedAudio]);
371
+ const updateSelectedVideo = useCallback(
372
+ (patch: Partial<PanelElement>) => {
373
+ if (!selectedElement || selectedElement.materialType !== "video") return;
374
+ updateElement(selectedElement.id, patch);
375
+ },
376
+ [selectedElement, updateElement]
377
+ );
378
+ const handleUploadVideoFile = useCallback(
379
+ async (file: File) => {
380
+ if (!selectedElement || selectedElement.materialType !== "video") return;
381
+ const base64 = await new Promise<string>((resolve, reject) => {
382
+ const reader = new FileReader();
383
+ reader.onload = () => resolve(String(reader.result ?? ""));
384
+ reader.onerror = () => reject(new Error(PANEL_MESSAGES.readVideoFailed));
385
+ reader.readAsDataURL(file);
386
+ });
387
+ updateSelectedVideo({ videoSrc: base64 });
388
+ setVideoStatus(PANEL_MESSAGES.videoLocalSaved);
389
+ try {
390
+ const form = new FormData();
391
+ form.append("file", file);
392
+ const resp = await fetch("/api/upload", { method: "POST", body: form });
393
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
394
+ const data = (await resp.json()) as { url?: string };
395
+ if (data.url) {
396
+ updateSelectedVideo({ videoRemoteUrl: data.url });
397
+ setVideoStatus(PANEL_MESSAGES.videoRemoteUploaded);
398
+ }
399
+ } catch {
400
+ setVideoStatus(PANEL_MESSAGES.videoServerUploadFailed);
401
+ }
402
+ },
403
+ [selectedElement, updateSelectedVideo]
404
+ );
405
+
406
+ const isSectionExpanded = (key: string, defaultValue = true) =>
407
+ expandedSections[key] ?? defaultValue;
408
+
409
+ const setSectionExpanded = (key: string, next: boolean) => {
410
+ setExpandedSections((prev) => ({ ...prev, [key]: next }));
411
+ };
412
+
413
+ const renderSection = (
414
+ key: string,
415
+ title: string,
416
+ children: React.ReactNode,
417
+ defaultOpen = true,
418
+ searchTerms: string[] = []
419
+ ) => {
420
+ const hit =
421
+ !hasSearch ||
422
+ [title, ...searchTerms].some((term) => term.toLowerCase().includes(normalizedSearch));
423
+ if (!hit) return null;
424
+ renderedSectionCount += 1;
425
+ return (
426
+ <Collapsible
427
+ open={hasSearch ? true : isSectionExpanded(key, defaultOpen)}
428
+ onOpenChange={(open) => setSectionExpanded(key, open)}
429
+ className="rounded-xl border border-border/70 bg-card/95 shadow-sm"
430
+ >
431
+ <div className="flex items-center gap-1.5 px-3 py-2">
432
+ <CollapsibleTrigger asChild>
433
+ <button
434
+ type="button"
435
+ className="flex h-6 w-6 items-center justify-center rounded-md text-xs hover:bg-accent"
436
+ >
437
+ {isSectionExpanded(key, defaultOpen) ? "▾" : "▸"}
438
+ </button>
439
+ </CollapsibleTrigger>
440
+ <div className="text-[11px] font-semibold tracking-wide text-muted-foreground">{title}</div>
441
+ </div>
442
+ <CollapsibleContent className="space-y-3 border-t border-border/60 bg-muted/[0.1] px-3 pb-3 pt-2.5">
443
+ {children}
444
+ </CollapsibleContent>
445
+ </Collapsible>
446
+ );
447
+ };
448
+
449
+ const renderFieldGroup = (title: string, children: React.ReactNode) => (
450
+ <div className="space-y-2.5 rounded-lg border border-border/55 bg-background/80 p-2.5">
451
+ <div className="text-[11px] font-semibold text-muted-foreground">{title}</div>
452
+ {children}
453
+ </div>
454
+ );
455
+ const isNodeCardExpanded = useCallback(
456
+ (id: string) => expandedNodeCards[id] ?? true,
457
+ [expandedNodeCards]
458
+ );
459
+ const setNodeCardExpanded = useCallback((id: string, open: boolean) => {
460
+ setExpandedNodeCards((prev) => ({ ...prev, [id]: open }));
461
+ }, []);
462
+
463
+ const renderColorField = (
464
+ label: string,
465
+ value: string,
466
+ onTextChange: (next: string) => void
467
+ ) => (
468
+ <label className="block space-y-1.5">
469
+ <div className="text-[11px] text-muted-foreground">{label}</div>
470
+ <div className="flex items-center gap-2">
471
+ <Input
472
+ value={value}
473
+ onChange={(e) => onTextChange(e.target.value)}
474
+ placeholder="#000000"
475
+ className="h-7"
476
+ />
477
+ <Input
478
+ type="color"
479
+ value={/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value || "") ? value : "#000000"}
480
+ onChange={(e) => onTextChange(e.target.value)}
481
+ className="h-7 w-10 cursor-pointer p-1"
482
+ aria-label={`${label}调色盘`}
483
+ />
484
+ </div>
485
+ </label>
486
+ );
487
+ const optionCheckboxClass =
488
+ "h-4 w-4 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40";
489
+ const optionInputClass =
490
+ "h-7 border border-border/60 bg-background/90 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-border/60";
491
+ const optionSelectTriggerClass =
492
+ "h-7 border border-border/60 bg-muted/40 shadow-none ring-0 focus:ring-0 focus:ring-offset-0 focus:border-border/60 data-[state=open]:border-border/60";
493
+ const optionLabelTextClass = "truncate whitespace-nowrap text-[11px] leading-none";
494
+ const renderOptionLabel = (label: string, keyPath: string, desc: string) => (
495
+ <div className="flex min-w-0 items-center gap-1">
496
+ <span className={optionLabelTextClass} title={`${label}(${keyPath})`}>{label}</span>
497
+ <TooltipProvider delayDuration={120}>
498
+ <Tooltip>
499
+ <TooltipTrigger asChild>
500
+ <button
501
+ type="button"
502
+ className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-border text-[10px] text-muted-foreground"
503
+ aria-label={`${label}说明`}
504
+ >
505
+ ?
506
+ </button>
507
+ </TooltipTrigger>
508
+ <TooltipContent className="max-w-[260px] text-[11px]">
509
+ <div>Key: {keyPath}</div>
510
+ <div>{desc}</div>
511
+ </TooltipContent>
512
+ </Tooltip>
513
+ </TooltipProvider>
514
+ </div>
515
+ );
516
+ const renderFormatterLabel = (label = "Tooltip Formatter(可选)") => (
517
+ <div className="flex min-w-0 items-center gap-1">
518
+ <span className={optionLabelTextClass} title={label}>{label}</span>
519
+ <TooltipProvider delayDuration={120}>
520
+ <Tooltip>
521
+ <TooltipTrigger asChild>
522
+ <button
523
+ type="button"
524
+ className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-border text-[10px] text-muted-foreground"
525
+ aria-label="Tooltip Formatter 说明"
526
+ >
527
+ ?
528
+ </button>
529
+ </TooltipTrigger>
530
+ <TooltipContent className="max-w-[360px] text-[11px] leading-5">
531
+ <div className="font-medium">可用占位符</div>
532
+ <div>{"{a}=系列名, {b}=类目名, {c}=数值, {d}=百分比(饼图)"}</div>
533
+ <div className="mt-1 font-medium">常用模板 + 输出示例</div>
534
+ <div>{"1) {b}: {c} -> 周一: 120"}</div>
535
+ <div>{"2) {a}<br/>{b}: {c} -> 销量<br/>周一: 120"}</div>
536
+ <div>{"3) {b}: {c} ({d}%) -> 访问来源: 335 (42%)"}</div>
537
+ <div>{"4) ¥{c} / {b} -> ¥120 / 周一"}</div>
538
+ </TooltipContent>
539
+ </Tooltip>
540
+ </TooltipProvider>
541
+ </div>
542
+ );
543
+ const mergeOptionPatch = useCallback(
544
+ (
545
+ base: Record<string, unknown> | undefined,
546
+ patch: Record<string, unknown>
547
+ ): Record<string, unknown> => {
548
+ const output: Record<string, unknown> = { ...(base ?? {}) };
549
+ for (const [key, value] of Object.entries(patch)) {
550
+ const prev = output[key];
551
+ if (
552
+ value &&
553
+ typeof value === "object" &&
554
+ !Array.isArray(value) &&
555
+ prev &&
556
+ typeof prev === "object" &&
557
+ !Array.isArray(prev)
558
+ ) {
559
+ output[key] = mergeOptionPatch(
560
+ prev as Record<string, unknown>,
561
+ value as Record<string, unknown>
562
+ );
563
+ } else {
564
+ output[key] = value;
565
+ }
566
+ }
567
+ return output;
568
+ },
569
+ []
570
+ );
571
+ const updateSelectedOptionForm = useCallback(
572
+ (patch: Record<string, unknown>) => {
573
+ if (!selectedElement) return;
574
+ updateSelectedChart({
575
+ option: mergeOptionPatch(
576
+ (selectedElement.chart?.option as Record<string, unknown> | undefined) ?? {},
577
+ patch
578
+ ),
579
+ });
580
+ },
581
+ [mergeOptionPatch, selectedElement, updateSelectedChart]
582
+ );
583
+ const execTextCommand = (cmd: "bold" | "italic" | "underline") => {
584
+ textEditorRef.current?.focus();
585
+ document.execCommand(cmd);
586
+ const next = textEditorRef.current?.innerHTML ?? "";
587
+ if (selectedElement?.materialType === "text") {
588
+ updateElement(selectedElement.id, { textHtml: next || "<p><br/></p>" });
589
+ }
590
+ };
591
+
592
+ let renderedSectionCount = 0;
593
+ return (
594
+ <ScopeConfigProvider
595
+ scope={showScopePanel ? viewElementScope : undefined}
596
+ warnings={scopeWarnings}
597
+ scrollContainerRef={sidebarScrollRef}
598
+ >
599
+ <aside
600
+ ref={sidebarScrollRef}
601
+ className={`scope-config-sidebar h-full overflow-auto border-l border-border bg-muted/[0.14] px-3 py-3 text-foreground [&_.scope-field--highlight]:rounded-md [&_.scope-field--highlight]:ring-2 [&_.scope-field--highlight]:ring-amber-400/80 [&_button[role=checkbox]]:border-2 [&_button[role=checkbox]]:border-foreground/80 [&_button[role=checkbox]]:bg-background [&_button[role=checkbox]]:ring-1 [&_button[role=checkbox]]:ring-foreground/40 [&_button[role=checkbox][data-state=checked]]:border-primary [&_button[role=checkbox][data-state=checked]]:ring-primary/40 ${themedScrollbarClass}`}
602
+ >
603
+ <div className="sticky top-0 z-20 mb-3 rounded-lg border border-border/70 bg-card/95 px-2.5 py-2 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-card/80">
604
+ <div className="flex items-center justify-between gap-2">
605
+ <div className="text-xs font-semibold tracking-wide">配置面板</div>
606
+ </div>
607
+ {showScopePanel ? (
608
+ <ViewElementScopePanel scope={viewElementScope} />
609
+ ) : null}
610
+ <ScopeTemplateWarningsPanel />
611
+ <div
612
+ className={
613
+ showScopePanel || scopeWarnings.length > 0
614
+ ? "mt-2 border-t border-border/50 pt-2"
615
+ : "mt-0"
616
+ }
617
+ >
618
+ <div className="flex items-center justify-end gap-2">
619
+ <button
620
+ type="button"
621
+ className="rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-accent"
622
+ onClick={() => setIsSearchCollapsed((prev) => !prev)}
623
+ >
624
+ {isSearchCollapsed ? "展开搜索" : "收起搜索"}
625
+ </button>
626
+ </div>
627
+ {!isSearchCollapsed ? (
628
+ <div className="mt-2">
629
+ <Input
630
+ value={configSearch}
631
+ onChange={(e) => setConfigSearch(e.target.value)}
632
+ placeholder="搜索配置,如:边框、tooltip、音频、网格..."
633
+ className="h-7"
634
+ data-scope-autocomplete="off"
635
+ />
636
+ {hasSearch ? (
637
+ <div className="mt-1 text-[11px] text-muted-foreground">
638
+ 搜索中:{configSearch}
639
+ </div>
640
+ ) : null}
641
+ </div>
642
+ ) : null}
643
+ </div>
644
+ </div>
645
+ {isMultiSelectMode ? (
646
+ <div className="space-y-3">
647
+ <Card>
648
+ <CardHeader className="pb-2">
649
+ <CardTitle className="text-xs">批量设置({effectiveSelectedElements.length} 个)</CardTitle>
650
+ </CardHeader>
651
+ <CardContent className="space-y-2 text-xs">
652
+ <div className="grid grid-cols-2 gap-2">
653
+ <button
654
+ type="button"
655
+ className="rounded border border-border bg-background px-2 py-1 hover:bg-accent"
656
+ onClick={() => effectiveSelectedElements.forEach((el) => updateElement(el.id, { locked: true }))}
657
+ >
658
+ 全部锁定
659
+ </button>
660
+ <button
661
+ type="button"
662
+ className="rounded border border-border bg-background px-2 py-1 hover:bg-accent"
663
+ onClick={() => effectiveSelectedElements.forEach((el) => updateElement(el.id, { locked: false }))}
664
+ >
665
+ 全部解锁
666
+ </button>
667
+ <button
668
+ type="button"
669
+ className="rounded border border-border bg-background px-2 py-1 hover:bg-accent"
670
+ onClick={() =>
671
+ effectiveSelectedElements.forEach((el) =>
672
+ onAdjustNodeZOrder?.(el.id, "bringForward")
673
+ )
674
+ }
675
+ >
676
+ 全部上移一层
677
+ </button>
678
+ <button
679
+ type="button"
680
+ className="rounded border border-border bg-background px-2 py-1 hover:bg-accent"
681
+ onClick={() =>
682
+ effectiveSelectedElements.forEach((el) =>
683
+ onAdjustNodeZOrder?.(el.id, "sendBackward")
684
+ )
685
+ }
686
+ >
687
+ 全部下移一层
688
+ </button>
689
+ <button
690
+ type="button"
691
+ className="rounded border border-border bg-background px-2 py-1 hover:bg-accent"
692
+ onClick={() =>
693
+ effectiveSelectedElements.forEach((el) =>
694
+ updateElement(el.id, { zIndex: 1 })
695
+ )
696
+ }
697
+ >
698
+ 全部 zIndex 设为 1
699
+ </button>
700
+ <button
701
+ type="button"
702
+ className="rounded border border-border bg-background px-2 py-1 hover:bg-accent"
703
+ onClick={() =>
704
+ effectiveSelectedElements.forEach((el) =>
705
+ updateElement(el.id, {
706
+ style: {
707
+ ...(el.style ?? {}),
708
+ backgroundColor: "#3b82f6",
709
+ },
710
+ })
711
+ )
712
+ }
713
+ >
714
+ 全部背景色设为蓝色
715
+ </button>
716
+ </div>
717
+ </CardContent>
718
+ </Card>
719
+ {effectiveSelectedElements
720
+ .filter((el) => {
721
+ if (!hasSearch) return true;
722
+ const text = `${el.name ?? ""} ${el.id} ${el.materialType ?? ""} zIndex style layer`
723
+ .toLowerCase();
724
+ return text.includes(normalizedSearch);
725
+ })
726
+ .map((el) => (
727
+ <Card
728
+ key={el.id}
729
+ className={el.locked ? "border-amber-500/40 bg-amber-500/5" : ""}
730
+ >
731
+ <CardHeader className="pb-2">
732
+ <div className="flex items-center gap-2">
733
+ <button
734
+ type="button"
735
+ className="inline-flex h-6 w-6 items-center justify-center rounded border border-border text-[11px] hover:bg-accent"
736
+ onClick={() => setNodeCardExpanded(el.id, !isNodeCardExpanded(el.id))}
737
+ aria-label={isNodeCardExpanded(el.id) ? "收起节点配置" : "展开节点配置"}
738
+ >
739
+ {isNodeCardExpanded(el.id) ? "▾" : "▸"}
740
+ </button>
741
+ <CardTitle className="min-w-0 flex-1 text-xs truncate">
742
+ {el.name?.trim() || el.materialType || "节点"} · {el.id}
743
+ </CardTitle>
744
+ <button
745
+ type="button"
746
+ className="inline-flex h-6 items-center justify-center rounded border border-border px-2 text-[11px] text-muted-foreground hover:bg-accent hover:text-foreground"
747
+ onClick={() => onExcludeSelectedNode?.(el.id)}
748
+ title="将该节点从当前多选中剔除"
749
+ >
750
+ 剔除
751
+ </button>
752
+ </div>
753
+ </CardHeader>
754
+ {isNodeCardExpanded(el.id) ? (
755
+ <CardContent className="space-y-2 text-xs">
756
+ <label className="flex items-center gap-2">
757
+ <Checkbox
758
+ checked={el.locked === true}
759
+ onCheckedChange={(checked) => updateElement(el.id, { locked: checked === true })}
760
+ />
761
+ <span>锁定节点</span>
762
+ </label>
763
+ {el.locked ? (
764
+ <div className="rounded border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-[11px] text-amber-700 dark:text-amber-300">
765
+ 当前节点已锁定,仅可操作锁定开关。
766
+ </div>
767
+ ) : null}
768
+ <fieldset disabled={el.locked} className={el.locked ? "opacity-60" : ""}>
769
+ <div className="space-y-2">
770
+ <label className="block space-y-1">
771
+ <div>名称</div>
772
+ <Input
773
+ className="h-7"
774
+ value={el.name ?? ""}
775
+ onChange={(e) => updateElement(el.id, { name: e.target.value || undefined })}
776
+ />
777
+ </label>
778
+ <div className="grid grid-cols-2 gap-2">
779
+ <label className="block space-y-1">
780
+ <div>zIndex</div>
781
+ <Input
782
+ className="h-7"
783
+ type="number"
784
+ value={el.zIndex ?? 1}
785
+ onChange={(e) =>
786
+ updateElement(el.id, { zIndex: Number(e.target.value) || 1 })
787
+ }
788
+ />
789
+ </label>
790
+ <label className="block space-y-1">
791
+ <div>图层</div>
792
+ <Select
793
+ value={el.layerId}
794
+ onValueChange={(value) => updateElement(el.id, { layerId: value })}
795
+ >
796
+ <SelectTrigger className="h-7">
797
+ <SelectValue placeholder="选择图层" />
798
+ </SelectTrigger>
799
+ <SelectContent>
800
+ {layers.map((layer) => (
801
+ <SelectItem key={layer.id} value={layer.id}>
802
+ {layer.name}
803
+ </SelectItem>
804
+ ))}
805
+ </SelectContent>
806
+ </Select>
807
+ </label>
808
+ </div>
809
+ <div className="grid grid-cols-2 gap-2">
810
+ <label className="block space-y-1">
811
+ <div>X</div>
812
+ <Input
813
+ className="h-7"
814
+ type="number"
815
+ value={el.x}
816
+ onChange={(e) => updateElement(el.id, { x: Number(e.target.value) || 0 })}
817
+ />
818
+ </label>
819
+ <label className="block space-y-1">
820
+ <div>Y</div>
821
+ <Input
822
+ className="h-7"
823
+ type="number"
824
+ value={el.y}
825
+ onChange={(e) => updateElement(el.id, { y: Number(e.target.value) || 0 })}
826
+ />
827
+ </label>
828
+ <label className="block space-y-1">
829
+ <div>旋转角度</div>
830
+ <Input
831
+ className="h-7"
832
+ type="number"
833
+ value={el.rotate ?? 0}
834
+ onChange={(e) =>
835
+ updateElement(el.id, { rotate: Number(e.target.value) || 0 })
836
+ }
837
+ />
838
+ </label>
839
+ <label className="block space-y-1">
840
+ <div>宽</div>
841
+ <Input
842
+ className="h-7"
843
+ type="number"
844
+ min={1}
845
+ value={el.width}
846
+ onChange={(e) =>
847
+ updateElement(el.id, { width: Math.max(1, Number(e.target.value) || 1) })
848
+ }
849
+ />
850
+ </label>
851
+ <label className="block space-y-1">
852
+ <div>高</div>
853
+ <Input
854
+ className="h-7"
855
+ type="number"
856
+ min={1}
857
+ value={el.height}
858
+ onChange={(e) =>
859
+ updateElement(el.id, { height: Math.max(1, Number(e.target.value) || 1) })
860
+ }
861
+ />
862
+ </label>
863
+ </div>
864
+ <div className="grid grid-cols-2 gap-2">
865
+ <label className="block space-y-1">
866
+ <div>背景色</div>
867
+ <Input
868
+ className="h-7"
869
+ value={el.style?.backgroundColor ?? ""}
870
+ placeholder="#000000"
871
+ onChange={(e) =>
872
+ updateElement(el.id, {
873
+ style: {
874
+ ...(el.style ?? {}),
875
+ backgroundColor: e.target.value || undefined,
876
+ },
877
+ })
878
+ }
879
+ />
880
+ </label>
881
+ <label className="block space-y-1">
882
+ <div>边框色</div>
883
+ <Input
884
+ className="h-7"
885
+ value={el.style?.borderColor ?? ""}
886
+ placeholder="#000000"
887
+ onChange={(e) =>
888
+ updateElement(el.id, {
889
+ style: {
890
+ ...(el.style ?? {}),
891
+ borderColor: e.target.value || undefined,
892
+ },
893
+ })
894
+ }
895
+ />
896
+ </label>
897
+ </div>
898
+ {CHART_TYPES.has(el.materialType ?? "") ? (
899
+ <div className="grid grid-cols-2 gap-2">
900
+ <label className="block space-y-1 col-span-2">
901
+ <div>图表标题</div>
902
+ <Input
903
+ className="h-7"
904
+ value={el.chart?.title ?? ""}
905
+ onChange={(e) =>
906
+ updateElement(el.id, {
907
+ chart: { ...(el.chart ?? {}), title: e.target.value },
908
+ })
909
+ }
910
+ />
911
+ </label>
912
+ <label className="block space-y-1">
913
+ <div>主色</div>
914
+ <Input
915
+ className="h-7"
916
+ value={el.chart?.color ?? ""}
917
+ onChange={(e) =>
918
+ updateElement(el.id, {
919
+ chart: { ...(el.chart ?? {}), color: e.target.value || undefined },
920
+ })
921
+ }
922
+ />
923
+ </label>
924
+ <label className="flex items-center gap-2">
925
+ <Checkbox
926
+ checked={el.chart?.colorMode === "gradient"}
927
+ onCheckedChange={(checked) =>
928
+ updateElement(el.id, {
929
+ chart: { ...(el.chart ?? {}), colorMode: checked ? "gradient" : "solid" },
930
+ })
931
+ }
932
+ />
933
+ <span>主色渐变</span>
934
+ </label>
935
+ {el.chart?.colorMode === "gradient" ? (
936
+ <>
937
+ <label className="block space-y-1">
938
+ <div>渐变起始色</div>
939
+ <Input
940
+ className="h-7"
941
+ value={el.chart?.gradientFrom ?? el.chart?.color ?? "#3b82f6"}
942
+ onChange={(e) =>
943
+ updateElement(el.id, {
944
+ chart: { ...(el.chart ?? {}), gradientFrom: e.target.value || "#3b82f6" },
945
+ })
946
+ }
947
+ />
948
+ </label>
949
+ <label className="block space-y-1">
950
+ <div>渐变结束色</div>
951
+ <Input
952
+ className="h-7"
953
+ value={el.chart?.gradientTo ?? "#22d3ee"}
954
+ onChange={(e) =>
955
+ updateElement(el.id, {
956
+ chart: { ...(el.chart ?? {}), gradientTo: e.target.value || "#22d3ee" },
957
+ })
958
+ }
959
+ />
960
+ </label>
961
+ <label className="block space-y-1 col-span-2">
962
+ <div>渐变方向</div>
963
+ <Select
964
+ value={el.chart?.gradientDirection ?? "to-right"}
965
+ onValueChange={(value) =>
966
+ updateElement(el.id, {
967
+ chart: {
968
+ ...(el.chart ?? {}),
969
+ gradientDirection: value as
970
+ | "to-right"
971
+ | "to-bottom"
972
+ | "to-bottom-right"
973
+ | "to-top-right",
974
+ },
975
+ })
976
+ }
977
+ >
978
+ <SelectTrigger className="h-7"><SelectValue /></SelectTrigger>
979
+ <SelectContent>
980
+ <SelectItem value="to-right">左 → 右</SelectItem>
981
+ <SelectItem value="to-bottom">上 → 下</SelectItem>
982
+ <SelectItem value="to-bottom-right">左上 → 右下</SelectItem>
983
+ <SelectItem value="to-top-right">左下 → 右上</SelectItem>
984
+ </SelectContent>
985
+ </Select>
986
+ </label>
987
+ <div className="col-span-2 space-y-1">
988
+ <div className="text-[11px] text-muted-foreground">渐变预览</div>
989
+ <div
990
+ className="h-6 rounded border border-border/60"
991
+ style={{
992
+ backgroundImage: `linear-gradient(${
993
+ (el.chart?.gradientDirection ?? "to-right") === "to-bottom"
994
+ ? "to bottom"
995
+ : (el.chart?.gradientDirection ?? "to-right") === "to-bottom-right"
996
+ ? "to bottom right"
997
+ : (el.chart?.gradientDirection ?? "to-right") === "to-top-right"
998
+ ? "to top right"
999
+ : "to right"
1000
+ }, ${el.chart?.gradientFrom ?? el.chart?.color ?? "#3b82f6"}, ${el.chart?.gradientTo ?? "#22d3ee"})`,
1001
+ }}
1002
+ />
1003
+ </div>
1004
+ </>
1005
+ ) : null}
1006
+ <label className="block space-y-1">
1007
+ <div>渲染</div>
1008
+ <Select
1009
+ value={el.chart?.renderMode ?? "canvas"}
1010
+ onValueChange={(value) =>
1011
+ updateElement(el.id, {
1012
+ chart: { ...(el.chart ?? {}), renderMode: value as "canvas" | "svg" },
1013
+ })
1014
+ }
1015
+ >
1016
+ <SelectTrigger className="h-7">
1017
+ <SelectValue />
1018
+ </SelectTrigger>
1019
+ <SelectContent>
1020
+ <SelectItem value="canvas">canvas</SelectItem>
1021
+ <SelectItem value="svg">svg</SelectItem>
1022
+ </SelectContent>
1023
+ </Select>
1024
+ </label>
1025
+ <label className="block space-y-1">
1026
+ <div>Tooltip 背景</div>
1027
+ <Input
1028
+ className="h-7"
1029
+ value={el.chart?.tooltipBackgroundColor ?? ""}
1030
+ placeholder="#0f172a"
1031
+ onChange={(e) =>
1032
+ updateElement(el.id, {
1033
+ chart: {
1034
+ ...(el.chart ?? {}),
1035
+ tooltipBackgroundColor: e.target.value || undefined,
1036
+ },
1037
+ })
1038
+ }
1039
+ />
1040
+ </label>
1041
+ <label className="flex items-center gap-2">
1042
+ <Checkbox
1043
+ checked={el.chart?.tooltipShow ?? true}
1044
+ onCheckedChange={(checked) =>
1045
+ updateElement(el.id, {
1046
+ chart: { ...(el.chart ?? {}), tooltipShow: checked === true },
1047
+ })
1048
+ }
1049
+ />
1050
+ <span>显示 Tooltip</span>
1051
+ </label>
1052
+ <label className="block space-y-1">
1053
+ <div>Tooltip 触发方式</div>
1054
+ <Select
1055
+ value={el.chart?.tooltipTrigger ?? "axis"}
1056
+ onValueChange={(value) =>
1057
+ updateElement(el.id, {
1058
+ chart: { ...(el.chart ?? {}), tooltipTrigger: value as "axis" | "item" },
1059
+ })
1060
+ }
1061
+ >
1062
+ <SelectTrigger className="h-7"><SelectValue /></SelectTrigger>
1063
+ <SelectContent>
1064
+ <SelectItem value="axis">axis</SelectItem>
1065
+ <SelectItem value="item">item</SelectItem>
1066
+ </SelectContent>
1067
+ </Select>
1068
+ </label>
1069
+ <label className="block space-y-1">
1070
+ <div>Tooltip 文字色</div>
1071
+ <Input
1072
+ className="h-7"
1073
+ value={el.chart?.tooltipTextColor ?? ""}
1074
+ placeholder="#f8fafc"
1075
+ onChange={(e) =>
1076
+ updateElement(el.id, {
1077
+ chart: {
1078
+ ...(el.chart ?? {}),
1079
+ tooltipTextColor: e.target.value || undefined,
1080
+ },
1081
+ })
1082
+ }
1083
+ />
1084
+ </label>
1085
+ <label className="block space-y-1 col-span-2">
1086
+ {renderFormatterLabel("Tooltip Formatter")}
1087
+ <Input
1088
+ className="h-7"
1089
+ value={el.chart?.tooltipFormatter ?? ""}
1090
+ placeholder="{b}: {c}"
1091
+ onChange={(e) =>
1092
+ updateElement(el.id, {
1093
+ chart: {
1094
+ ...(el.chart ?? {}),
1095
+ tooltipFormatter: e.target.value || undefined,
1096
+ },
1097
+ })
1098
+ }
1099
+ />
1100
+ </label>
1101
+ <label className="block space-y-1 col-span-2">
1102
+ <div>类目(逗号分隔)</div>
1103
+ <Input
1104
+ className="h-7"
1105
+ value={(el.chart?.labels ?? []).join(",")}
1106
+ onChange={(e) =>
1107
+ updateElement(el.id, {
1108
+ chart: {
1109
+ ...(el.chart ?? {}),
1110
+ labels: e.target.value
1111
+ .split(",")
1112
+ .map((s) => s.trim())
1113
+ .filter(Boolean),
1114
+ },
1115
+ })
1116
+ }
1117
+ />
1118
+ </label>
1119
+ <label className="block space-y-1 col-span-2">
1120
+ <div>数值(逗号分隔)</div>
1121
+ <Input
1122
+ className="h-7"
1123
+ value={getChartValuesDisplayText(el.chart)}
1124
+ onChange={(e) =>
1125
+ updateElement(el.id, {
1126
+ chart: {
1127
+ ...(el.chart ?? {}),
1128
+ valuesText: e.target.value,
1129
+ },
1130
+ })
1131
+ }
1132
+ />
1133
+ </label>
1134
+ {["bar", "line", "area", "scatter"].includes(el.materialType ?? "") ? (
1135
+ <>
1136
+ <label className="block space-y-1">
1137
+ <div>X 轴名称</div>
1138
+ <Input
1139
+ className="h-7"
1140
+ value={el.chart?.xAxisName ?? ""}
1141
+ onChange={(e) =>
1142
+ updateElement(el.id, {
1143
+ chart: { ...(el.chart ?? {}), xAxisName: e.target.value },
1144
+ })
1145
+ }
1146
+ />
1147
+ </label>
1148
+ <label className="block space-y-1">
1149
+ <div>Y 轴名称</div>
1150
+ <Input
1151
+ className="h-7"
1152
+ value={el.chart?.yAxisName ?? ""}
1153
+ onChange={(e) =>
1154
+ updateElement(el.id, {
1155
+ chart: { ...(el.chart ?? {}), yAxisName: e.target.value },
1156
+ })
1157
+ }
1158
+ />
1159
+ </label>
1160
+ <label className="block space-y-1">
1161
+ <div>X 轴标签颜色</div>
1162
+ <Input
1163
+ className="h-7"
1164
+ value={el.chart?.xAxisLabelColor ?? ""}
1165
+ placeholder="#64748b"
1166
+ onChange={(e) =>
1167
+ updateElement(el.id, {
1168
+ chart: { ...(el.chart ?? {}), xAxisLabelColor: e.target.value || undefined },
1169
+ })
1170
+ }
1171
+ />
1172
+ </label>
1173
+ <label className="block space-y-1">
1174
+ <div>Y 轴标签颜色</div>
1175
+ <Input
1176
+ className="h-7"
1177
+ value={el.chart?.yAxisLabelColor ?? ""}
1178
+ placeholder="#64748b"
1179
+ onChange={(e) =>
1180
+ updateElement(el.id, {
1181
+ chart: { ...(el.chart ?? {}), yAxisLabelColor: e.target.value || undefined },
1182
+ })
1183
+ }
1184
+ />
1185
+ </label>
1186
+ <label className="block space-y-1">
1187
+ <div>X 轴标签字号</div>
1188
+ <Input
1189
+ className="h-7"
1190
+ type="number"
1191
+ min={8}
1192
+ max={48}
1193
+ value={el.chart?.xAxisLabelFontSize ?? 10}
1194
+ onChange={(e) =>
1195
+ updateElement(el.id, {
1196
+ chart: {
1197
+ ...(el.chart ?? {}),
1198
+ xAxisLabelFontSize: Math.max(8, Math.min(48, Number(e.target.value) || 10)),
1199
+ },
1200
+ })
1201
+ }
1202
+ />
1203
+ </label>
1204
+ <label className="block space-y-1">
1205
+ <div>Y 轴标签字号</div>
1206
+ <Input
1207
+ className="h-7"
1208
+ type="number"
1209
+ min={8}
1210
+ max={48}
1211
+ value={el.chart?.yAxisLabelFontSize ?? 10}
1212
+ onChange={(e) =>
1213
+ updateElement(el.id, {
1214
+ chart: {
1215
+ ...(el.chart ?? {}),
1216
+ yAxisLabelFontSize: Math.max(8, Math.min(48, Number(e.target.value) || 10)),
1217
+ },
1218
+ })
1219
+ }
1220
+ />
1221
+ </label>
1222
+ <label className="flex items-center gap-2">
1223
+ <Checkbox
1224
+ checked={el.chart?.xAxisLabelAutoEllipsis ?? false}
1225
+ onCheckedChange={(checked) =>
1226
+ updateElement(el.id, {
1227
+ chart: { ...(el.chart ?? {}), xAxisLabelAutoEllipsis: checked === true },
1228
+ })
1229
+ }
1230
+ />
1231
+ <span>X 轴标签自动缩略</span>
1232
+ </label>
1233
+ <label className="flex items-center gap-2">
1234
+ <Checkbox
1235
+ checked={el.chart?.yAxisLabelAutoEllipsis ?? false}
1236
+ onCheckedChange={(checked) =>
1237
+ updateElement(el.id, {
1238
+ chart: { ...(el.chart ?? {}), yAxisLabelAutoEllipsis: checked === true },
1239
+ })
1240
+ }
1241
+ />
1242
+ <span>Y 轴标签自动缩略</span>
1243
+ </label>
1244
+ <label className="flex items-center gap-2">
1245
+ <Checkbox
1246
+ checked={el.chart?.xAxisTickShow ?? true}
1247
+ onCheckedChange={(checked) =>
1248
+ updateElement(el.id, {
1249
+ chart: { ...(el.chart ?? {}), xAxisTickShow: checked === true },
1250
+ })
1251
+ }
1252
+ />
1253
+ <span>X 轴刻度线</span>
1254
+ </label>
1255
+ <label className="flex items-center gap-2">
1256
+ <Checkbox
1257
+ checked={el.chart?.yAxisTickShow ?? true}
1258
+ onCheckedChange={(checked) =>
1259
+ updateElement(el.id, {
1260
+ chart: { ...(el.chart ?? {}), yAxisTickShow: checked === true },
1261
+ })
1262
+ }
1263
+ />
1264
+ <span>Y 轴刻度线</span>
1265
+ </label>
1266
+ <label className="block space-y-1">
1267
+ <div>X 轴刻度线颜色</div>
1268
+ <Input
1269
+ className="h-7"
1270
+ value={el.chart?.xAxisTickColor ?? ""}
1271
+ placeholder="#94a3b8"
1272
+ onChange={(e) =>
1273
+ updateElement(el.id, {
1274
+ chart: { ...(el.chart ?? {}), xAxisTickColor: e.target.value || undefined },
1275
+ })
1276
+ }
1277
+ />
1278
+ </label>
1279
+ <label className="block space-y-1">
1280
+ <div>Y 轴刻度线颜色</div>
1281
+ <Input
1282
+ className="h-7"
1283
+ value={el.chart?.yAxisTickColor ?? ""}
1284
+ placeholder="#94a3b8"
1285
+ onChange={(e) =>
1286
+ updateElement(el.id, {
1287
+ chart: { ...(el.chart ?? {}), yAxisTickColor: e.target.value || undefined },
1288
+ })
1289
+ }
1290
+ />
1291
+ </label>
1292
+ <label className="flex items-center gap-2">
1293
+ <Checkbox
1294
+ checked={el.chart?.xAxisSplitLineShow ?? false}
1295
+ onCheckedChange={(checked) =>
1296
+ updateElement(el.id, {
1297
+ chart: { ...(el.chart ?? {}), xAxisSplitLineShow: checked === true },
1298
+ })
1299
+ }
1300
+ />
1301
+ <span>X 轴分割线</span>
1302
+ </label>
1303
+ <label className="flex items-center gap-2">
1304
+ <Checkbox
1305
+ checked={el.chart?.yAxisSplitLineShow ?? true}
1306
+ onCheckedChange={(checked) =>
1307
+ updateElement(el.id, {
1308
+ chart: { ...(el.chart ?? {}), yAxisSplitLineShow: checked === true },
1309
+ })
1310
+ }
1311
+ />
1312
+ <span>Y 轴分割线</span>
1313
+ </label>
1314
+ <label className="block space-y-1">
1315
+ <div>X 轴分割线颜色</div>
1316
+ <Input
1317
+ className="h-7"
1318
+ value={el.chart?.xAxisSplitLineColor ?? ""}
1319
+ placeholder="#e2e8f0"
1320
+ onChange={(e) =>
1321
+ updateElement(el.id, {
1322
+ chart: { ...(el.chart ?? {}), xAxisSplitLineColor: e.target.value || undefined },
1323
+ })
1324
+ }
1325
+ />
1326
+ </label>
1327
+ <label className="block space-y-1">
1328
+ <div>Y 轴分割线颜色</div>
1329
+ <Input
1330
+ className="h-7"
1331
+ value={el.chart?.yAxisSplitLineColor ?? ""}
1332
+ placeholder="#e2e8f0"
1333
+ onChange={(e) =>
1334
+ updateElement(el.id, {
1335
+ chart: { ...(el.chart ?? {}), yAxisSplitLineColor: e.target.value || undefined },
1336
+ })
1337
+ }
1338
+ />
1339
+ </label>
1340
+ </>
1341
+ ) : null}
1342
+ {el.materialType === "gauge" ? (
1343
+ <label className="block space-y-1">
1344
+ <div>仪表盘值</div>
1345
+ <Input
1346
+ className="h-7"
1347
+ type="number"
1348
+ value={el.chart?.values?.[0] ?? 0}
1349
+ onChange={(e) =>
1350
+ updateElement(el.id, {
1351
+ chart: {
1352
+ ...(el.chart ?? {}),
1353
+ values: [Number(e.target.value) || 0],
1354
+ },
1355
+ })
1356
+ }
1357
+ />
1358
+ </label>
1359
+ ) : null}
1360
+ {el.materialType === "bar" ? (
1361
+ <label className="block space-y-1">
1362
+ <div>柱宽</div>
1363
+ <Input
1364
+ className="h-7"
1365
+ type="number"
1366
+ min={1}
1367
+ value={el.chart?.barWidth ?? 24}
1368
+ onChange={(e) =>
1369
+ updateElement(el.id, {
1370
+ chart: {
1371
+ ...(el.chart ?? {}),
1372
+ barWidth: Math.max(1, Number(e.target.value) || 1),
1373
+ },
1374
+ })
1375
+ }
1376
+ />
1377
+ </label>
1378
+ ) : null}
1379
+ {(el.materialType === "line" || el.materialType === "area") ? (
1380
+ <label className="flex items-center gap-2">
1381
+ <Checkbox
1382
+ checked={el.chart?.smooth ?? true}
1383
+ onCheckedChange={(checked) =>
1384
+ updateElement(el.id, {
1385
+ chart: { ...(el.chart ?? {}), smooth: checked === true },
1386
+ })
1387
+ }
1388
+ />
1389
+ <span>平滑曲线</span>
1390
+ </label>
1391
+ ) : null}
1392
+ {el.materialType === "pie" ? (
1393
+ <>
1394
+ <label className="block space-y-1">
1395
+ <div>内半径</div>
1396
+ <Input
1397
+ className="h-7"
1398
+ type="number"
1399
+ min={0}
1400
+ max={99}
1401
+ value={el.chart?.pieInnerRadius ?? 30}
1402
+ onChange={(e) =>
1403
+ updateElement(el.id, {
1404
+ chart: {
1405
+ ...(el.chart ?? {}),
1406
+ pieInnerRadius: Math.max(0, Math.min(99, Number(e.target.value) || 0)),
1407
+ },
1408
+ })
1409
+ }
1410
+ />
1411
+ </label>
1412
+ <label className="block space-y-1">
1413
+ <div>外半径</div>
1414
+ <Input
1415
+ className="h-7"
1416
+ type="number"
1417
+ min={1}
1418
+ max={100}
1419
+ value={el.chart?.pieOuterRadius ?? 65}
1420
+ onChange={(e) =>
1421
+ updateElement(el.id, {
1422
+ chart: {
1423
+ ...(el.chart ?? {}),
1424
+ pieOuterRadius: Math.max(1, Math.min(100, Number(e.target.value) || 1)),
1425
+ },
1426
+ })
1427
+ }
1428
+ />
1429
+ </label>
1430
+ </>
1431
+ ) : null}
1432
+ <label className="block space-y-1">
1433
+ {renderOptionLabel("网格左边距", "grid.left", "控制绘图区左侧留白。")}
1434
+ <Input
1435
+ className="h-7"
1436
+ type="number"
1437
+ value={Number((el.chart?.option as any)?.grid?.left ?? 28)}
1438
+ onChange={(e) =>
1439
+ updateElement(el.id, {
1440
+ chart: {
1441
+ ...(el.chart ?? {}),
1442
+ option: mergeOptionPatch(el.chart?.option as any, {
1443
+ grid: { left: Number(e.target.value) || 0 },
1444
+ }),
1445
+ },
1446
+ })
1447
+ }
1448
+ />
1449
+ </label>
1450
+ <label className="block space-y-1">
1451
+ {renderOptionLabel("网格右边距", "grid.right", "控制绘图区右侧留白。")}
1452
+ <Input
1453
+ className="h-7"
1454
+ type="number"
1455
+ value={Number((el.chart?.option as any)?.grid?.right ?? 10)}
1456
+ onChange={(e) =>
1457
+ updateElement(el.id, {
1458
+ chart: {
1459
+ ...(el.chart ?? {}),
1460
+ option: mergeOptionPatch(el.chart?.option as any, {
1461
+ grid: { right: Number(e.target.value) || 0 },
1462
+ }),
1463
+ },
1464
+ })
1465
+ }
1466
+ />
1467
+ </label>
1468
+ <label className="block space-y-1">
1469
+ {renderOptionLabel("网格上边距", "grid.top", "控制绘图区顶部留白。")}
1470
+ <Input
1471
+ className="h-7"
1472
+ type="number"
1473
+ value={Number((el.chart?.option as any)?.grid?.top ?? 30)}
1474
+ onChange={(e) =>
1475
+ updateElement(el.id, {
1476
+ chart: {
1477
+ ...(el.chart ?? {}),
1478
+ option: mergeOptionPatch(el.chart?.option as any, {
1479
+ grid: { top: Number(e.target.value) || 0 },
1480
+ }),
1481
+ },
1482
+ })
1483
+ }
1484
+ />
1485
+ </label>
1486
+ <label className="block space-y-1">
1487
+ {renderOptionLabel("网格下边距", "grid.bottom", "控制绘图区底部留白。")}
1488
+ <Input
1489
+ className="h-7"
1490
+ type="number"
1491
+ value={Number((el.chart?.option as any)?.grid?.bottom ?? 20)}
1492
+ onChange={(e) =>
1493
+ updateElement(el.id, {
1494
+ chart: {
1495
+ ...(el.chart ?? {}),
1496
+ option: mergeOptionPatch(el.chart?.option as any, {
1497
+ grid: { bottom: Number(e.target.value) || 0 },
1498
+ }),
1499
+ },
1500
+ })
1501
+ }
1502
+ />
1503
+ </label>
1504
+ <label className="block space-y-1">
1505
+ {renderOptionLabel("图例位置", "legend.top", "设置图例在容器中的停靠位置。")}
1506
+ <Select
1507
+ value={String((el.chart?.option as any)?.legend?.top ?? "top")}
1508
+ onValueChange={(value) =>
1509
+ updateElement(el.id, {
1510
+ chart: {
1511
+ ...(el.chart ?? {}),
1512
+ option: mergeOptionPatch(el.chart?.option as any, {
1513
+ legend: { top: value },
1514
+ }),
1515
+ },
1516
+ })
1517
+ }
1518
+ >
1519
+ <SelectTrigger className="h-7"><SelectValue /></SelectTrigger>
1520
+ <SelectContent>
1521
+ <SelectItem value="top">top</SelectItem>
1522
+ <SelectItem value="bottom">bottom</SelectItem>
1523
+ <SelectItem value="left">left</SelectItem>
1524
+ <SelectItem value="right">right</SelectItem>
1525
+ </SelectContent>
1526
+ </Select>
1527
+ </label>
1528
+ <label className="block space-y-1">
1529
+ {renderOptionLabel("图例排列", "legend.orient", "图例横向或纵向排列。")}
1530
+ <Select
1531
+ value={String((el.chart?.option as any)?.legend?.orient ?? "horizontal")}
1532
+ onValueChange={(value) =>
1533
+ updateElement(el.id, {
1534
+ chart: {
1535
+ ...(el.chart ?? {}),
1536
+ option: mergeOptionPatch(el.chart?.option as any, {
1537
+ legend: { orient: value },
1538
+ }),
1539
+ },
1540
+ })
1541
+ }
1542
+ >
1543
+ <SelectTrigger className="h-7"><SelectValue /></SelectTrigger>
1544
+ <SelectContent>
1545
+ <SelectItem value="horizontal">horizontal</SelectItem>
1546
+ <SelectItem value="vertical">vertical</SelectItem>
1547
+ </SelectContent>
1548
+ </Select>
1549
+ </label>
1550
+ <label className="flex items-center gap-2">
1551
+ <Checkbox
1552
+ checked={Boolean((el.chart?.option as any)?.legend?.show ?? true)}
1553
+ onCheckedChange={(checked) =>
1554
+ updateElement(el.id, {
1555
+ chart: {
1556
+ ...(el.chart ?? {}),
1557
+ option: mergeOptionPatch(el.chart?.option as any, {
1558
+ legend: { show: checked === true },
1559
+ }),
1560
+ },
1561
+ })
1562
+ }
1563
+ />
1564
+ <span>显示图例(legend.show)</span>
1565
+ </label>
1566
+ <label className="flex items-center gap-2">
1567
+ <Checkbox
1568
+ checked={Boolean(
1569
+ Array.isArray((el.chart?.option as any)?.dataZoom) &&
1570
+ (el.chart?.option as any)?.dataZoom.some((z: any) => z?.type === "inside")
1571
+ )}
1572
+ onCheckedChange={(checked) => {
1573
+ const prev = Array.isArray((el.chart?.option as any)?.dataZoom)
1574
+ ? [...(el.chart?.option as any).dataZoom]
1575
+ : [];
1576
+ const next = checked
1577
+ ? [...prev.filter((z: any) => z?.type !== "inside"), { type: "inside" }]
1578
+ : prev.filter((z: any) => z?.type !== "inside");
1579
+ updateElement(el.id, {
1580
+ chart: {
1581
+ ...(el.chart ?? {}),
1582
+ option: mergeOptionPatch(el.chart?.option as any, { dataZoom: next }),
1583
+ },
1584
+ });
1585
+ }}
1586
+ />
1587
+ <span>内置缩放(dataZoom[type=inside])</span>
1588
+ </label>
1589
+ <label className="flex items-center gap-2">
1590
+ <Checkbox
1591
+ checked={Boolean(
1592
+ Array.isArray((el.chart?.option as any)?.dataZoom) &&
1593
+ (el.chart?.option as any)?.dataZoom.some((z: any) => z?.type === "slider")
1594
+ )}
1595
+ onCheckedChange={(checked) => {
1596
+ const prev = Array.isArray((el.chart?.option as any)?.dataZoom)
1597
+ ? [...(el.chart?.option as any).dataZoom]
1598
+ : [];
1599
+ const next = checked
1600
+ ? [...prev.filter((z: any) => z?.type !== "slider"), { type: "slider" }]
1601
+ : prev.filter((z: any) => z?.type !== "slider");
1602
+ updateElement(el.id, {
1603
+ chart: {
1604
+ ...(el.chart ?? {}),
1605
+ option: mergeOptionPatch(el.chart?.option as any, { dataZoom: next }),
1606
+ },
1607
+ });
1608
+ }}
1609
+ />
1610
+ <span>滑块缩放(dataZoom[type=slider])</span>
1611
+ </label>
1612
+ <label className="flex items-center gap-2">
1613
+ <Checkbox
1614
+ checked={Boolean((el.chart?.option as any)?.axisPointer?.show ?? false)}
1615
+ onCheckedChange={(checked) =>
1616
+ updateElement(el.id, {
1617
+ chart: {
1618
+ ...(el.chart ?? {}),
1619
+ option: mergeOptionPatch(el.chart?.option as any, {
1620
+ axisPointer: { show: checked === true },
1621
+ }),
1622
+ },
1623
+ })
1624
+ }
1625
+ />
1626
+ <span>显示轴指示器(axisPointer.show)</span>
1627
+ </label>
1628
+ <label className="block space-y-1">
1629
+ <div>轴指示器类型(axisPointer.type)</div>
1630
+ <Select
1631
+ value={String((el.chart?.option as any)?.axisPointer?.type ?? "line")}
1632
+ onValueChange={(value) =>
1633
+ updateElement(el.id, {
1634
+ chart: {
1635
+ ...(el.chart ?? {}),
1636
+ option: mergeOptionPatch(el.chart?.option as any, {
1637
+ axisPointer: { type: value },
1638
+ }),
1639
+ },
1640
+ })
1641
+ }
1642
+ >
1643
+ <SelectTrigger className="h-7"><SelectValue /></SelectTrigger>
1644
+ <SelectContent>
1645
+ <SelectItem value="line">line</SelectItem>
1646
+ <SelectItem value="shadow">shadow</SelectItem>
1647
+ <SelectItem value="cross">cross</SelectItem>
1648
+ </SelectContent>
1649
+ </Select>
1650
+ </label>
1651
+ <label className="block space-y-1 col-span-2">
1652
+ <div>高级 option JSON(覆盖基础配置)</div>
1653
+ <Textarea
1654
+ className="h-28 font-mono text-[11px]"
1655
+ defaultValue={JSON.stringify(el.chart?.option ?? {}, null, 2)}
1656
+ placeholder='例如:{"grid":{"left":24}}'
1657
+ onBlur={(e) => {
1658
+ const nextText = e.target.value.trim();
1659
+ if (!nextText) {
1660
+ updateElement(el.id, { chart: { ...(el.chart ?? {}), option: undefined } });
1661
+ return;
1662
+ }
1663
+ try {
1664
+ const parsed = JSON.parse(nextText) as Record<string, unknown>;
1665
+ updateElement(el.id, { chart: { ...(el.chart ?? {}), option: parsed } });
1666
+ } catch {
1667
+ // ignore invalid json input on blur
1668
+ }
1669
+ }}
1670
+ />
1671
+ </label>
1672
+ </div>
1673
+ ) : null}
1674
+ {el.materialType === "text" ? (
1675
+ <div className="grid grid-cols-2 gap-2">
1676
+ <label className="block space-y-1 col-span-2">
1677
+ <div>文本内容(HTML)</div>
1678
+ <Textarea
1679
+ className="h-24"
1680
+ value={el.textHtml ?? ""}
1681
+ onChange={(e) => updateElement(el.id, { textHtml: e.target.value || "<p><br/></p>" })}
1682
+ />
1683
+ </label>
1684
+ <label className="block space-y-1">
1685
+ <div>字体大小</div>
1686
+ <Input
1687
+ className="h-7"
1688
+ type="number"
1689
+ min={8}
1690
+ value={el.textFontSize ?? 14}
1691
+ onChange={(e) => updateElement(el.id, { textFontSize: Math.max(8, Number(e.target.value) || 14) })}
1692
+ />
1693
+ </label>
1694
+ <label className="block space-y-1">
1695
+ <div>文字颜色</div>
1696
+ <Input
1697
+ className="h-7"
1698
+ value={el.textColor ?? ""}
1699
+ onChange={(e) => updateElement(el.id, { textColor: e.target.value || undefined })}
1700
+ />
1701
+ </label>
1702
+ </div>
1703
+ ) : null}
1704
+ {el.materialType === "audio" ? (
1705
+ <div className="grid grid-cols-2 gap-2">
1706
+ <label className="block space-y-1 col-span-2">
1707
+ <div>音频 URL</div>
1708
+ <Input
1709
+ className="h-7"
1710
+ value={el.audioRemoteUrl ?? ""}
1711
+ onChange={(e) => updateElement(el.id, { audioRemoteUrl: e.target.value || undefined })}
1712
+ />
1713
+ </label>
1714
+ <label className="block space-y-1">
1715
+ <div>动效</div>
1716
+ <Select
1717
+ value={el.audioVisualEffect ?? "pulse"}
1718
+ onValueChange={(value) =>
1719
+ updateElement(el.id, { audioVisualEffect: value as "none" | "pulse" | "ripple" })
1720
+ }
1721
+ >
1722
+ <SelectTrigger className="h-7"><SelectValue /></SelectTrigger>
1723
+ <SelectContent>
1724
+ <SelectItem value="none">none</SelectItem>
1725
+ <SelectItem value="pulse">pulse</SelectItem>
1726
+ <SelectItem value="ripple">ripple</SelectItem>
1727
+ </SelectContent>
1728
+ </Select>
1729
+ </label>
1730
+ <label className="block space-y-1">
1731
+ <div>速度</div>
1732
+ <Select
1733
+ value={el.audioVisualSpeed ?? "normal"}
1734
+ onValueChange={(value) =>
1735
+ updateElement(el.id, { audioVisualSpeed: value as "slow" | "normal" | "fast" })
1736
+ }
1737
+ >
1738
+ <SelectTrigger className="h-7"><SelectValue /></SelectTrigger>
1739
+ <SelectContent>
1740
+ <SelectItem value="slow">slow</SelectItem>
1741
+ <SelectItem value="normal">normal</SelectItem>
1742
+ <SelectItem value="fast">fast</SelectItem>
1743
+ </SelectContent>
1744
+ </Select>
1745
+ </label>
1746
+ </div>
1747
+ ) : null}
1748
+ {el.materialType === "video" ? (
1749
+ <label className="block space-y-1">
1750
+ <div>视频 URL</div>
1751
+ <Input
1752
+ className="h-7"
1753
+ value={el.videoRemoteUrl ?? ""}
1754
+ onChange={(e) => updateElement(el.id, { videoRemoteUrl: e.target.value || undefined })}
1755
+ />
1756
+ </label>
1757
+ ) : null}
1758
+ {el.materialType === "grid" ? (
1759
+ <div className="grid grid-cols-2 gap-2 rounded-lg border border-border/60 bg-muted/20 p-3">
1760
+ <label className="block space-y-1.5">
1761
+ <div>行</div>
1762
+ <Input className="h-7" type="number" min={1} value={el.gridRows ?? 2} onChange={(e) => updateElement(el.id, { gridRows: Math.max(1, Number(e.target.value) || 2) })} />
1763
+ </label>
1764
+ <label className="block space-y-1.5">
1765
+ <div>列</div>
1766
+ <Input className="h-7" type="number" min={1} value={el.gridCols ?? 3} onChange={(e) => updateElement(el.id, { gridCols: Math.max(1, Number(e.target.value) || 3) })} />
1767
+ </label>
1768
+ </div>
1769
+ ) : null}
1770
+ {el.materialType === "geometry" ? (
1771
+ <div className="grid grid-cols-2 gap-2 rounded-lg border border-border/60 bg-muted/20 p-3">
1772
+ <label className="block space-y-1.5">
1773
+ <div>形状</div>
1774
+ <Select
1775
+ value={el.geometryShape ?? "rect"}
1776
+ onValueChange={(value) =>
1777
+ updateElement(el.id, { geometryShape: value as PanelElement["geometryShape"] })
1778
+ }
1779
+ >
1780
+ <SelectTrigger className="h-7"><SelectValue /></SelectTrigger>
1781
+ <SelectContent>
1782
+ <SelectItem value="rect">矩形</SelectItem>
1783
+ <SelectItem value="circle">圆形</SelectItem>
1784
+ <SelectItem value="triangle">三角形</SelectItem>
1785
+ <SelectItem value="diamond">菱形</SelectItem>
1786
+ <SelectItem value="hexagon">六边形</SelectItem>
1787
+ <SelectItem value="star">星形</SelectItem>
1788
+ <SelectItem value="heart">爱心</SelectItem>
1789
+ </SelectContent>
1790
+ </Select>
1791
+ </label>
1792
+ <label className="block space-y-1.5">
1793
+ <div>颜色</div>
1794
+ <Input
1795
+ className="h-7"
1796
+ value={el.geometryColor ?? "#3b82f6"}
1797
+ onChange={(e) => updateElement(el.id, { geometryColor: e.target.value || "#3b82f6" })}
1798
+ />
1799
+ </label>
1800
+ </div>
1801
+ ) : null}
1802
+ {el.materialType === "reference" ? (
1803
+ <div className="grid grid-cols-2 gap-2">
1804
+ <label className="block space-y-1.5">
1805
+ <div>引用图层</div>
1806
+ <Select
1807
+ value={el.refLayerId ?? "__none__"}
1808
+ onValueChange={(value) => updateElement(el.id, { refLayerId: value === "__none__" ? undefined : value })}
1809
+ >
1810
+ <SelectTrigger className="h-7"><SelectValue /></SelectTrigger>
1811
+ <SelectContent>
1812
+ <SelectItem value="__none__">无</SelectItem>
1813
+ {layers.filter((l) => l.id !== el.layerId).map((l) => (
1814
+ <SelectItem key={l.id} value={l.id}>{l.name}</SelectItem>
1815
+ ))}
1816
+ </SelectContent>
1817
+ </Select>
1818
+ </label>
1819
+ <label className="block space-y-1.5">
1820
+ <div>拷贝</div>
1821
+ <Select
1822
+ value={el.refCopyMode ?? "shallow"}
1823
+ onValueChange={(value) => setReferenceCopyMode?.(el.id, value as ReferenceCopyMode)}
1824
+ >
1825
+ <SelectTrigger className="h-7"><SelectValue /></SelectTrigger>
1826
+ <SelectContent>
1827
+ <SelectItem value="shallow">shallow</SelectItem>
1828
+ <SelectItem value="deep">deep</SelectItem>
1829
+ </SelectContent>
1830
+ </Select>
1831
+ </label>
1832
+ </div>
1833
+ ) : null}
1834
+ </div>
1835
+ </fieldset>
1836
+ </CardContent>
1837
+ ) : null}
1838
+ </Card>
1839
+ ))}
1840
+ {hasSearch &&
1841
+ effectiveSelectedElements.every((el) => {
1842
+ const text = `${el.name ?? ""} ${el.id} ${el.materialType ?? ""} zIndex style layer`
1843
+ .toLowerCase();
1844
+ return !text.includes(normalizedSearch);
1845
+ }) ? (
1846
+ <div className="rounded border border-border/60 bg-background px-2 py-1.5 text-[11px] text-muted-foreground">
1847
+ 未匹配到可编辑节点,请更换关键词。
1848
+ </div>
1849
+ ) : null}
1850
+ </div>
1851
+ ) : null}
1852
+ {!isMultiSelectMode && !selectedElement ? (
1853
+ <Empty className="py-7">
1854
+ <EmptyIcon>
1855
+ <svg
1856
+ viewBox="0 0 24 24"
1857
+ className="h-4 w-4"
1858
+ fill="none"
1859
+ stroke="currentColor"
1860
+ strokeWidth="1.8"
1861
+ aria-hidden="true"
1862
+ >
1863
+ <rect x="4" y="4" width="16" height="16" rx="2.5" />
1864
+ <path d="M8 9h8M8 12h8M8 15h5" />
1865
+ </svg>
1866
+ </EmptyIcon>
1867
+ <EmptyTitle>暂无可配置节点</EmptyTitle>
1868
+ <EmptyDescription>请先在画布中选中一个节点,再到这里进行配置。</EmptyDescription>
1869
+ </Empty>
1870
+ ) : !isMultiSelectMode && selectedElement ? (
1871
+ <div className="space-y-3">
1872
+ {!isNodeEditable ? (
1873
+ <div className="rounded border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-[11px] text-amber-700 dark:text-amber-300">
1874
+ {readonlyReason}
1875
+ </div>
1876
+ ) : null}
1877
+ <div className="rounded-lg border border-border/60 bg-card/80 px-2.5 py-2 text-xs">
1878
+ <label className="flex items-center gap-2">
1879
+ <Checkbox
1880
+ checked={selectedElement.locked === true}
1881
+ disabled={!canToggleNodeLock}
1882
+ className="h-4 w-4 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
1883
+ onCheckedChange={(checked) =>
1884
+ updateElement(selectedElement.id, {
1885
+ locked: checked === true,
1886
+ })
1887
+ }
1888
+ />
1889
+ <span>锁定节点(禁止层级/位置/大小/旋转)</span>
1890
+ </label>
1891
+ </div>
1892
+ <fieldset disabled={!isNodeEditable} className={!isNodeEditable ? "opacity-60" : ""}>
1893
+ <div className="space-y-3.5 text-xs">
1894
+ {renderSection(
1895
+ "nodeInfo",
1896
+ "节点信息",
1897
+ <>
1898
+ <label className="block space-y-1">
1899
+ <div>节点名称</div>
1900
+ <Input
1901
+ value={selectedElement.name ?? ""}
1902
+ onChange={(e) =>
1903
+ updateElement(selectedElement.id, {
1904
+ name: e.target.value || undefined,
1905
+ })
1906
+ }
1907
+ placeholder="自定义节点名称(显示在节点树)"
1908
+ className="h-7"
1909
+ />
1910
+ </label>
1911
+ <div className="grid grid-cols-3 gap-2">
1912
+ <label className="block space-y-1">
1913
+ <div>X</div>
1914
+ <Input
1915
+ type="number"
1916
+ value={selectedElement.x}
1917
+ onChange={(e) =>
1918
+ updateElement(selectedElement.id, {
1919
+ x: Number(e.target.value) || 0,
1920
+ })
1921
+ }
1922
+ className="h-7"
1923
+ />
1924
+ </label>
1925
+ <label className="block space-y-1">
1926
+ <div>Y</div>
1927
+ <Input
1928
+ type="number"
1929
+ value={selectedElement.y}
1930
+ onChange={(e) =>
1931
+ updateElement(selectedElement.id, {
1932
+ y: Number(e.target.value) || 0,
1933
+ })
1934
+ }
1935
+ className="h-7"
1936
+ />
1937
+ </label>
1938
+ </div>
1939
+ <div className="grid grid-cols-3 gap-2">
1940
+ <label className="block space-y-1">
1941
+ <div>旋转角度</div>
1942
+ <Input
1943
+ type="number"
1944
+ value={selectedElement.rotate ?? 0}
1945
+ onChange={(e) =>
1946
+ updateElement(selectedElement.id, {
1947
+ rotate: Number(e.target.value) || 0,
1948
+ })
1949
+ }
1950
+ className="h-7"
1951
+ />
1952
+ </label>
1953
+ <label className="block space-y-1">
1954
+ <div>宽</div>
1955
+ <Input
1956
+ type="number"
1957
+ min={1}
1958
+ value={selectedElement?.width ?? 1}
1959
+ onChange={(e) => {
1960
+ if (!selectedElement) return;
1961
+ updateElement(selectedElement.id, {
1962
+ width: Math.max(1, Number(e.target.value) || 1),
1963
+ });
1964
+ }}
1965
+ className="h-7"
1966
+ />
1967
+ </label>
1968
+ <label className="block space-y-1">
1969
+ <div>高</div>
1970
+ <Input
1971
+ type="number"
1972
+ min={1}
1973
+ value={selectedElement.height}
1974
+ onChange={(e) =>
1975
+ updateElement(selectedElement.id, {
1976
+ height: Math.max(1, Number(e.target.value) || 1),
1977
+ })
1978
+ }
1979
+ className="h-7"
1980
+ />
1981
+ </label>
1982
+ </div>
1983
+ <div className="space-y-1.5">
1984
+ <div className="text-[11px] text-muted-foreground">节点层级</div>
1985
+ <div className="text-[11px] text-muted-foreground/90">
1986
+ 当前 zIndex:{nodeZOrderLabel ?? "-"}
1987
+ </div>
1988
+ <div className="grid grid-cols-2 gap-2">
1989
+ <button
1990
+ type="button"
1991
+ className="rounded border border-border bg-background px-2 py-1 text-[11px] hover:bg-accent"
1992
+ onClick={() => onAdjustNodeZOrder?.(selectedElement.id, "bringForward")}
1993
+ >
1994
+ 上移一层
1995
+ </button>
1996
+ <button
1997
+ type="button"
1998
+ className="rounded border border-border bg-background px-2 py-1 text-[11px] hover:bg-accent"
1999
+ onClick={() => onAdjustNodeZOrder?.(selectedElement.id, "sendBackward")}
2000
+ >
2001
+ 下移一层
2002
+ </button>
2003
+ <button
2004
+ type="button"
2005
+ className="rounded border border-border bg-background px-2 py-1 text-[11px] hover:bg-accent"
2006
+ onClick={() => onAdjustNodeZOrder?.(selectedElement.id, "bringToFront")}
2007
+ >
2008
+ 置顶
2009
+ </button>
2010
+ <button
2011
+ type="button"
2012
+ className="rounded border border-border bg-background px-2 py-1 text-[11px] hover:bg-accent"
2013
+ onClick={() => onAdjustNodeZOrder?.(selectedElement.id, "sendToBack")}
2014
+ >
2015
+ 置底
2016
+ </button>
2017
+ </div>
2018
+ </div>
2019
+ <div className="truncate text-muted-foreground">ID: {selectedElement.id}</div>
2020
+ <div className="text-muted-foreground">类型: {selectedElement.materialType ?? selectedElement.id}</div>
2021
+ </>,
2022
+ true,
2023
+ ["名称", "id", "类型", "锁定", "locked", "name"]
2024
+ )}
2025
+
2026
+ {renderSection(
2027
+ "styleBackground",
2028
+ "通用样式 / 背景",
2029
+ <>
2030
+ {renderFieldGroup(
2031
+ "背景填充",
2032
+ <>
2033
+ {renderColorField(
2034
+ "背景色",
2035
+ selectedElement.style?.backgroundColor ?? "",
2036
+ (next) => updateSelectedStyle({ backgroundColor: next || undefined })
2037
+ )}
2038
+ <label className="block space-y-1">
2039
+ <div>背景图</div>
2040
+ <Input
2041
+ value={selectedElement.style?.backgroundImage ?? ""}
2042
+ onChange={(e) => updateSelectedStyle({ backgroundImage: e.target.value || undefined })}
2043
+ placeholder='url("https://...") / linear-gradient(...)'
2044
+ className="h-7"
2045
+ />
2046
+ </label>
2047
+ <div className="flex items-center gap-2">
2048
+ <label className="inline-flex cursor-pointer items-center rounded border border-border px-2 py-1 text-[11px] hover:bg-accent">
2049
+ 上传图片
2050
+ <Input
2051
+ type="file"
2052
+ accept="image/*"
2053
+ className="hidden"
2054
+ onChange={async (e) => {
2055
+ const file = e.target.files?.[0];
2056
+ e.currentTarget.value = "";
2057
+ if (!file) return;
2058
+ await handleUploadBackgroundImage(file);
2059
+ }}
2060
+ />
2061
+ </label>
2062
+ {uploadStatus ? (
2063
+ <span className="text-[11px] text-muted-foreground">{uploadStatus}</span>
2064
+ ) : null}
2065
+ </div>
2066
+ </>
2067
+ )}
2068
+ {renderFieldGroup(
2069
+ "背景布局",
2070
+ <div className="grid grid-cols-2 gap-2">
2071
+ <label className="block space-y-1">
2072
+ <div>背景尺寸</div>
2073
+ <Select
2074
+ value={selectedElement.style?.backgroundSize ?? "__none__"}
2075
+ onValueChange={(value) =>
2076
+ updateSelectedStyle({
2077
+ backgroundSize: value === "__none__" ? undefined : value,
2078
+ })
2079
+ }
2080
+ >
2081
+ <SelectTrigger className="h-7">
2082
+ <SelectValue placeholder="选择背景尺寸" />
2083
+ </SelectTrigger>
2084
+ <SelectContent>
2085
+ <SelectItem value="__none__">默认</SelectItem>
2086
+ <SelectItem value="cover">cover</SelectItem>
2087
+ <SelectItem value="contain">contain</SelectItem>
2088
+ <SelectItem value="100% 100%">100% 100%</SelectItem>
2089
+ <SelectItem value="auto">auto</SelectItem>
2090
+ </SelectContent>
2091
+ </Select>
2092
+ </label>
2093
+ <label className="block space-y-1">
2094
+ <div>背景位置</div>
2095
+ <Select
2096
+ value={selectedElement.style?.backgroundPosition ?? "__none__"}
2097
+ onValueChange={(value) =>
2098
+ updateSelectedStyle({
2099
+ backgroundPosition: value === "__none__" ? undefined : value,
2100
+ })
2101
+ }
2102
+ >
2103
+ <SelectTrigger className="h-7">
2104
+ <SelectValue placeholder="选择背景位置" />
2105
+ </SelectTrigger>
2106
+ <SelectContent>
2107
+ <SelectItem value="__none__">默认</SelectItem>
2108
+ <SelectItem value="center">center</SelectItem>
2109
+ <SelectItem value="top">top</SelectItem>
2110
+ <SelectItem value="bottom">bottom</SelectItem>
2111
+ <SelectItem value="left">left</SelectItem>
2112
+ <SelectItem value="right">right</SelectItem>
2113
+ <SelectItem value="top left">top left</SelectItem>
2114
+ <SelectItem value="top right">top right</SelectItem>
2115
+ <SelectItem value="bottom left">bottom left</SelectItem>
2116
+ <SelectItem value="bottom right">bottom right</SelectItem>
2117
+ </SelectContent>
2118
+ </Select>
2119
+ </label>
2120
+ </div>
2121
+ )}
2122
+ </>,
2123
+ true,
2124
+ ["背景色", "背景图", "background", "backgroundSize", "backgroundPosition", "布局"]
2125
+ )}
2126
+
2127
+ {renderSection(
2128
+ "styleBorder",
2129
+ "通用样式 / 边框",
2130
+ <>
2131
+ {renderFieldGroup(
2132
+ "边框几何",
2133
+ <div className="grid grid-cols-2 gap-2">
2134
+ <label className="block space-y-1">
2135
+ <div>边框宽度(px)</div>
2136
+ <Input
2137
+ type="number"
2138
+ min={0}
2139
+ value={selectedElement.style?.borderWidth ?? 0}
2140
+ onChange={(e) =>
2141
+ updateSelectedStyle({ borderWidth: Math.max(0, Number(e.target.value) || 0) })
2142
+ }
2143
+ className="h-7"
2144
+ />
2145
+ </label>
2146
+ <label className="block space-y-1">
2147
+ <div>边框圆角(px)</div>
2148
+ <Input
2149
+ type="number"
2150
+ min={0}
2151
+ value={selectedElement.style?.borderRadius ?? 0}
2152
+ onChange={(e) =>
2153
+ updateSelectedStyle({ borderRadius: Math.max(0, Number(e.target.value) || 0) })
2154
+ }
2155
+ className="h-7"
2156
+ />
2157
+ </label>
2158
+ </div>
2159
+ )}
2160
+ {renderFieldGroup(
2161
+ "边框视觉",
2162
+ <div className="grid grid-cols-2 gap-2">
2163
+ <label className="block space-y-1">
2164
+ <div>边框样式</div>
2165
+ <Select
2166
+ value={selectedElement.style?.borderStyle ?? "solid"}
2167
+ onValueChange={(value) =>
2168
+ updateSelectedStyle({
2169
+ borderStyle: value as NonNullable<PanelElementStyle["borderStyle"]>,
2170
+ })
2171
+ }
2172
+ >
2173
+ <SelectTrigger className="h-7">
2174
+ <SelectValue placeholder="请选择边框样式" />
2175
+ </SelectTrigger>
2176
+ <SelectContent>
2177
+ <SelectItem value="none">none</SelectItem>
2178
+ <SelectItem value="solid">solid</SelectItem>
2179
+ <SelectItem value="dashed">dashed</SelectItem>
2180
+ <SelectItem value="dotted">dotted</SelectItem>
2181
+ <SelectItem value="double">double</SelectItem>
2182
+ </SelectContent>
2183
+ </Select>
2184
+ </label>
2185
+ {renderColorField(
2186
+ "边框颜色",
2187
+ selectedElement.style?.borderColor ?? "",
2188
+ (next) => updateSelectedStyle({ borderColor: next || undefined })
2189
+ )}
2190
+ </div>
2191
+ )}
2192
+ </>,
2193
+ true,
2194
+ ["边框", "border", "宽度", "圆角", "颜色", "样式"]
2195
+ )}
2196
+
2197
+ {isChartElement ? (
2198
+ <>
2199
+ {renderSection(
2200
+ "chartBasic",
2201
+ "图表配置 / 基础",
2202
+ <>
2203
+ {renderFieldGroup(
2204
+ "基础显示",
2205
+ <>
2206
+ <label className="block space-y-1.5">
2207
+ <div>标题</div>
2208
+ <Input
2209
+ value={selectedElement.chart?.title ?? ""}
2210
+ onChange={(e) => updateSelectedChart({ title: e.target.value })}
2211
+ className="h-7"
2212
+ />
2213
+ </label>
2214
+
2215
+ {renderColorField(
2216
+ "主色",
2217
+ selectedElement.chart?.color ?? "#3b82f6",
2218
+ (next) => updateSelectedChart({ color: next || "#3b82f6" })
2219
+ )}
2220
+ <label className="flex items-center gap-2">
2221
+ <Checkbox
2222
+ checked={selectedElement.chart?.colorMode === "gradient"}
2223
+ onCheckedChange={(checked) =>
2224
+ updateSelectedChart({ colorMode: checked ? "gradient" : "solid" })
2225
+ }
2226
+ />
2227
+ <span>主色使用渐变</span>
2228
+ </label>
2229
+ {selectedElement.chart?.colorMode === "gradient" ? (
2230
+ <div className="grid grid-cols-2 gap-2">
2231
+ {renderColorField(
2232
+ "渐变起始色",
2233
+ selectedElement.chart?.gradientFrom ?? selectedElement.chart?.color ?? "#3b82f6",
2234
+ (next) => updateSelectedChart({ gradientFrom: next || "#3b82f6" })
2235
+ )}
2236
+ {renderColorField(
2237
+ "渐变结束色",
2238
+ selectedElement.chart?.gradientTo ?? "#22d3ee",
2239
+ (next) => updateSelectedChart({ gradientTo: next || "#22d3ee" })
2240
+ )}
2241
+ <label className="block space-y-1 col-span-2">
2242
+ <div>渐变方向</div>
2243
+ <Select
2244
+ value={selectedElement.chart?.gradientDirection ?? "to-right"}
2245
+ onValueChange={(value) =>
2246
+ updateSelectedChart({
2247
+ gradientDirection: value as
2248
+ | "to-right"
2249
+ | "to-bottom"
2250
+ | "to-bottom-right"
2251
+ | "to-top-right",
2252
+ })
2253
+ }
2254
+ >
2255
+ <SelectTrigger className="h-7">
2256
+ <SelectValue placeholder="请选择渐变方向" />
2257
+ </SelectTrigger>
2258
+ <SelectContent>
2259
+ <SelectItem value="to-right">左 → 右</SelectItem>
2260
+ <SelectItem value="to-bottom">上 → 下</SelectItem>
2261
+ <SelectItem value="to-bottom-right">左上 → 右下</SelectItem>
2262
+ <SelectItem value="to-top-right">左下 → 右上</SelectItem>
2263
+ </SelectContent>
2264
+ </Select>
2265
+ </label>
2266
+ <div className="col-span-2 space-y-1">
2267
+ <div className="text-[11px] text-muted-foreground">渐变预览</div>
2268
+ <div
2269
+ className="h-6 rounded border border-border/60"
2270
+ style={{
2271
+ backgroundImage: `linear-gradient(${
2272
+ (selectedElement.chart?.gradientDirection ?? "to-right") === "to-bottom"
2273
+ ? "to bottom"
2274
+ : (selectedElement.chart?.gradientDirection ?? "to-right") === "to-bottom-right"
2275
+ ? "to bottom right"
2276
+ : (selectedElement.chart?.gradientDirection ?? "to-right") === "to-top-right"
2277
+ ? "to top right"
2278
+ : "to right"
2279
+ }, ${selectedElement.chart?.gradientFrom ?? selectedElement.chart?.color ?? "#3b82f6"}, ${selectedElement.chart?.gradientTo ?? "#22d3ee"})`,
2280
+ }}
2281
+ />
2282
+ </div>
2283
+ </div>
2284
+ ) : null}
2285
+ <label className="block space-y-1.5">
2286
+ <div>显示模式</div>
2287
+ <Select
2288
+ value={selectedElement.chart?.renderMode ?? "canvas"}
2289
+ onValueChange={(value) =>
2290
+ updateSelectedChart({ renderMode: value as "canvas" | "svg" })
2291
+ }
2292
+ >
2293
+ <SelectTrigger className="h-7">
2294
+ <SelectValue placeholder="请选择显示模式" />
2295
+ </SelectTrigger>
2296
+ <SelectContent>
2297
+ <SelectItem value="canvas">Canvas</SelectItem>
2298
+ <SelectItem value="svg">SVG</SelectItem>
2299
+ </SelectContent>
2300
+ </Select>
2301
+ </label>
2302
+ </>
2303
+ )}
2304
+ {renderFieldGroup(
2305
+ "提示框 Tooltip",
2306
+ <>
2307
+ <div className="grid grid-cols-2 gap-2">
2308
+ <label className="flex items-center gap-2">
2309
+ <Checkbox
2310
+ checked={selectedElement.chart?.tooltipShow ?? true}
2311
+ className="h-4 w-4 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
2312
+ onCheckedChange={(checked) =>
2313
+ updateSelectedChart({ tooltipShow: checked === true })
2314
+ }
2315
+ />
2316
+ <span>显示 Tooltip</span>
2317
+ </label>
2318
+ <label className="block space-y-1">
2319
+ <div>Tooltip 触发方式</div>
2320
+ <Select
2321
+ value={selectedElement.chart?.tooltipTrigger ?? "axis"}
2322
+ onValueChange={(value) =>
2323
+ updateSelectedChart({ tooltipTrigger: value as "axis" | "item" })
2324
+ }
2325
+ >
2326
+ <SelectTrigger className="h-7">
2327
+ <SelectValue placeholder="请选择触发方式" />
2328
+ </SelectTrigger>
2329
+ <SelectContent>
2330
+ <SelectItem value="axis">axis</SelectItem>
2331
+ <SelectItem value="item">item</SelectItem>
2332
+ </SelectContent>
2333
+ </Select>
2334
+ </label>
2335
+ </div>
2336
+ <div className="grid grid-cols-2 gap-2">
2337
+ {renderColorField(
2338
+ "Tooltip 背景色",
2339
+ selectedElement.chart?.tooltipBackgroundColor ?? "#0f172a",
2340
+ (next) => updateSelectedChart({ tooltipBackgroundColor: next || "#0f172a" })
2341
+ )}
2342
+ {renderColorField(
2343
+ "Tooltip 文字色",
2344
+ selectedElement.chart?.tooltipTextColor ?? "#f8fafc",
2345
+ (next) => updateSelectedChart({ tooltipTextColor: next || "#f8fafc" })
2346
+ )}
2347
+ </div>
2348
+ <label className="block space-y-1">
2349
+ {renderFormatterLabel("Tooltip Formatter(可选)")}
2350
+ <Input
2351
+ value={selectedElement.chart?.tooltipFormatter ?? ""}
2352
+ onChange={(e) =>
2353
+ updateSelectedChart({ tooltipFormatter: e.target.value || undefined })
2354
+ }
2355
+ placeholder="例如:{b}: {c}"
2356
+ className="h-7"
2357
+ />
2358
+ </label>
2359
+ </>
2360
+ )}
2361
+ {renderFieldGroup(
2362
+ "数据",
2363
+ <>
2364
+ <label className="block space-y-1">
2365
+ <div>类目(逗号分隔)</div>
2366
+ <Input
2367
+ value={(selectedElement.chart?.labels ?? []).join(",")}
2368
+ onChange={(e) =>
2369
+ updateSelectedChart({
2370
+ labels: e.target.value
2371
+ .split(",")
2372
+ .map((s) => s.trim())
2373
+ .filter(Boolean),
2374
+ })
2375
+ }
2376
+ className="h-7"
2377
+ />
2378
+ </label>
2379
+
2380
+ <label className="block space-y-1">
2381
+ <div>数值(逗号分隔)</div>
2382
+ <Input
2383
+ value={getChartValuesDisplayText(selectedElement.chart)}
2384
+ onChange={(e) =>
2385
+ updateSelectedChart({
2386
+ valuesText: e.target.value,
2387
+ })
2388
+ }
2389
+ className="h-7"
2390
+ />
2391
+ </label>
2392
+ </>
2393
+ )}
2394
+
2395
+ {selectedChartType === "bar" ||
2396
+ selectedChartType === "line" ||
2397
+ selectedChartType === "area" ||
2398
+ selectedChartType === "scatter" ? (
2399
+ renderFieldGroup(
2400
+ "坐标轴",
2401
+ <>
2402
+ <div className="grid grid-cols-2 gap-2">
2403
+ <label className="block">
2404
+ <div className="mb-1">X 轴名称</div>
2405
+ <Input
2406
+ value={selectedElement.chart?.xAxisName ?? ""}
2407
+ onChange={(e) => updateSelectedChart({ xAxisName: e.target.value })}
2408
+ placeholder="例如:日期 / 类目"
2409
+ className="h-7"
2410
+ />
2411
+ </label>
2412
+ <label className="block">
2413
+ <div className="mb-1">Y 轴名称</div>
2414
+ <Input
2415
+ value={selectedElement.chart?.yAxisName ?? ""}
2416
+ onChange={(e) => updateSelectedChart({ yAxisName: e.target.value })}
2417
+ placeholder="例如:销量 / 数值"
2418
+ className="h-7"
2419
+ />
2420
+ </label>
2421
+ </div>
2422
+ <div className="grid grid-cols-2 gap-2">
2423
+ <label className="flex items-center gap-2">
2424
+ <Checkbox
2425
+ checked={selectedElement.chart?.xAxisTickShow ?? true}
2426
+ className="h-4 w-4 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
2427
+ onCheckedChange={(checked) =>
2428
+ updateSelectedChart({ xAxisTickShow: checked === true })
2429
+ }
2430
+ />
2431
+ <span>X 轴刻度线</span>
2432
+ </label>
2433
+ <label className="flex items-center gap-2">
2434
+ <Checkbox
2435
+ checked={selectedElement.chart?.yAxisTickShow ?? true}
2436
+ className="h-4 w-4 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
2437
+ onCheckedChange={(checked) =>
2438
+ updateSelectedChart({ yAxisTickShow: checked === true })
2439
+ }
2440
+ />
2441
+ <span>Y 轴刻度线</span>
2442
+ </label>
2443
+ </div>
2444
+ <div className="grid grid-cols-2 gap-2">
2445
+ {renderColorField(
2446
+ "X 轴刻度线颜色",
2447
+ selectedElement.chart?.xAxisTickColor ?? "#94a3b8",
2448
+ (next) => updateSelectedChart({ xAxisTickColor: next || "#94a3b8" })
2449
+ )}
2450
+ {renderColorField(
2451
+ "Y 轴刻度线颜色",
2452
+ selectedElement.chart?.yAxisTickColor ?? "#94a3b8",
2453
+ (next) => updateSelectedChart({ yAxisTickColor: next || "#94a3b8" })
2454
+ )}
2455
+ </div>
2456
+ <div className="grid grid-cols-2 gap-2">
2457
+ <label className="flex items-center gap-2">
2458
+ <Checkbox
2459
+ checked={selectedElement.chart?.xAxisSplitLineShow ?? false}
2460
+ className="h-4 w-4 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
2461
+ onCheckedChange={(checked) =>
2462
+ updateSelectedChart({ xAxisSplitLineShow: checked === true })
2463
+ }
2464
+ />
2465
+ <span>X 轴分割线</span>
2466
+ </label>
2467
+ <label className="flex items-center gap-2">
2468
+ <Checkbox
2469
+ checked={selectedElement.chart?.yAxisSplitLineShow ?? true}
2470
+ className="h-4 w-4 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
2471
+ onCheckedChange={(checked) =>
2472
+ updateSelectedChart({ yAxisSplitLineShow: checked === true })
2473
+ }
2474
+ />
2475
+ <span>Y 轴分割线</span>
2476
+ </label>
2477
+ </div>
2478
+ <div className="grid grid-cols-2 gap-2">
2479
+ {renderColorField(
2480
+ "X 轴分割线颜色",
2481
+ selectedElement.chart?.xAxisSplitLineColor ?? "#e2e8f0",
2482
+ (next) => updateSelectedChart({ xAxisSplitLineColor: next || "#e2e8f0" })
2483
+ )}
2484
+ {renderColorField(
2485
+ "Y 轴分割线颜色",
2486
+ selectedElement.chart?.yAxisSplitLineColor ?? "#e2e8f0",
2487
+ (next) => updateSelectedChart({ yAxisSplitLineColor: next || "#e2e8f0" })
2488
+ )}
2489
+ </div>
2490
+ <div className="grid grid-cols-2 gap-2">
2491
+ {renderColorField(
2492
+ "X 轴标签颜色",
2493
+ selectedElement.chart?.xAxisLabelColor ?? "#64748b",
2494
+ (next) => updateSelectedChart({ xAxisLabelColor: next || "#64748b" })
2495
+ )}
2496
+ {renderColorField(
2497
+ "Y 轴标签颜色",
2498
+ selectedElement.chart?.yAxisLabelColor ?? "#64748b",
2499
+ (next) => updateSelectedChart({ yAxisLabelColor: next || "#64748b" })
2500
+ )}
2501
+ </div>
2502
+ <div className="grid grid-cols-2 gap-2">
2503
+ <label className="block">
2504
+ <div className="mb-1">X 轴标签字号</div>
2505
+ <Input
2506
+ type="number"
2507
+ min={8}
2508
+ max={48}
2509
+ value={selectedElement.chart?.xAxisLabelFontSize ?? 10}
2510
+ onChange={(e) =>
2511
+ updateSelectedChart({
2512
+ xAxisLabelFontSize: Math.max(8, Math.min(48, Number(e.target.value) || 10)),
2513
+ })
2514
+ }
2515
+ className="h-7"
2516
+ />
2517
+ </label>
2518
+ <label className="block">
2519
+ <div className="mb-1">Y 轴标签字号</div>
2520
+ <Input
2521
+ type="number"
2522
+ min={8}
2523
+ max={48}
2524
+ value={selectedElement.chart?.yAxisLabelFontSize ?? 10}
2525
+ onChange={(e) =>
2526
+ updateSelectedChart({
2527
+ yAxisLabelFontSize: Math.max(8, Math.min(48, Number(e.target.value) || 10)),
2528
+ })
2529
+ }
2530
+ className="h-7"
2531
+ />
2532
+ </label>
2533
+ </div>
2534
+ <div className="grid grid-cols-2 gap-2">
2535
+ <label className="flex items-center gap-2">
2536
+ <Checkbox
2537
+ checked={selectedElement.chart?.xAxisLabelAutoEllipsis ?? false}
2538
+ className="h-4 w-4 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
2539
+ onCheckedChange={(checked) =>
2540
+ updateSelectedChart({ xAxisLabelAutoEllipsis: checked === true })
2541
+ }
2542
+ />
2543
+ <span>X 轴标签自动缩略</span>
2544
+ </label>
2545
+ <label className="flex items-center gap-2">
2546
+ <Checkbox
2547
+ checked={selectedElement.chart?.yAxisLabelAutoEllipsis ?? false}
2548
+ className="h-4 w-4 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
2549
+ onCheckedChange={(checked) =>
2550
+ updateSelectedChart({ yAxisLabelAutoEllipsis: checked === true })
2551
+ }
2552
+ />
2553
+ <span>Y 轴标签自动缩略</span>
2554
+ </label>
2555
+ </div>
2556
+ </>
2557
+ )
2558
+ ) : null}
2559
+
2560
+ {selectedChartType === "bar" ||
2561
+ selectedChartType === "line" ||
2562
+ selectedChartType === "area" ||
2563
+ selectedChartType === "pie" ? (
2564
+ renderFieldGroup(
2565
+ "系列",
2566
+ <>
2567
+ {selectedChartType === "bar" ? (
2568
+ <label className="block space-y-1">
2569
+ <div>柱宽(px)</div>
2570
+ <Input
2571
+ type="number"
2572
+ min={1}
2573
+ value={selectedElement.chart?.barWidth ?? 24}
2574
+ onChange={(e) =>
2575
+ updateSelectedChart({ barWidth: Math.max(1, Number(e.target.value) || 1) })
2576
+ }
2577
+ className="h-7"
2578
+ />
2579
+ </label>
2580
+ ) : null}
2581
+
2582
+ {selectedChartType === "line" || selectedChartType === "area" ? (
2583
+ <label className="flex items-center gap-2">
2584
+ <Checkbox
2585
+ checked={selectedElement.chart?.smooth ?? true}
2586
+ className="h-4 w-4 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
2587
+ onCheckedChange={(checked) =>
2588
+ updateSelectedChart({ smooth: checked === true })
2589
+ }
2590
+ />
2591
+ <span>平滑曲线</span>
2592
+ </label>
2593
+ ) : null}
2594
+
2595
+ {selectedChartType === "pie" ? (
2596
+ <div className="grid grid-cols-2 gap-2">
2597
+ <label className="block space-y-1">
2598
+ <div>内半径(%)</div>
2599
+ <Input
2600
+ type="number"
2601
+ min={0}
2602
+ max={99}
2603
+ value={selectedElement.chart?.pieInnerRadius ?? 30}
2604
+ onChange={(e) =>
2605
+ updateSelectedChart({
2606
+ pieInnerRadius: Math.max(
2607
+ 0,
2608
+ Math.min(99, Number(e.target.value) || 0)
2609
+ ),
2610
+ })
2611
+ }
2612
+ className="h-7"
2613
+ />
2614
+ </label>
2615
+ <label className="block space-y-1">
2616
+ <div>外半径(%)</div>
2617
+ <Input
2618
+ type="number"
2619
+ min={1}
2620
+ max={100}
2621
+ value={selectedElement.chart?.pieOuterRadius ?? 65}
2622
+ onChange={(e) =>
2623
+ updateSelectedChart({
2624
+ pieOuterRadius: Math.max(
2625
+ 1,
2626
+ Math.min(100, Number(e.target.value) || 1)
2627
+ ),
2628
+ })
2629
+ }
2630
+ className="h-7"
2631
+ />
2632
+ </label>
2633
+ </div>
2634
+ ) : null}
2635
+ </>
2636
+ )
2637
+ ) : null}
2638
+ </>,
2639
+ true,
2640
+ [
2641
+ "图表",
2642
+ "title",
2643
+ "tooltip",
2644
+ "x轴",
2645
+ "y轴",
2646
+ "axis",
2647
+ "label",
2648
+ "刻度线",
2649
+ "分割线",
2650
+ "render",
2651
+ "svg",
2652
+ "canvas",
2653
+ ]
2654
+ )}
2655
+
2656
+ {renderSection(
2657
+ "chartAdvanced",
2658
+ "图表配置 / 高级",
2659
+ <>
2660
+ {renderFieldGroup(
2661
+ "常用项(表单)",
2662
+ <div className="grid grid-cols-1 gap-2">
2663
+ <div className="rounded-lg border border-border/60 bg-background/70 p-3">
2664
+ <Collapsible
2665
+ open={isSectionExpanded("chartAdvancedLayout", false)}
2666
+ onOpenChange={(open) => setSectionExpanded("chartAdvancedLayout", open)}
2667
+ >
2668
+ <div className="mb-2 flex items-center gap-1.5">
2669
+ <CollapsibleTrigger asChild>
2670
+ <button
2671
+ type="button"
2672
+ className="flex h-5 w-5 items-center justify-center rounded text-[11px] hover:bg-accent"
2673
+ aria-label={isSectionExpanded("chartAdvancedLayout", false) ? "收起布局与坐标" : "展开布局与坐标"}
2674
+ >
2675
+ {isSectionExpanded("chartAdvancedLayout", false) ? "▾" : "▸"}
2676
+ </button>
2677
+ </CollapsibleTrigger>
2678
+ <div className="text-[11px] font-medium text-muted-foreground">布局与坐标</div>
2679
+ </div>
2680
+ <CollapsibleContent>
2681
+ <div className="grid grid-cols-2 gap-2">
2682
+ <label className="block space-y-1.5">
2683
+ <div className={optionLabelTextClass}>网格左距(grid.left)</div>
2684
+ <Input type="number" className={optionInputClass} value={Number((selectedElement.chart?.option as any)?.grid?.left ?? 28)} onChange={(e) => updateSelectedOptionForm({ grid: { left: Number(e.target.value) || 0 } })} />
2685
+ </label>
2686
+ <label className="block space-y-1.5">
2687
+ <div className={optionLabelTextClass}>网格右距(grid.right)</div>
2688
+ <Input type="number" className={optionInputClass} value={Number((selectedElement.chart?.option as any)?.grid?.right ?? 10)} onChange={(e) => updateSelectedOptionForm({ grid: { right: Number(e.target.value) || 0 } })} />
2689
+ </label>
2690
+ <label className="block space-y-1.5">
2691
+ <div className={optionLabelTextClass}>网格上距(grid.top)</div>
2692
+ <Input type="number" className={optionInputClass} value={Number((selectedElement.chart?.option as any)?.grid?.top ?? 30)} onChange={(e) => updateSelectedOptionForm({ grid: { top: Number(e.target.value) || 0 } })} />
2693
+ </label>
2694
+ <label className="block space-y-1.5">
2695
+ <div className={optionLabelTextClass}>网格下距(grid.bottom)</div>
2696
+ <Input type="number" className={optionInputClass} value={Number((selectedElement.chart?.option as any)?.grid?.bottom ?? 20)} onChange={(e) => updateSelectedOptionForm({ grid: { bottom: Number(e.target.value) || 0 } })} />
2697
+ </label>
2698
+ <label className="block space-y-1.5">
2699
+ <div className={optionLabelTextClass}>图例位置(legend.top)</div>
2700
+ <Select value={String((selectedElement.chart?.option as any)?.legend?.top ?? "top")} onValueChange={(value) => updateSelectedOptionForm({ legend: { top: value } })}>
2701
+ <SelectTrigger className={optionSelectTriggerClass}><SelectValue placeholder="选择图例位置" /></SelectTrigger>
2702
+ <SelectContent>
2703
+ <SelectItem value="top">顶部</SelectItem>
2704
+ <SelectItem value="bottom">底部</SelectItem>
2705
+ <SelectItem value="left">左侧</SelectItem>
2706
+ <SelectItem value="right">右侧</SelectItem>
2707
+ </SelectContent>
2708
+ </Select>
2709
+ </label>
2710
+ <label className="block space-y-1.5">
2711
+ <div className={optionLabelTextClass}>图例排列(legend.orient)</div>
2712
+ <Select value={String((selectedElement.chart?.option as any)?.legend?.orient ?? "horizontal")} onValueChange={(value) => updateSelectedOptionForm({ legend: { orient: value } })}>
2713
+ <SelectTrigger className={optionSelectTriggerClass}><SelectValue placeholder="选择图例排列" /></SelectTrigger>
2714
+ <SelectContent>
2715
+ <SelectItem value="horizontal">横向</SelectItem>
2716
+ <SelectItem value="vertical">纵向</SelectItem>
2717
+ </SelectContent>
2718
+ </Select>
2719
+ </label>
2720
+ <label className="block space-y-1.5">
2721
+ {renderOptionLabel("X 轴最小值", "xAxis.min", "设置横轴最小值,留空可在 JSON 里删除。")}
2722
+ <Input type="number" className={optionInputClass} value={Number((selectedElement.chart?.option as any)?.xAxis?.min ?? 0)} onChange={(e) => updateSelectedOptionForm({ xAxis: { min: Number(e.target.value) || 0 } })} />
2723
+ </label>
2724
+ <label className="block space-y-1.5">
2725
+ {renderOptionLabel("X 轴最大值", "xAxis.max", "设置横轴最大值,留空可在 JSON 里删除。")}
2726
+ <Input type="number" className={optionInputClass} value={Number((selectedElement.chart?.option as any)?.xAxis?.max ?? 100)} onChange={(e) => updateSelectedOptionForm({ xAxis: { max: Number(e.target.value) || 0 } })} />
2727
+ </label>
2728
+ <label className="block space-y-1.5">
2729
+ {renderOptionLabel("Y 轴最小值", "yAxis.min", "设置纵轴最小值,留空可在 JSON 里删除。")}
2730
+ <Input type="number" className={optionInputClass} value={Number((selectedElement.chart?.option as any)?.yAxis?.min ?? 0)} onChange={(e) => updateSelectedOptionForm({ yAxis: { min: Number(e.target.value) || 0 } })} />
2731
+ </label>
2732
+ <label className="block space-y-1.5">
2733
+ {renderOptionLabel("Y 轴最大值", "yAxis.max", "设置纵轴最大值,留空可在 JSON 里删除。")}
2734
+ <Input type="number" className={optionInputClass} value={Number((selectedElement.chart?.option as any)?.yAxis?.max ?? 100)} onChange={(e) => updateSelectedOptionForm({ yAxis: { max: Number(e.target.value) || 0 } })} />
2735
+ </label>
2736
+ <label className="block space-y-1.5">
2737
+ {renderOptionLabel("X 轴标签旋转", "xAxis.axisLabel.rotate", "单位为度。")}
2738
+ <Input type="number" className={optionInputClass} value={Number((selectedElement.chart?.option as any)?.xAxis?.axisLabel?.rotate ?? 0)} onChange={(e) => updateSelectedOptionForm({ xAxis: { axisLabel: { rotate: Number(e.target.value) || 0 } } })} />
2739
+ </label>
2740
+ <label className="block space-y-1.5">
2741
+ {renderOptionLabel("Y 轴标签旋转", "yAxis.axisLabel.rotate", "单位为度。")}
2742
+ <Input type="number" className={optionInputClass} value={Number((selectedElement.chart?.option as any)?.yAxis?.axisLabel?.rotate ?? 0)} onChange={(e) => updateSelectedOptionForm({ yAxis: { axisLabel: { rotate: Number(e.target.value) || 0 } } })} />
2743
+ </label>
2744
+ </div>
2745
+ </CollapsibleContent>
2746
+ </Collapsible>
2747
+ </div>
2748
+ <div className="rounded-lg border border-border/60 bg-background/70 p-3">
2749
+ <div className="space-y-2">
2750
+ <Collapsible
2751
+ open={isSectionExpanded("chartAdvancedHighFreq", true)}
2752
+ onOpenChange={(open) => setSectionExpanded("chartAdvancedHighFreq", open)}
2753
+ >
2754
+ <div className="mb-2 flex items-center gap-1.5">
2755
+ <CollapsibleTrigger asChild>
2756
+ <button
2757
+ type="button"
2758
+ className="flex h-5 w-5 items-center justify-center rounded text-[11px] hover:bg-accent"
2759
+ aria-label={isSectionExpanded("chartAdvancedHighFreq", true) ? "收起高频项" : "展开高频项"}
2760
+ >
2761
+ {isSectionExpanded("chartAdvancedHighFreq", true) ? "▾" : "▸"}
2762
+ </button>
2763
+ </CollapsibleTrigger>
2764
+ <div className="text-[11px] font-medium text-muted-foreground">高频项</div>
2765
+ </div>
2766
+ <CollapsibleContent>
2767
+ <div className="grid grid-cols-1 gap-2">
2768
+ <label className="flex items-center gap-2 rounded-md bg-muted/30 px-2 py-1.5">
2769
+ <Checkbox className={optionCheckboxClass} checked={Boolean((selectedElement.chart?.option as any)?.legend?.show ?? true)} onCheckedChange={(checked) => updateSelectedOptionForm({ legend: { show: checked === true } })} />
2770
+ {renderOptionLabel("显示图例", "legend.show", "控制图例显隐。")}
2771
+ </label>
2772
+ <label className="flex items-center gap-2 rounded-md bg-muted/30 px-2 py-1.5">
2773
+ <Checkbox className={optionCheckboxClass} checked={Boolean((selectedElement.chart?.option as any)?.grid?.containLabel ?? false)} onCheckedChange={(checked) => updateSelectedOptionForm({ grid: { containLabel: checked === true } })} />
2774
+ {renderOptionLabel("网格包含标签", "grid.containLabel", "自动为坐标轴标签预留空间。")}
2775
+ </label>
2776
+ <label className="flex items-center gap-2 rounded-md bg-muted/30 px-2 py-1.5">
2777
+ <Checkbox className={optionCheckboxClass} checked={Boolean(Array.isArray((selectedElement.chart?.option as any)?.dataZoom) && (selectedElement.chart?.option as any)?.dataZoom.some((z: any) => z?.type === "inside"))} onCheckedChange={(checked) => { const prev = Array.isArray((selectedElement.chart?.option as any)?.dataZoom) ? [...(selectedElement.chart?.option as any).dataZoom] : []; const next = checked ? [...prev.filter((z: any) => z?.type !== "inside"), { type: "inside" }] : prev.filter((z: any) => z?.type !== "inside"); updateSelectedOptionForm({ dataZoom: next }); }} />
2778
+ {renderOptionLabel("内置缩放", "dataZoom[type=inside]", "启用鼠标滚轮/手势缩放。")}
2779
+ </label>
2780
+ <label className="flex items-center gap-2 rounded-md bg-muted/30 px-2 py-1.5">
2781
+ <Checkbox className={optionCheckboxClass} checked={Boolean(Array.isArray((selectedElement.chart?.option as any)?.dataZoom) && (selectedElement.chart?.option as any)?.dataZoom.some((z: any) => z?.type === "slider"))} onCheckedChange={(checked) => { const prev = Array.isArray((selectedElement.chart?.option as any)?.dataZoom) ? [...(selectedElement.chart?.option as any).dataZoom] : []; const next = checked ? [...prev.filter((z: any) => z?.type !== "slider"), { type: "slider" }] : prev.filter((z: any) => z?.type !== "slider"); updateSelectedOptionForm({ dataZoom: next }); }} />
2782
+ {renderOptionLabel("滑块缩放", "dataZoom[type=slider]", "显示底部拖拽缩放条。")}
2783
+ </label>
2784
+ <label className="flex items-center gap-2 rounded-md bg-muted/30 px-2 py-1.5">
2785
+ <Checkbox className={optionCheckboxClass} checked={Boolean((selectedElement.chart?.option as any)?.animation ?? false)} onCheckedChange={(checked) => updateSelectedOptionForm({ animation: checked === true })} />
2786
+ {renderOptionLabel("开启动画", "animation", "开启后切换数据时有过渡动效。")}
2787
+ </label>
2788
+ <label className="block space-y-1.5">
2789
+ {renderOptionLabel("动画时长", "animationDuration", "动画持续时间,单位毫秒。")}
2790
+ <Input type="number" min={0} className={optionInputClass} value={Number((selectedElement.chart?.option as any)?.animationDuration ?? 300)} onChange={(e) => updateSelectedOptionForm({ animationDuration: Math.max(0, Number(e.target.value) || 0) })} />
2791
+ </label>
2792
+ </div>
2793
+ </CollapsibleContent>
2794
+ </Collapsible>
2795
+ <Collapsible
2796
+ open={isSectionExpanded("chartAdvancedAxisPointer", false)}
2797
+ onOpenChange={(open) => setSectionExpanded("chartAdvancedAxisPointer", open)}
2798
+ >
2799
+ <div className="mb-2 flex items-center gap-1.5">
2800
+ <CollapsibleTrigger asChild>
2801
+ <button
2802
+ type="button"
2803
+ className="flex h-5 w-5 items-center justify-center rounded text-[11px] hover:bg-accent"
2804
+ aria-label={isSectionExpanded("chartAdvancedAxisPointer", false) ? "收起轴指示器项" : "展开轴指示器项"}
2805
+ >
2806
+ {isSectionExpanded("chartAdvancedAxisPointer", false) ? "▾" : "▸"}
2807
+ </button>
2808
+ </CollapsibleTrigger>
2809
+ <div className="text-[11px] font-medium text-muted-foreground">轴指示器与对齐</div>
2810
+ </div>
2811
+ <CollapsibleContent>
2812
+ <div className="grid grid-cols-1 gap-2">
2813
+ <label className="flex items-center gap-2 rounded-md bg-muted/30 px-2 py-1.5">
2814
+ <Checkbox className={optionCheckboxClass} checked={Boolean((selectedElement.chart?.option as any)?.axisPointer?.show ?? false)} onCheckedChange={(checked) => updateSelectedOptionForm({ axisPointer: { show: checked === true } })} />
2815
+ {renderOptionLabel("显示轴指示器", "axisPointer.show", "悬停时显示轴对齐提示。")}
2816
+ </label>
2817
+ <label className="block space-y-1.5 rounded-md bg-muted/30 px-2 py-1.5">
2818
+ {renderOptionLabel("轴指示器类型", "axisPointer.type", "设置指示器样式:线/阴影/十字。")}
2819
+ <Select value={String((selectedElement.chart?.option as any)?.axisPointer?.type ?? "line")} onValueChange={(value) => updateSelectedOptionForm({ axisPointer: { type: value } })}>
2820
+ <SelectTrigger className={optionSelectTriggerClass}><SelectValue placeholder="选择轴指示器类型" /></SelectTrigger>
2821
+ <SelectContent>
2822
+ <SelectItem value="line">线</SelectItem>
2823
+ <SelectItem value="shadow">阴影</SelectItem>
2824
+ <SelectItem value="cross">十字</SelectItem>
2825
+ </SelectContent>
2826
+ </Select>
2827
+ </label>
2828
+ <label className="flex items-center gap-2 rounded-md bg-muted/30 px-2 py-1.5">
2829
+ <Checkbox className={optionCheckboxClass} checked={Boolean((selectedElement.chart?.option as any)?.axisPointer?.snap ?? false)} onCheckedChange={(checked) => updateSelectedOptionForm({ axisPointer: { snap: checked === true } })} />
2830
+ {renderOptionLabel("轴指示器吸附", "axisPointer.snap", "指示器吸附到最近数据点。")}
2831
+ </label>
2832
+ </div>
2833
+ </CollapsibleContent>
2834
+ </Collapsible>
2835
+ </div>
2836
+ </div>
2837
+ </div>
2838
+ )}
2839
+ {renderFieldGroup(
2840
+ "JSON 高级模式",
2841
+ <>
2842
+ <label className="flex items-center gap-2 rounded-md bg-background/70 px-2 py-1.5">
2843
+ <Checkbox
2844
+ checked={isAdvancedOptionMode}
2845
+ className="h-4 w-4 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
2846
+ onCheckedChange={(checked) => setIsAdvancedOptionMode(checked === true)}
2847
+ />
2848
+ <span>开启高级模式(直接编辑图表 JSON 配置)</span>
2849
+ </label>
2850
+ <div className="rounded border border-border/60 bg-background px-2 py-1.5 text-[11px] text-muted-foreground">
2851
+ 基础配置会先生成图表配置,高级模式会在此基础上覆盖(深度合并)。
2852
+ </div>
2853
+ {isAdvancedOptionMode ? (
2854
+ <>
2855
+ <Textarea
2856
+ value={optionJsonText}
2857
+ onChange={(e) => {
2858
+ const next = e.target.value;
2859
+ setOptionJsonText(next);
2860
+ try {
2861
+ const parsed = JSON.parse(next) as Record<string, unknown>;
2862
+ updateSelectedChart({ option: parsed });
2863
+ setOptionJsonError(null);
2864
+ } catch {
2865
+ setOptionJsonError("JSON 格式错误,修正后会自动应用");
2866
+ }
2867
+ }}
2868
+ spellCheck={false}
2869
+ className="h-44 font-mono text-[11px]"
2870
+ />
2871
+ {optionJsonError ? (
2872
+ <div className="rounded border border-destructive/40 bg-destructive/10 px-2 py-1.5 text-[11px] text-destructive">
2873
+ {optionJsonError}
2874
+ </div>
2875
+ ) : (
2876
+ <div className="rounded border border-emerald-500/30 bg-emerald-500/10 px-2 py-1.5 text-[11px] text-emerald-700 dark:text-emerald-300">
2877
+ JSON 有效,已实时应用到当前图表。
2878
+ </div>
2879
+ )}
2880
+ </>
2881
+ ) : null}
2882
+ </>
2883
+ )}
2884
+ </>,
2885
+ false,
2886
+ ["json", "option", "高级", "echarts"]
2887
+ )}
2888
+ </>
2889
+ ) : selectedElement.materialType === "text" ? (
2890
+ renderSection(
2891
+ "textConfig",
2892
+ "文本配置",
2893
+ <>
2894
+ {renderFieldGroup(
2895
+ "文本内容",
2896
+ <>
2897
+ <div className="flex items-center gap-1">
2898
+ <button
2899
+ type="button"
2900
+ className="rounded border border-border px-2 py-1 text-[11px] hover:bg-accent"
2901
+ onClick={() => execTextCommand("bold")}
2902
+ >
2903
+ B
2904
+ </button>
2905
+ <button
2906
+ type="button"
2907
+ className="rounded border border-border px-2 py-1 text-[11px] italic hover:bg-accent"
2908
+ onClick={() => execTextCommand("italic")}
2909
+ >
2910
+ I
2911
+ </button>
2912
+ <button
2913
+ type="button"
2914
+ className="rounded border border-border px-2 py-1 text-[11px] underline hover:bg-accent"
2915
+ onClick={() => execTextCommand("underline")}
2916
+ >
2917
+ U
2918
+ </button>
2919
+ </div>
2920
+ <div
2921
+ ref={textEditorRef}
2922
+ className="min-h-[120px] rounded border border-border bg-background px-2 py-1.5 text-xs leading-6 outline-none"
2923
+ style={{
2924
+ fontFamily: selectedElement.textFontFamily || undefined,
2925
+ fontSize: selectedElement.textFontSize
2926
+ ? `${selectedElement.textFontSize}px`
2927
+ : undefined,
2928
+ fontWeight: selectedElement.textFontWeight || undefined,
2929
+ color: selectedElement.textColor || undefined,
2930
+ lineHeight: selectedElement.textLineHeight
2931
+ ? String(selectedElement.textLineHeight)
2932
+ : undefined,
2933
+ textAlign: selectedElement.textAlign ?? "left",
2934
+ }}
2935
+ contentEditable
2936
+ suppressContentEditableWarning
2937
+ onInput={(e) => {
2938
+ const nextHtml = (e.currentTarget as HTMLDivElement).innerHTML;
2939
+ updateSelectedText({
2940
+ textHtml: nextHtml || "<p><br/></p>",
2941
+ });
2942
+ }}
2943
+ />
2944
+ </>
2945
+ )}
2946
+ {renderFieldGroup(
2947
+ "文字样式",
2948
+ <>
2949
+ <label className="block space-y-1">
2950
+ <div>字体</div>
2951
+ <Input
2952
+ value={selectedElement.textFontFamily ?? ""}
2953
+ onChange={(e) =>
2954
+ updateSelectedText({
2955
+ textFontFamily: e.target.value || undefined,
2956
+ })
2957
+ }
2958
+ placeholder="如:Inter, PingFang SC, Microsoft YaHei"
2959
+ className="h-7"
2960
+ />
2961
+ </label>
2962
+ <div className="grid grid-cols-2 gap-2">
2963
+ <label className="block space-y-1">
2964
+ <div>字号(px)</div>
2965
+ <Input
2966
+ type="number"
2967
+ min={8}
2968
+ max={200}
2969
+ step={1}
2970
+ value={selectedElement.textFontSize ?? 14}
2971
+ onChange={(e) =>
2972
+ updateSelectedText({
2973
+ textFontSize: Math.max(8, Number(e.target.value) || 14),
2974
+ })
2975
+ }
2976
+ className="h-7"
2977
+ />
2978
+ </label>
2979
+ <label className="block space-y-1">
2980
+ <div>字重</div>
2981
+ <Select
2982
+ value={selectedElement.textFontWeight ?? "400"}
2983
+ onValueChange={(value) =>
2984
+ updateSelectedText({
2985
+ textFontWeight: value,
2986
+ })
2987
+ }
2988
+ >
2989
+ <SelectTrigger className="h-7">
2990
+ <SelectValue placeholder="选择字重" />
2991
+ </SelectTrigger>
2992
+ <SelectContent>
2993
+ <SelectItem value="300">300</SelectItem>
2994
+ <SelectItem value="400">400</SelectItem>
2995
+ <SelectItem value="500">500</SelectItem>
2996
+ <SelectItem value="600">600</SelectItem>
2997
+ <SelectItem value="700">700</SelectItem>
2998
+ </SelectContent>
2999
+ </Select>
3000
+ </label>
3001
+ </div>
3002
+ <div className="grid grid-cols-2 gap-2">
3003
+ <label className="block space-y-1">
3004
+ <div>对齐</div>
3005
+ <Select
3006
+ value={selectedElement.textAlign ?? "left"}
3007
+ onValueChange={(value) =>
3008
+ updateSelectedText({
3009
+ textAlign: value as "left" | "center" | "right" | "justify",
3010
+ })
3011
+ }
3012
+ >
3013
+ <SelectTrigger className="h-7">
3014
+ <SelectValue placeholder="选择对齐方式" />
3015
+ </SelectTrigger>
3016
+ <SelectContent>
3017
+ <SelectItem value="left">左对齐</SelectItem>
3018
+ <SelectItem value="center">居中</SelectItem>
3019
+ <SelectItem value="right">右对齐</SelectItem>
3020
+ <SelectItem value="justify">两端对齐</SelectItem>
3021
+ </SelectContent>
3022
+ </Select>
3023
+ </label>
3024
+ <label className="block space-y-1">
3025
+ <div>行高</div>
3026
+ <Input
3027
+ type="number"
3028
+ min={1}
3029
+ max={3}
3030
+ step={0.1}
3031
+ value={selectedElement.textLineHeight ?? 1.6}
3032
+ onChange={(e) =>
3033
+ updateSelectedText({
3034
+ textLineHeight: Math.min(
3035
+ 3,
3036
+ Math.max(1, Number(e.target.value) || 1.6)
3037
+ ),
3038
+ })
3039
+ }
3040
+ className="h-7"
3041
+ />
3042
+ </label>
3043
+ </div>
3044
+ {renderColorField(
3045
+ "文字颜色",
3046
+ selectedElement.textColor ?? "",
3047
+ (next) =>
3048
+ updateSelectedText({
3049
+ textColor: next || undefined,
3050
+ })
3051
+ )}
3052
+ </>
3053
+ )}
3054
+ {renderFieldGroup(
3055
+ "输入能力",
3056
+ <label className="flex items-center gap-2">
3057
+ <Checkbox
3058
+ checked={selectedElement.textAllowInput ?? true}
3059
+ className="h-4 w-4 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
3060
+ onCheckedChange={(checked) =>
3061
+ updateSelectedText({
3062
+ textAllowInput: checked !== false,
3063
+ })
3064
+ }
3065
+ />
3066
+ <span>允许在画布内直接输入(默认开启)</span>
3067
+ </label>
3068
+ )}
3069
+ </>,
3070
+ true,
3071
+ ["文本", "富文本", "字体", "颜色", "对齐", "行高", "输入"]
3072
+ )
3073
+ ) : selectedElement.materialType === "audio" ? (
3074
+ renderSection(
3075
+ "audioConfig",
3076
+ "音频配置",
3077
+ <>
3078
+ {renderFieldGroup(
3079
+ "音频来源",
3080
+ <>
3081
+ <label className="block space-y-1">
3082
+ <div>音频 URL</div>
3083
+ <Input
3084
+ value={selectedElement.audioRemoteUrl ?? ""}
3085
+ onChange={(e) =>
3086
+ updateSelectedAudio({
3087
+ audioRemoteUrl: e.target.value || undefined,
3088
+ audioSrc: e.target.value || selectedElement.audioSrc,
3089
+ })
3090
+ }
3091
+ placeholder="https://example.com/audio.mp3"
3092
+ className="h-7"
3093
+ />
3094
+ </label>
3095
+ <div className="flex items-center gap-2">
3096
+ <label className="inline-flex cursor-pointer items-center rounded border border-border px-2 py-1 text-[11px] hover:bg-accent">
3097
+ 上传音频
3098
+ <Input
3099
+ type="file"
3100
+ accept="audio/*"
3101
+ className="hidden"
3102
+ onChange={async (e) => {
3103
+ const file = e.target.files?.[0];
3104
+ e.currentTarget.value = "";
3105
+ if (!file) return;
3106
+ await handleUploadAudioFile(file);
3107
+ }}
3108
+ />
3109
+ </label>
3110
+ <button
3111
+ type="button"
3112
+ className="rounded border border-border px-2 py-1 text-[11px] hover:bg-accent disabled:opacity-50"
3113
+ onClick={isRecordingAudio ? stopRecordingAudio : startRecordingAudio}
3114
+ >
3115
+ {isRecordingAudio ? "停止录音" : "开始录音"}
3116
+ </button>
3117
+ </div>
3118
+ {audioStatus ? (
3119
+ <div className="rounded border border-border/60 bg-background px-2 py-1.5 text-[11px] text-muted-foreground">
3120
+ {audioStatus}
3121
+ </div>
3122
+ ) : null}
3123
+ <audio
3124
+ controls
3125
+ className="h-8 w-full"
3126
+ src={selectedElement.audioSrc || selectedElement.audioRemoteUrl || ""}
3127
+ />
3128
+ </>
3129
+ )}
3130
+ {renderFieldGroup(
3131
+ "展示样式",
3132
+ <>
3133
+ <label className="flex items-center gap-2">
3134
+ <Checkbox
3135
+ checked={selectedElement.mediaAutoPauseOnEdit !== false}
3136
+ className="h-4 w-4 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
3137
+ onCheckedChange={(checked) =>
3138
+ updateSelectedAudio({
3139
+ mediaAutoPauseOnEdit: checked !== false,
3140
+ })
3141
+ }
3142
+ />
3143
+ <span>编辑时自动暂停媒体</span>
3144
+ </label>
3145
+ <label className="block space-y-1">
3146
+ <div>预设喇叭图标</div>
3147
+ <Select
3148
+ value={selectedElement.audioIconPreset ?? "__none__"}
3149
+ onValueChange={(value) =>
3150
+ updateSelectedAudio({
3151
+ audioIconPreset:
3152
+ value === "__none__"
3153
+ ? undefined
3154
+ : (value as "speaker" | "music" | "headphone" | "wave"),
3155
+ })
3156
+ }
3157
+ >
3158
+ <SelectTrigger className="h-7">
3159
+ <SelectValue placeholder="选择图标" />
3160
+ </SelectTrigger>
3161
+ <SelectContent>
3162
+ <SelectItem value="__none__">默认(显示进度条)</SelectItem>
3163
+ <SelectItem value="speaker">喇叭</SelectItem>
3164
+ <SelectItem value="music">音符</SelectItem>
3165
+ <SelectItem value="headphone">耳机</SelectItem>
3166
+ <SelectItem value="wave">声波</SelectItem>
3167
+ </SelectContent>
3168
+ </Select>
3169
+ </label>
3170
+ <label className="block space-y-1">
3171
+ <div>播放动效</div>
3172
+ <Select
3173
+ value={selectedElement.audioVisualEffect ?? "pulse"}
3174
+ onValueChange={(value) =>
3175
+ updateSelectedAudio({
3176
+ audioVisualEffect: value as "none" | "pulse" | "ripple",
3177
+ })
3178
+ }
3179
+ >
3180
+ <SelectTrigger className="h-7">
3181
+ <SelectValue placeholder="选择动效" />
3182
+ </SelectTrigger>
3183
+ <SelectContent>
3184
+ <SelectItem value="none">无动效</SelectItem>
3185
+ <SelectItem value="pulse">呼吸高亮</SelectItem>
3186
+ <SelectItem value="ripple">波纹扩散</SelectItem>
3187
+ </SelectContent>
3188
+ </Select>
3189
+ </label>
3190
+ <label className="block space-y-1">
3191
+ <div>动效速度</div>
3192
+ <Select
3193
+ value={selectedElement.audioVisualSpeed ?? "normal"}
3194
+ onValueChange={(value) =>
3195
+ updateSelectedAudio({
3196
+ audioVisualSpeed: value as "slow" | "normal" | "fast",
3197
+ })
3198
+ }
3199
+ >
3200
+ <SelectTrigger className="h-7">
3201
+ <SelectValue placeholder="选择速度" />
3202
+ </SelectTrigger>
3203
+ <SelectContent>
3204
+ <SelectItem value="slow">慢</SelectItem>
3205
+ <SelectItem value="normal">中</SelectItem>
3206
+ <SelectItem value="fast">快</SelectItem>
3207
+ </SelectContent>
3208
+ </Select>
3209
+ </label>
3210
+ <div className="flex items-center gap-2">
3211
+ <label className="inline-flex cursor-pointer items-center rounded border border-border px-2 py-1 text-[11px] hover:bg-accent">
3212
+ 上传占位图
3213
+ <Input
3214
+ type="file"
3215
+ accept="image/*"
3216
+ className="hidden"
3217
+ onChange={async (e) => {
3218
+ const file = e.target.files?.[0];
3219
+ e.currentTarget.value = "";
3220
+ if (!file) return;
3221
+ await handleUploadAudioPoster(file);
3222
+ }}
3223
+ />
3224
+ </label>
3225
+ <button
3226
+ type="button"
3227
+ className="rounded border border-border px-2 py-1 text-[11px] hover:bg-accent"
3228
+ onClick={() =>
3229
+ updateSelectedAudio({
3230
+ audioPosterImage: undefined,
3231
+ })
3232
+ }
3233
+ >
3234
+ 清空占位图
3235
+ </button>
3236
+ </div>
3237
+ {selectedElement.audioPosterImage ? (
3238
+ <img
3239
+ src={selectedElement.audioPosterImage}
3240
+ alt="音频占位图预览"
3241
+ className="h-20 w-full rounded border border-border/60 object-cover"
3242
+ />
3243
+ ) : null}
3244
+ <div className="text-[11px] text-muted-foreground">
3245
+ 设置占位图或图标后,节点上将隐藏进度条,改为点击图标播放/暂停。
3246
+ </div>
3247
+ </>
3248
+ )}
3249
+ </>,
3250
+ true,
3251
+ ["音频", "url", "上传", "录音", "icon", "占位", "动效", "自动暂停", "media"]
3252
+ )
3253
+ ) : selectedElement.materialType === "video" ? (
3254
+ renderSection(
3255
+ "videoConfig",
3256
+ "视频配置",
3257
+ <>
3258
+ {renderFieldGroup(
3259
+ "视频来源",
3260
+ <>
3261
+ <label className="block space-y-1">
3262
+ <div>视频 URL</div>
3263
+ <Input
3264
+ value={selectedElement.videoRemoteUrl ?? ""}
3265
+ onChange={(e) =>
3266
+ updateSelectedVideo({
3267
+ videoRemoteUrl: e.target.value || undefined,
3268
+ videoSrc: e.target.value || selectedElement.videoSrc,
3269
+ })
3270
+ }
3271
+ placeholder="https://example.com/video.mp4"
3272
+ className="h-7"
3273
+ />
3274
+ </label>
3275
+ <div className="flex items-center gap-2">
3276
+ <label className="inline-flex cursor-pointer items-center rounded border border-border px-2 py-1 text-[11px] hover:bg-accent">
3277
+ 上传视频
3278
+ <Input
3279
+ type="file"
3280
+ accept="video/*"
3281
+ className="hidden"
3282
+ onChange={async (e) => {
3283
+ const file = e.target.files?.[0];
3284
+ e.currentTarget.value = "";
3285
+ if (!file) return;
3286
+ await handleUploadVideoFile(file);
3287
+ }}
3288
+ />
3289
+ </label>
3290
+ </div>
3291
+ {videoStatus ? (
3292
+ <div className="rounded border border-border/60 bg-background px-2 py-1.5 text-[11px] text-muted-foreground">
3293
+ {videoStatus}
3294
+ </div>
3295
+ ) : null}
3296
+ <video
3297
+ controls
3298
+ className="h-36 w-full rounded border border-border/60 bg-black/80 object-contain"
3299
+ src={selectedElement.videoSrc || selectedElement.videoRemoteUrl || ""}
3300
+ />
3301
+ <label className="flex items-center gap-2">
3302
+ <Checkbox
3303
+ checked={selectedElement.mediaAutoPauseOnEdit !== false}
3304
+ className="h-4 w-4 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
3305
+ onCheckedChange={(checked) =>
3306
+ updateSelectedVideo({
3307
+ mediaAutoPauseOnEdit: checked !== false,
3308
+ })
3309
+ }
3310
+ />
3311
+ <span>编辑时自动暂停媒体</span>
3312
+ </label>
3313
+ </>
3314
+ )}
3315
+ </>,
3316
+ true,
3317
+ ["视频", "url", "上传", "预览", "自动暂停", "media"]
3318
+ )
3319
+ ) : null}
3320
+ {selectedElement
3321
+ ? selectedElement.parentGridId
3322
+ ? renderSection(
3323
+ "gridChildSpan",
3324
+ "网格子节点占位",
3325
+ <>
3326
+ {renderFieldGroup(
3327
+ "跨槽位",
3328
+ <div className="grid grid-cols-2 gap-2">
3329
+ <label className="block space-y-1">
3330
+ <div>跨列(colSpan)</div>
3331
+ <Input
3332
+ type="number"
3333
+ min={1}
3334
+ max={12}
3335
+ value={selectedElement.gridColSpan ?? 1}
3336
+ onChange={(e) =>
3337
+ updateElement(selectedElement.id, {
3338
+ gridColSpan: Math.max(1, Math.min(12, Number(e.target.value) || 1)),
3339
+ })
3340
+ }
3341
+ className="h-7"
3342
+ />
3343
+ </label>
3344
+ <label className="block space-y-1">
3345
+ <div>跨行(rowSpan)</div>
3346
+ <Input
3347
+ type="number"
3348
+ min={1}
3349
+ max={12}
3350
+ value={selectedElement.gridRowSpan ?? 1}
3351
+ onChange={(e) =>
3352
+ updateElement(selectedElement.id, {
3353
+ gridRowSpan: Math.max(1, Math.min(12, Number(e.target.value) || 1)),
3354
+ })
3355
+ }
3356
+ className="h-7"
3357
+ />
3358
+ </label>
3359
+ </div>
3360
+ )}
3361
+ <div className="rounded border border-border/60 bg-background px-2 py-1.5 text-[11px] text-muted-foreground">
3362
+ 网格子节点可跨越多格,占据更大区域,便于复杂布局。
3363
+ </div>
3364
+ </>,
3365
+ true,
3366
+ ["网格", "跨列", "跨行", "span", "slot"]
3367
+ )
3368
+ : null
3369
+ : null}
3370
+ {selectedElement.materialType === "geometry" ? (
3371
+ renderSection(
3372
+ "geometryConfig",
3373
+ "几何配置",
3374
+ <>
3375
+ {renderFieldGroup(
3376
+ "基础形状",
3377
+ <>
3378
+ <label className="block space-y-1">
3379
+ <div>形状</div>
3380
+ <Select
3381
+ value={selectedElement.geometryShape ?? "rect"}
3382
+ onValueChange={(value) =>
3383
+ updateSelectedGeometry({
3384
+ geometryShape: value as PanelElement["geometryShape"],
3385
+ })
3386
+ }
3387
+ >
3388
+ <SelectTrigger className="h-7">
3389
+ <SelectValue placeholder="选择形状" />
3390
+ </SelectTrigger>
3391
+ <SelectContent>
3392
+ <SelectItem value="rect">矩形</SelectItem>
3393
+ <SelectItem value="circle">圆形</SelectItem>
3394
+ <SelectItem value="triangle">三角形</SelectItem>
3395
+ <SelectItem value="diamond">菱形</SelectItem>
3396
+ <SelectItem value="hexagon">六边形</SelectItem>
3397
+ <SelectItem value="star">星形</SelectItem>
3398
+ <SelectItem value="heart">爱心</SelectItem>
3399
+ </SelectContent>
3400
+ </Select>
3401
+ </label>
3402
+ {renderColorField(
3403
+ "几何颜色",
3404
+ selectedElement.geometryColor ?? "#3b82f6",
3405
+ (next) => updateSelectedGeometry({ geometryColor: next || "#3b82f6" })
3406
+ )}
3407
+ </>
3408
+ )}
3409
+ {renderFieldGroup(
3410
+ "高级(Canvas 脚本)",
3411
+ <>
3412
+ <div className="rounded border border-border/60 bg-background px-2 py-1.5 text-[11px] text-muted-foreground">
3413
+ 可输入 Canvas 绘制逻辑,变量:ctx、width、height、element。脚本异常会被安全忽略。
3414
+ </div>
3415
+ <Textarea
3416
+ value={selectedElement.geometryScript ?? ""}
3417
+ onChange={(e) => updateSelectedGeometry({ geometryScript: e.target.value || undefined })}
3418
+ spellCheck={false}
3419
+ className="h-36 font-mono text-[11px]"
3420
+ placeholder="// 例: ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.fillRect(8,8,width-16,height-16);"
3421
+ />
3422
+ </>
3423
+ )}
3424
+ {renderFieldGroup(
3425
+ "手绘叠加",
3426
+ <>
3427
+ <div className="flex items-center gap-2">
3428
+ <label className="block space-y-1">
3429
+ <div className="text-[11px]">画笔颜色</div>
3430
+ <Input
3431
+ type="color"
3432
+ value={geometryDrawPenColor}
3433
+ onChange={(e) => setGeometryDrawPenColor(e.target.value)}
3434
+ className="h-7 w-10 p-1"
3435
+ />
3436
+ </label>
3437
+ <label className="block space-y-1">
3438
+ <div className="text-[11px]">画笔粗细</div>
3439
+ <Input
3440
+ type="number"
3441
+ min={1}
3442
+ max={24}
3443
+ value={geometryDrawPenWidth}
3444
+ onChange={(e) => setGeometryDrawPenWidth(Math.max(1, Math.min(24, Number(e.target.value) || 1)))}
3445
+ className="h-7 w-20"
3446
+ />
3447
+ </label>
3448
+ </div>
3449
+ <div className="rounded border border-border/60 bg-white p-2">
3450
+ <canvas
3451
+ ref={geometryDrawCanvasRef}
3452
+ width={320}
3453
+ height={180}
3454
+ className="h-[180px] w-full cursor-crosshair rounded border border-border/60"
3455
+ onPointerDown={(e) => {
3456
+ const canvas = geometryDrawCanvasRef.current;
3457
+ if (!canvas) return;
3458
+ const ctx = canvas.getContext("2d");
3459
+ if (!ctx) return;
3460
+ const rect = canvas.getBoundingClientRect();
3461
+ const x = ((e.clientX - rect.left) / rect.width) * canvas.width;
3462
+ const y = ((e.clientY - rect.top) / rect.height) * canvas.height;
3463
+ geometryLastPointRef.current = { x, y };
3464
+ setIsGeometryDrawing(true);
3465
+ ctx.lineCap = "round";
3466
+ ctx.lineJoin = "round";
3467
+ ctx.strokeStyle = geometryDrawPenColor;
3468
+ ctx.lineWidth = geometryDrawPenWidth;
3469
+ ctx.beginPath();
3470
+ ctx.moveTo(x, y);
3471
+ }}
3472
+ onPointerMove={(e) => {
3473
+ if (!isGeometryDrawing) return;
3474
+ const canvas = geometryDrawCanvasRef.current;
3475
+ const last = geometryLastPointRef.current;
3476
+ if (!canvas || !last) return;
3477
+ const ctx = canvas.getContext("2d");
3478
+ if (!ctx) return;
3479
+ const rect = canvas.getBoundingClientRect();
3480
+ const x = ((e.clientX - rect.left) / rect.width) * canvas.width;
3481
+ const y = ((e.clientY - rect.top) / rect.height) * canvas.height;
3482
+ ctx.lineTo(x, y);
3483
+ ctx.stroke();
3484
+ geometryLastPointRef.current = { x, y };
3485
+ }}
3486
+ onPointerUp={() => {
3487
+ setIsGeometryDrawing(false);
3488
+ geometryLastPointRef.current = null;
3489
+ }}
3490
+ onPointerLeave={() => {
3491
+ setIsGeometryDrawing(false);
3492
+ geometryLastPointRef.current = null;
3493
+ }}
3494
+ />
3495
+ </div>
3496
+ <div className="flex items-center gap-2">
3497
+ <button
3498
+ type="button"
3499
+ className="rounded border border-border px-2 py-1 text-[11px] hover:bg-accent"
3500
+ onClick={() => {
3501
+ const canvas = geometryDrawCanvasRef.current;
3502
+ if (!canvas) return;
3503
+ updateSelectedGeometry({ geometrySketchDataUrl: canvas.toDataURL("image/png") });
3504
+ }}
3505
+ >
3506
+ 应用手绘到节点
3507
+ </button>
3508
+ <button
3509
+ type="button"
3510
+ className="rounded border border-border px-2 py-1 text-[11px] hover:bg-accent"
3511
+ onClick={() => {
3512
+ updateSelectedGeometry({ geometrySketchDataUrl: undefined });
3513
+ redrawGeometryPadFromElement();
3514
+ }}
3515
+ >
3516
+ 清空手绘
3517
+ </button>
3518
+ </div>
3519
+ </>
3520
+ )}
3521
+ </>,
3522
+ true,
3523
+ ["几何", "geometry", "形状", "canvas", "脚本", "手绘"]
3524
+ )
3525
+ ) : selectedElement.materialType === "grid" ? (
3526
+ renderSection(
3527
+ "gridConfig",
3528
+ "网格布局配置",
3529
+ <>
3530
+ {renderFieldGroup(
3531
+ "网格参数",
3532
+ <div className="grid grid-cols-2 gap-2">
3533
+ <label className="block space-y-1">
3534
+ <div>行数</div>
3535
+ <Input
3536
+ type="number"
3537
+ min={1}
3538
+ max={12}
3539
+ value={selectedElement.gridRows ?? 2}
3540
+ onChange={(e) =>
3541
+ updateElement(selectedElement.id, {
3542
+ gridRows: Math.max(1, Math.min(12, Number(e.target.value) || 2)),
3543
+ })
3544
+ }
3545
+ className="h-7"
3546
+ />
3547
+ </label>
3548
+ <label className="block space-y-1">
3549
+ <div>列数</div>
3550
+ <Input
3551
+ type="number"
3552
+ min={1}
3553
+ max={12}
3554
+ value={selectedElement.gridCols ?? 3}
3555
+ onChange={(e) =>
3556
+ updateElement(selectedElement.id, {
3557
+ gridCols: Math.max(1, Math.min(12, Number(e.target.value) || 3)),
3558
+ })
3559
+ }
3560
+ className="h-7"
3561
+ />
3562
+ </label>
3563
+ <label className="block space-y-1">
3564
+ <div>间距(px)</div>
3565
+ <Input
3566
+ type="number"
3567
+ min={0}
3568
+ max={80}
3569
+ value={selectedElement.gridGap ?? 8}
3570
+ onChange={(e) =>
3571
+ updateElement(selectedElement.id, {
3572
+ gridGap: Math.max(0, Math.min(80, Number(e.target.value) || 0)),
3573
+ })
3574
+ }
3575
+ className="h-7"
3576
+ />
3577
+ </label>
3578
+ <label className="block space-y-1">
3579
+ <div>内边距(px)</div>
3580
+ <Input
3581
+ type="number"
3582
+ min={0}
3583
+ max={100}
3584
+ value={selectedElement.gridPadding ?? 10}
3585
+ onChange={(e) =>
3586
+ updateElement(selectedElement.id, {
3587
+ gridPadding: Math.max(0, Math.min(100, Number(e.target.value) || 0)),
3588
+ })
3589
+ }
3590
+ className="h-7"
3591
+ />
3592
+ </label>
3593
+ <label className="block space-y-1">
3594
+ <div>吸附阈值(px)</div>
3595
+ <Input
3596
+ type="number"
3597
+ min={8}
3598
+ max={120}
3599
+ value={selectedElement.gridSnapThreshold ?? 36}
3600
+ onChange={(e) =>
3601
+ updateElement(selectedElement.id, {
3602
+ gridSnapThreshold: Math.max(
3603
+ 8,
3604
+ Math.min(120, Number(e.target.value) || 36)
3605
+ ),
3606
+ })
3607
+ }
3608
+ className="h-7"
3609
+ />
3610
+ </label>
3611
+ </div>
3612
+ )}
3613
+ <div className="rounded border border-border/60 bg-background px-2 py-1.5 text-[11px] text-muted-foreground">
3614
+ 其他节点拖拽靠近该网格槽位中心时会自动吸附,并在节点树显示为该网格子节点。
3615
+ </div>
3616
+ </>,
3617
+ true,
3618
+ ["网格", "grid", "行", "列", "间距", "内边距", "吸附", "阈值"]
3619
+ )
3620
+ ) : selectedElement.materialType === "reference" ? (
3621
+ renderSection(
3622
+ "reference",
3623
+ "引用组件配置",
3624
+ <>
3625
+ {renderFieldGroup(
3626
+ "引用源",
3627
+ <label className="block space-y-1">
3628
+ <div>引用图层</div>
3629
+ <Select
3630
+ value={selectedElement.refLayerId ?? "__none__"}
3631
+ onValueChange={(value) =>
3632
+ updateElement(selectedElement.id, {
3633
+ refLayerId: value === "__none__" ? undefined : value,
3634
+ })
3635
+ }
3636
+ >
3637
+ <SelectTrigger className="h-7">
3638
+ <SelectValue placeholder="请选择图层" />
3639
+ </SelectTrigger>
3640
+ <SelectContent>
3641
+ <SelectItem value="__none__">无(不引用)</SelectItem>
3642
+ {layers
3643
+ .filter((l) => l.id !== selectedElement.layerId)
3644
+ .map((l) => (
3645
+ <SelectItem key={l.id} value={l.id}>
3646
+ {l.name}
3647
+ </SelectItem>
3648
+ ))}
3649
+ </SelectContent>
3650
+ </Select>
3651
+ </label>
3652
+ )}
3653
+ {renderFieldGroup(
3654
+ "拷贝策略",
3655
+ <>
3656
+ <label className="block space-y-1">
3657
+ <div>拷贝模式</div>
3658
+ <Select
3659
+ value={selectedElement.refCopyMode ?? "shallow"}
3660
+ onValueChange={(value) =>
3661
+ setReferenceCopyMode?.(
3662
+ selectedElement.id,
3663
+ value as ReferenceCopyMode
3664
+ )
3665
+ }
3666
+ >
3667
+ <SelectTrigger className="h-7">
3668
+ <SelectValue placeholder="请选择拷贝模式" />
3669
+ </SelectTrigger>
3670
+ <SelectContent>
3671
+ <SelectItem value="shallow">浅拷贝(跟随源图层变化)</SelectItem>
3672
+ <SelectItem value="deep">深拷贝(冻结当前引用快照)</SelectItem>
3673
+ </SelectContent>
3674
+ </Select>
3675
+ </label>
3676
+ <div className="rounded border border-border/60 bg-background px-2 py-1.5 text-[11px] text-muted-foreground">
3677
+ 浅拷贝会实时同步被引用图层;深拷贝会固定当前快照,不再随源变化。
3678
+ </div>
3679
+ </>
3680
+ )}
3681
+ </>,
3682
+ true,
3683
+ ["引用", "ref", "图层", "浅拷贝", "深拷贝", "snapshot"]
3684
+ )
3685
+ ) : (
3686
+ <div className="text-xs leading-6 text-muted-foreground">
3687
+ 当前节点不是图表类型,暂无图表配置项。
3688
+ </div>
3689
+ )}
3690
+ {hasSearch && renderedSectionCount === 0 ? (
3691
+ <div className="rounded border border-border/60 bg-background px-2 py-1.5 text-[11px] text-muted-foreground">
3692
+ 未找到匹配项,请尝试更换关键词。
3693
+ </div>
3694
+ ) : null}
3695
+ </div>
3696
+ </fieldset>
3697
+ </div>
3698
+ ) : null}
3699
+ </aside>
3700
+ </ScopeConfigProvider>
3701
+ );
3702
+ }
3703
+