@arronqzy/react-view 1.0.3 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/styles.css +555 -15
  2. package/package.json +14 -7
  3. package/readme.md +74 -5
  4. package/src/core/template.tsx +12 -2
  5. package/src/panel/ReactViewOnlinePreview.tsx +18 -8
  6. package/src/panel/ReactViewPanel.tsx +403 -177
  7. package/src/panel/ai/AssistantChatPanel.tsx +895 -0
  8. package/src/panel/ai/AssistantMarkdown.tsx +256 -0
  9. package/src/panel/ai/applyAssistantAction.ts +652 -0
  10. package/src/panel/ai/revealConfigField.ts +137 -0
  11. package/src/panel/components/ConfigHintIcon.tsx +50 -0
  12. package/src/panel/components/ElementsLayer.tsx +39 -22
  13. package/src/panel/components/MaterialSidebar.tsx +157 -109
  14. package/src/panel/components/MoveableLayer.tsx +75 -61
  15. package/src/panel/components/PanelCanvas.tsx +81 -64
  16. package/src/panel/components/PanelConfigSidebar.tsx +471 -448
  17. package/src/panel/components/SelectLayer.tsx +1 -0
  18. package/src/panel/components/ViewElementScopePanel.tsx +13 -12
  19. package/src/panel/components/WorkspaceConfigSidebar.tsx +5 -2
  20. package/src/panel/components/WorkspaceProjectNav.tsx +25 -14
  21. package/src/panel/components/WorkspaceStageSplit.tsx +3 -1
  22. package/src/panel/components/scope-config/ScopeConfigContext.tsx +9 -0
  23. package/src/panel/components/scope-config/ScopeTemplatePreviewHost.tsx +242 -0
  24. package/src/panel/components/scope-config/ScopeTemplateUsageHint.tsx +12 -0
  25. package/src/panel/components/scope-config/ScopeTemplateWarningsPanel.tsx +6 -3
  26. package/src/panel/components/table/ConditionEditor.tsx +205 -0
  27. package/src/panel/components/table/JsonCodeEditor.tsx +115 -0
  28. package/src/panel/components/table/PanelConfigTableSection.tsx +2192 -0
  29. package/src/panel/components/table/TableNodeContent.tsx +544 -0
  30. package/src/panel/constants/messages.ts +76 -34
  31. package/src/panel/hooks/usePanelElements.ts +64 -52
  32. package/src/panel/hooks/useRafThrottledScroll.ts +30 -0
  33. package/src/panel/hooks/useWorkspaceProjects.ts +11 -8
  34. package/src/panel/types.ts +6 -0
  35. package/src/panel/utils/chartOptionBuilder.ts +12 -1
  36. package/src/panel/utils/panelElementDefaults.ts +69 -39
  37. package/src/panel/utils/scope-field-labels.ts +51 -37
  38. package/src/panel/utils/scope-template-chart.ts +92 -0
  39. package/src/panel/utils/scope-template-preview.ts +128 -0
  40. package/src/panel/utils/scope-template-spread.ts +252 -0
  41. package/src/panel/utils/scope-template-warnings.ts +131 -11
  42. package/src/panel/utils/scope-template.ts +105 -3
  43. package/src/tailwind.css +36 -15
@@ -1,4 +1,5 @@
1
1
  import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { useI18n } from "@arronqzy/i18n/react";
2
3
  import type { State } from "@arronqzy/rx-store";
3
4
  import type { PanelElement } from "./types";
4
5
 
@@ -14,6 +15,14 @@ import { SelectLayer } from "./components/SelectLayer";
14
15
  import { MoveableLayer } from "./components/MoveableLayer";
15
16
  import { buildChartOption, CHART_TYPES } from "./utils/chartOptionBuilder";
16
17
  import { MaterialSidebar } from "./components/MaterialSidebar";
18
+ import { AssistantChatPanel } from "./ai/AssistantChatPanel";
19
+ import {
20
+ listLayersFromStore,
21
+ listPanelElementsFromStore,
22
+ getActiveLayerIdFromStore,
23
+ type ApplyAssistantDeps,
24
+ } from "./ai/applyAssistantAction";
25
+ import { revealPanelConfigFromPatch } from "./ai/revealConfigField";
17
26
  import {
18
27
  BlueprintGraph,
19
28
  BlueprintMetaDialog,
@@ -55,8 +64,9 @@ import {
55
64
  useViewScopeStoreVersion,
56
65
  } from "./scope/view-scope-store";
57
66
  import { resolvePanelElementScope } from "./utils/scope-template";
58
- import { PANEL_MESSAGES } from "./constants/messages";
67
+ import { getPanelMessages } from "./constants/messages";
59
68
  import { PANEL_Z_INDEX } from "./constants/zIndex";
69
+ import { useRafThrottledScroll } from "./hooks/useRafThrottledScroll";
60
70
  import {
61
71
  AlertDialog,
62
72
  AlertDialogAction,
@@ -66,6 +76,7 @@ import {
66
76
  AlertDialogFooter,
67
77
  AlertDialogHeader,
68
78
  AlertDialogTitle,
79
+ Button,
69
80
  Checkbox,
70
81
  Dialog,
71
82
  DialogContent,
@@ -111,6 +122,7 @@ import {
111
122
  TooltipProvider,
112
123
  TooltipTrigger,
113
124
  toast,
125
+ useTheme,
114
126
  } from "@arronqzy/ui";
115
127
 
116
128
  function IconPlus() {
@@ -272,16 +284,20 @@ function IconImage() {
272
284
  );
273
285
  }
274
286
 
275
- function formatRelativeTime(timestamp: number, now: number): string {
287
+ function formatRelativeTime(
288
+ timestamp: number,
289
+ now: number,
290
+ t: (key: string, params?: Record<string, string | number>) => string
291
+ ): string {
276
292
  const diff = Math.max(0, now - timestamp);
277
293
  const sec = Math.floor(diff / 1000);
278
- if (sec < 60) return `${sec}秒前`;
294
+ if (sec < 60) return t("common.secondsAgo", { sec });
279
295
  const min = Math.floor(sec / 60);
280
- if (min < 60) return `${min}分钟前`;
296
+ if (min < 60) return t("common.minutesAgo", { min });
281
297
  const hour = Math.floor(min / 60);
282
- if (hour < 24) return `${hour}小时前`;
298
+ if (hour < 24) return t("common.hoursAgo", { hour });
283
299
  const day = Math.floor(hour / 24);
284
- return `${day}天前`;
300
+ return t("common.daysAgo", { day });
285
301
  }
286
302
 
287
303
  function getSelectedTargetsFromIds(
@@ -304,6 +320,7 @@ function shouldClearSelectionOnBlank(target: HTMLElement | null) {
304
320
  if (
305
321
  target.closest(".rv-selectable") ||
306
322
  target.closest(".moveable-control-box") ||
323
+ target.closest(".moveable-group") ||
307
324
  target.closest(".moveable-line") ||
308
325
  target.closest(".moveable-control") ||
309
326
  target.closest(".moveable-direction")
@@ -330,9 +347,15 @@ export type ReactViewPanelProps = {
330
347
  className?: string;
331
348
  };
332
349
 
333
- export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelProps) {
350
+ export function ReactViewPanel({
351
+ initialZoom = 1,
352
+ className,
353
+ }: ReactViewPanelProps) {
354
+ const { t, locale, setLocale } = useI18n();
355
+ const messages = useMemo(() => getPanelMessages(t), [t]);
334
356
  const THEME_STORAGE_KEY = "panel:theme";
335
357
  const TITLE_ICON_STORAGE_KEY = "panel:titleIconDataUrl";
358
+ const { setTheme } = useTheme();
336
359
  const themedScrollbarClass =
337
360
  "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";
338
361
  const {
@@ -396,7 +419,9 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
396
419
  return { x: rounded, y: rounded };
397
420
  });
398
421
  }, []);
399
- const [scroll, setScroll] = useState({ left: 0, top: 0 });
422
+ const { scroll, scrollRef: scrollPosRef, onScrollChange: onViewportScrollChange } =
423
+ useRafThrottledScroll();
424
+ const viewportSyncRef = useRef<(() => void) | null>(null);
400
425
 
401
426
  const [selectedIds, setSelectedIds] = useState<string[]>([]);
402
427
  const [selectedTargets, setSelectedTargets] = useState<HTMLElement[]>([]);
@@ -429,16 +454,19 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
429
454
  const [isHistoryPanelExpanded, setIsHistoryPanelExpanded] = useState(false);
430
455
  const [historyNow, setHistoryNow] = useState(Date.now());
431
456
  const [historyKeyword, setHistoryKeyword] = useState("");
432
- const [productName, setProductName] = useState("未命名产物");
457
+ const [productName, setProductName] = useState(t("panel.defaults.unnamedProduct"));
433
458
  const [titleIconDataUrl, setTitleIconDataUrl] = useState<string>("");
434
459
  const [titleIconPreviewOpen, setTitleIconPreviewOpen] = useState(false);
435
460
  const [titleIconZoom, setTitleIconZoom] = useState(1.6);
436
461
  const [pendingPreviewLayerId, setPendingPreviewLayerId] = useState<string | null>(null);
437
462
  const [panelFontSize, setPanelFontSize] = useState<"sm" | "md" | "lg">("md");
438
463
  const [blueprintOpen, setBlueprintOpen] = useState(false);
464
+ const [assistantOpen, setAssistantOpen] = useState(false);
439
465
  const [blueprintGraph, setBlueprintGraph] = useState(() => BlueprintGraph.empty());
466
+ const blueprintGraphRef = useRef(blueprintGraph);
467
+ blueprintGraphRef.current = blueprintGraph;
440
468
  const [blueprintMeta, setBlueprintMeta] = useState<BlueprintMetaDraft>({
441
- name: "未命名蓝图",
469
+ name: t("panel.defaults.unnamedBlueprint"),
442
470
  remark: "",
443
471
  });
444
472
  const [blueprintLibraryItems, setBlueprintLibraryItems] = useState<
@@ -487,7 +515,9 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
487
515
  const applyTheme = useCallback((checked: boolean) => {
488
516
  const root = document.documentElement;
489
517
  root.classList.toggle("dark", checked);
518
+ root.classList.toggle("light", !checked);
490
519
  root.dataset.theme = checked ? "dark" : "light";
520
+ setTheme(checked ? "dark" : "light");
491
521
  try {
492
522
  // panel 独立持久化键,避免与宿主应用的通用 theme 键互相覆盖
493
523
  localStorage.setItem(THEME_STORAGE_KEY, checked ? "dark" : "light");
@@ -497,7 +527,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
497
527
  // ignore storage errors
498
528
  }
499
529
  setIsDark(checked);
500
- }, [THEME_STORAGE_KEY]);
530
+ }, [THEME_STORAGE_KEY, setTheme]);
501
531
 
502
532
  useEffect(() => {
503
533
  const root = document.documentElement;
@@ -828,7 +858,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
828
858
  const blueprintDebugSession = useBlueprintDebugSession({
829
859
  graph: blueprintGraph,
830
860
  blueprintId: activeBlueprintLibraryId,
831
- blueprintName: blueprintMeta.name || "未命名蓝图",
861
+ blueprintName: blueprintMeta.name || t("panel.defaults.unnamedBlueprint"),
832
862
  resolveLibraryBlueprint,
833
863
  libraryNameById: blueprintLibraryNameById,
834
864
  onExecutionBlocked: handleBlueprintExecutionBlocked,
@@ -894,8 +924,8 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
894
924
  toast({
895
925
  title:
896
926
  removed > 0
897
- ? `已清空 IndexedDB 中 ${removed} 条蓝图日志`
898
- : "IndexedDB 中暂无已保存日志",
927
+ ? messages.clearedBlueprintLogs(removed)
928
+ : messages.noSavedBlueprintLogs,
899
929
  });
900
930
  },
901
931
  hasSavedRuns: blueprintDebugSession.totalSavedRunCount > 0,
@@ -946,7 +976,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
946
976
  async (id: string) => {
947
977
  const record = await getBlueprintLibraryRecord(id);
948
978
  if (!record) {
949
- toast({ title: "蓝图不存在或已被删除" });
979
+ toast({ title: t("panel.messages.blueprintNotFound") });
950
980
  void refreshBlueprintLibrary();
951
981
  return;
952
982
  }
@@ -1018,7 +1048,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1018
1048
 
1019
1049
  const existing = await getBlueprintLibraryRecord(activeBlueprintLibraryId);
1020
1050
  if (!existing) {
1021
- toast({ title: "蓝图不存在或已被删除" });
1051
+ toast({ title: t("panel.messages.blueprintNotFound") });
1022
1052
  void refreshBlueprintLibrary();
1023
1053
  setActiveBlueprintLibraryId(null);
1024
1054
  setBlueprintSyncedDocument(null);
@@ -1035,7 +1065,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1035
1065
  await putBlueprintLibraryRecord(record);
1036
1066
  setBlueprintSyncedDocument(blueprintGraph.document);
1037
1067
  await refreshBlueprintLibrary();
1038
- toast({ title: `已同步「${record.name}」` });
1068
+ toast({ title: messages.blueprintSynced(record.name) });
1039
1069
  }, [
1040
1070
  activeBlueprintLibraryId,
1041
1071
  blueprintGraph.document,
@@ -1072,7 +1102,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1072
1102
  setActiveBlueprintLibraryId(record.id);
1073
1103
  setBlueprintSyncedDocument(blueprintGraph.document);
1074
1104
  await refreshBlueprintLibrary();
1075
- toast({ title: "蓝图已保存到本地" });
1105
+ toast({ title: t("panel.messages.blueprintSavedLocal") });
1076
1106
  },
1077
1107
  [activeBlueprintLibraryId, blueprintGraph.document, refreshBlueprintLibrary]
1078
1108
  );
@@ -1089,7 +1119,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1089
1119
  downloadBlueprintExport(
1090
1120
  buildBlueprintExportPayload(blueprintGraph.document, meta)
1091
1121
  );
1092
- toast({ title: "蓝图已导出" });
1122
+ toast({ title: t("panel.messages.blueprintExported") });
1093
1123
  return;
1094
1124
  }
1095
1125
  await saveBlueprintToLibrary(meta);
@@ -1101,7 +1131,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1101
1131
  async (id: string, name: string) => {
1102
1132
  const updated = await updateBlueprintLibraryMeta(id, { name });
1103
1133
  if (!updated) {
1104
- toast({ title: "重命名失败" });
1134
+ toast({ title: t("panel.messages.renameFailed") });
1105
1135
  void refreshBlueprintLibrary();
1106
1136
  return;
1107
1137
  }
@@ -1109,7 +1139,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1109
1139
  if (activeBlueprintLibraryId === id) {
1110
1140
  setBlueprintMeta((prev) => ({ ...prev, name }));
1111
1141
  }
1112
- toast({ title: "蓝图已重命名" });
1142
+ toast({ title: t("panel.messages.blueprintRenamed") });
1113
1143
  },
1114
1144
  [activeBlueprintLibraryId, refreshBlueprintLibrary]
1115
1145
  );
@@ -1122,7 +1152,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1122
1152
  setActiveBlueprintLibraryId(null);
1123
1153
  setBlueprintSyncedDocument(null);
1124
1154
  }
1125
- toast({ title: "蓝图已从库中删除" });
1155
+ toast({ title: t("panel.messages.blueprintDeletedFromLibrary") });
1126
1156
  },
1127
1157
  [activeBlueprintLibraryId, refreshBlueprintLibrary]
1128
1158
  );
@@ -1142,7 +1172,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1142
1172
  snapshotWorkspaceBlueprint();
1143
1173
  }
1144
1174
  await loadBlueprintFromLibrary(record.id);
1145
- toast({ title: "蓝图已导入并加载" });
1175
+ toast({ title: t("panel.messages.blueprintImported") });
1146
1176
  },
1147
1177
  [
1148
1178
  activeBlueprintLibraryId,
@@ -1162,7 +1192,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1162
1192
  workspaceBlueprintRef.current = {
1163
1193
  document: record.blueprintDocument,
1164
1194
  meta: {
1165
- name: record.blueprintMeta?.name ?? "未命名蓝图",
1195
+ name: record.blueprintMeta?.name ?? t("panel.defaults.unnamedBlueprint"),
1166
1196
  remark: record.blueprintMeta?.remark ?? "",
1167
1197
  },
1168
1198
  };
@@ -1190,7 +1220,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1190
1220
  onProjectApplied: handleWorkspaceProjectApplied,
1191
1221
  });
1192
1222
 
1193
- useBlueprintPageLifecycle({
1223
+ const { triggerBlueprintNode } = useBlueprintPageLifecycle({
1194
1224
  graph: blueprintGraph,
1195
1225
  active: blueprintOpen,
1196
1226
  bootKey: workspaceProjects.activeProjectId ?? undefined,
@@ -1205,7 +1235,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1205
1235
  const handleWorkspaceCreateProject = useCallback(async () => {
1206
1236
  const result = await workspaceProjects.handleCreateProject();
1207
1237
  if (result?.name) {
1208
- toast({ title: `已创建工作区「${result.name}」` });
1238
+ toast({ title: messages.workspaceCreated(result.name) });
1209
1239
  }
1210
1240
  return result;
1211
1241
  }, [workspaceProjects]);
@@ -1217,12 +1247,12 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1217
1247
  await workspaceProjects.handleOpenProject(id);
1218
1248
  setSelectedIds([]);
1219
1249
  toast({
1220
- title: "工作区已加载",
1221
- description: "视图与蓝图已恢复",
1250
+ title: t("panel.messages.workspaceLoaded"),
1251
+ description: t("panel.messages.workspaceLoadedDesc"),
1222
1252
  });
1223
1253
  } catch (error) {
1224
1254
  toast({
1225
- title: error instanceof Error ? error.message : "打开工作区失败",
1255
+ title: error instanceof Error ? error.message : t("panel.messages.openWorkspaceFailed"),
1226
1256
  });
1227
1257
  }
1228
1258
  },
@@ -1233,11 +1263,11 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1233
1263
  try {
1234
1264
  const name = await workspaceProjects.handleSyncProject();
1235
1265
  if (name) {
1236
- toast({ title: `已同步「${name}」` });
1266
+ toast({ title: messages.blueprintSynced(name) });
1237
1267
  }
1238
1268
  } catch (error) {
1239
1269
  toast({
1240
- title: error instanceof Error ? error.message : "同步失败",
1270
+ title: error instanceof Error ? error.message : t("panel.messages.syncFailed"),
1241
1271
  });
1242
1272
  }
1243
1273
  }, [workspaceProjects]);
@@ -1245,7 +1275,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1245
1275
  const handleWorkspaceDeleteProject = useCallback(
1246
1276
  async (id: string) => {
1247
1277
  await workspaceProjects.handleDeleteProject(id);
1248
- toast({ title: "工作区已删除" });
1278
+ toast({ title: t("panel.messages.workspaceDeleted") });
1249
1279
  },
1250
1280
  [workspaceProjects]
1251
1281
  );
@@ -1256,7 +1286,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1256
1286
  await workspaceProjects.handlePreviewProject(projectId, options);
1257
1287
  } catch (error) {
1258
1288
  toast({
1259
- title: error instanceof Error ? error.message : "打开预览失败",
1289
+ title: error instanceof Error ? error.message : t("panel.messages.openPreviewFailed"),
1260
1290
  });
1261
1291
  }
1262
1292
  },
@@ -1295,42 +1325,200 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1295
1325
  const layer = layerById.get(el.layerId);
1296
1326
  return !layer?.locked;
1297
1327
  });
1328
+
1329
+ const selectedIdsRef = useRef(selectedIds);
1330
+ selectedIdsRef.current = selectedIds;
1331
+ const zoomRef = useRef(zoom);
1332
+ zoomRef.current = zoom;
1333
+ const blueprintOpenRef = useRef(blueprintOpen);
1334
+ blueprintOpenRef.current = blueprintOpen;
1335
+ const workspaceLiveRef = useRef({
1336
+ activeProjectId: workspaceProjects.activeProjectId,
1337
+ activeProjectName: workspaceProjects.activeProjectName,
1338
+ dirty: workspaceProjects.dirty,
1339
+ projectCount: workspaceProjects.projects.length,
1340
+ });
1341
+ workspaceLiveRef.current = {
1342
+ activeProjectId: workspaceProjects.activeProjectId,
1343
+ activeProjectName: workspaceProjects.activeProjectName,
1344
+ dirty: workspaceProjects.dirty,
1345
+ projectCount: workspaceProjects.projects.length,
1346
+ };
1347
+
1348
+ const assistantDeps = useMemo<ApplyAssistantDeps>(
1349
+ () => ({
1350
+ addElementFromMaterial,
1351
+ updateElement,
1352
+ deleteElement,
1353
+ duplicateElement,
1354
+ setSelectedIds: (ids) => {
1355
+ selectedIdsRef.current = ids;
1356
+ selectViewElements(ids);
1357
+ // Newly added nodes may not be in DOM until next paint — refresh Moveable targets.
1358
+ requestAnimationFrame(() => {
1359
+ setSelectedTargets(
1360
+ getSelectedTargetsFromIds(canvasRef.current, selectedIdsRef.current)
1361
+ );
1362
+ });
1363
+ },
1364
+ getSelectedIds: () => selectedIdsRef.current,
1365
+ revealConfigFromPatch: (patch) => {
1366
+ window.setTimeout(() => {
1367
+ revealPanelConfigFromPatch(patch);
1368
+ }, 60);
1369
+ },
1370
+ focusWorkspace: (area) => {
1371
+ if (area === "blueprint") {
1372
+ setConfigFocus("blueprint");
1373
+ setBlueprintOpen(true);
1374
+ } else {
1375
+ focusViewConfig();
1376
+ }
1377
+ },
1378
+ getElementIds: () =>
1379
+ new Set(listPanelElementsFromStore().map((el) => el.id)),
1380
+ getElement: (id) => listPanelElementsFromStore().find((el) => el.id === id),
1381
+ listElements: () => listPanelElementsFromStore(),
1382
+ getLayers: () => listLayersFromStore(),
1383
+ getActiveLayerId: () => getActiveLayerIdFromStore(),
1384
+ setActiveLayer,
1385
+ addLayer,
1386
+ renameLayer,
1387
+ setLayerLocked: (id, locked) => {
1388
+ const layer = listLayersFromStore().find((l) => l.id === id);
1389
+ if (!layer) return;
1390
+ if (!!layer.locked !== locked) toggleLayerLock(id);
1391
+ },
1392
+ deleteLayer: (id) => {
1393
+ const result = deleteLayer(id, { mode: "remove" });
1394
+ return result.ok
1395
+ ? { ok: true }
1396
+ : { ok: false, reason: result.reason };
1397
+ },
1398
+ bringElementsToFront,
1399
+ sendElementsToBack,
1400
+ bringElementsForward,
1401
+ sendElementsBackward,
1402
+ getZoom: () => zoomRef.current.x,
1403
+ setZoomAbsolute: (value) => {
1404
+ const next = Math.min(4, Math.max(0.25, value));
1405
+ const rounded = Number(next.toFixed(4));
1406
+ zoomRef.current = { x: rounded, y: rounded };
1407
+ setZoom({ x: rounded, y: rounded });
1408
+ },
1409
+ adjustZoomDelta: (delta) => {
1410
+ const next = Math.min(4, Math.max(0.25, zoomRef.current.x + delta));
1411
+ const rounded = Number(next.toFixed(4));
1412
+ zoomRef.current = { x: rounded, y: rounded };
1413
+ setZoom({ x: rounded, y: rounded });
1414
+ },
1415
+ fitViewport: () => {
1416
+ zoomRef.current = { x: 1, y: 1 };
1417
+ setZoom({ x: 1, y: 1 });
1418
+ },
1419
+ undo,
1420
+ redo,
1421
+ setBlueprintGraph,
1422
+ getBlueprintGraph: () => blueprintGraphRef.current,
1423
+ setBlueprintOpen,
1424
+ getBlueprintOpen: () => blueprintOpenRef.current,
1425
+ runBlueprintAll: () => blueprintDebugSession.runAll(),
1426
+ getWorkspace: () => ({ ...workspaceLiveRef.current }),
1427
+ workspaceSave: () => workspaceProjects.handleSaveProject(),
1428
+ workspaceSync: () => workspaceProjects.handleSyncProject(),
1429
+ workspaceCreate: async (name) => {
1430
+ if (name?.trim()) setProductName(name.trim());
1431
+ return workspaceProjects.handleCreateProject();
1432
+ },
1433
+ workspaceOpen: (id) => workspaceProjects.handleOpenProject(id),
1434
+ workspacePreview: async (id) => {
1435
+ const target =
1436
+ id ?? workspaceLiveRef.current.activeProjectId ?? undefined;
1437
+ if (!target) {
1438
+ const created = await workspaceProjects.handleCreateProject();
1439
+ await openOnlinePreviewForProject(created.id, { syncFirst: false });
1440
+ return;
1441
+ }
1442
+ await openOnlinePreviewForProject(target, { syncFirst: true });
1443
+ },
1444
+ exportPanelJson: () => JSON.stringify(exportPanelData(), null, 2),
1445
+ importPanelJson: (json) => {
1446
+ const parsed = JSON.parse(json) as State;
1447
+ const ok = importPanelData(parsed);
1448
+ if (ok) {
1449
+ selectedIdsRef.current = [];
1450
+ setSelectedIds([]);
1451
+ }
1452
+ return ok;
1453
+ },
1454
+ setTheme: (theme) => applyTheme(theme === "dark"),
1455
+ setLocale,
1456
+ setPanelFontSize,
1457
+ }),
1458
+ [
1459
+ addElementFromMaterial,
1460
+ updateElement,
1461
+ deleteElement,
1462
+ duplicateElement,
1463
+ selectViewElements,
1464
+ focusViewConfig,
1465
+ setActiveLayer,
1466
+ addLayer,
1467
+ renameLayer,
1468
+ toggleLayerLock,
1469
+ deleteLayer,
1470
+ bringElementsToFront,
1471
+ sendElementsToBack,
1472
+ bringElementsForward,
1473
+ sendElementsBackward,
1474
+ undo,
1475
+ redo,
1476
+ setBlueprintGraph,
1477
+ blueprintDebugSession,
1478
+ workspaceProjects,
1479
+ openOnlinePreviewForProject,
1480
+ exportPanelData,
1481
+ importPanelData,
1482
+ applyTheme,
1483
+ setLocale,
1484
+ ]
1485
+ );
1298
1486
  const showActionHint = useCallback((message: string) => {
1299
1487
  toast({
1300
- title: "操作受限",
1488
+ title: t("panel.messages.operationRestricted"),
1301
1489
  description: message,
1302
1490
  });
1303
1491
  }, []);
1304
1492
  const hintUnlockNodeForDelete = useCallback(() => {
1305
- showActionHint(PANEL_MESSAGES.nodeDeleteLocked);
1493
+ showActionHint(messages.nodeDeleteLocked);
1306
1494
  }, [showActionHint]);
1307
1495
  const hintUnlockLayerForDelete = useCallback(() => {
1308
- showActionHint(PANEL_MESSAGES.nodeDeleteLayerLocked);
1496
+ showActionHint(messages.nodeDeleteLayerLocked);
1309
1497
  }, [showActionHint]);
1310
1498
  const hintLockedNodesInBatchDelete = useCallback(() => {
1311
- showActionHint(PANEL_MESSAGES.nodeBatchDeleteContainsLocked);
1499
+ showActionHint(messages.nodeBatchDeleteContainsLocked);
1312
1500
  }, [showActionHint]);
1313
1501
  const hintNodeNotFound = useCallback(() => {
1314
- showActionHint(PANEL_MESSAGES.nodeNotFound);
1502
+ showActionHint(messages.nodeNotFound);
1315
1503
  }, [showActionHint]);
1316
1504
  const hintUnlockNodeForMove = useCallback(() => {
1317
- showActionHint(PANEL_MESSAGES.nodeMoveLocked);
1505
+ showActionHint(messages.nodeMoveLocked);
1318
1506
  }, [showActionHint]);
1319
1507
  const hintUnlockSourceLayerForMove = useCallback(() => {
1320
- showActionHint(PANEL_MESSAGES.nodeMoveSourceLayerLocked);
1508
+ showActionHint(messages.nodeMoveSourceLayerLocked);
1321
1509
  }, [showActionHint]);
1322
1510
  const hintTargetLayerNotFound = useCallback(() => {
1323
- showActionHint(PANEL_MESSAGES.targetLayerNotFound);
1511
+ showActionHint(messages.targetLayerNotFound);
1324
1512
  }, [showActionHint]);
1325
1513
  const hintUnlockTargetLayerForMove = useCallback(() => {
1326
- showActionHint(PANEL_MESSAGES.nodeMoveTargetLayerLocked);
1514
+ showActionHint(messages.nodeMoveTargetLayerLocked);
1327
1515
  }, [showActionHint]);
1328
1516
  const getLayerDeleteBlockReason = useCallback(
1329
1517
  (layerId: string) => {
1330
1518
  const targetLayer = layerById.get(layerId);
1331
- if (!targetLayer) return "图层不存在";
1332
- if (!targetLayer.editable) return "默认图层不可删除";
1333
- if (targetLayer.locked) return "锁定图层不可删除";
1519
+ if (!targetLayer) return t("panel.messages.layerNotFound");
1520
+ if (!targetLayer.editable) return t("panel.layers.cannotDeleteDefault");
1521
+ if (targetLayer.locked) return t("panel.layers.cannotDeleteLocked");
1334
1522
  if (targetLayer.isMapping) return null;
1335
1523
  const hasBlockingRef = allElements.some((el) => {
1336
1524
  if (el.layerId === layerId) return false;
@@ -1338,7 +1526,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1338
1526
  if (el.refLayerId !== layerId) return false;
1339
1527
  return (el.refCopyMode ?? "shallow") !== "deep";
1340
1528
  });
1341
- if (hasBlockingRef) return "该图层仍被浅拷贝引用,请先删除引用节点或改为深拷贝";
1529
+ if (hasBlockingRef) return t("panel.messages.shallowRefBlockingDelete");
1342
1530
  return null;
1343
1531
  },
1344
1532
  [allElements, layerById]
@@ -1389,7 +1577,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1389
1577
  const parsed = JSON.parse(text) as State;
1390
1578
  const ok = importPanelData(parsed);
1391
1579
  if (!ok) {
1392
- window.alert("导入失败:文件格式不正确");
1580
+ window.alert(messages.importInvalidFormat);
1393
1581
  } else {
1394
1582
  setSelectedIds([]);
1395
1583
  }
@@ -1548,7 +1736,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1548
1736
  const previewFaviconLink = faviconHrefWithVersion
1549
1737
  ? `<link rel="icon" type="image/png" href="${faviconHrefWithVersion}" /><link rel="shortcut icon" type="image/png" href="${faviconHrefWithVersion}" />`
1550
1738
  : "";
1551
- const previewTitle = productName.trim() || "未命名产物";
1739
+ const previewTitle = productName.trim() || t("panel.defaults.unnamedProduct");
1552
1740
  const escapedPreviewTitle = previewTitle
1553
1741
  .replace(/&/g, "&amp;")
1554
1742
  .replace(/</g, "&lt;")
@@ -1670,7 +1858,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1670
1858
  }
1671
1859
  } catch (error) {
1672
1860
  toast({
1673
- title: error instanceof Error ? error.message : "打开预览失败",
1861
+ title: error instanceof Error ? error.message : t("panel.messages.openPreviewFailed"),
1674
1862
  });
1675
1863
  }
1676
1864
  })();
@@ -1760,59 +1948,70 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1760
1948
  <nav className="panel-font-root relative z-30 flex items-center gap-2 border-b border-border bg-background/95 px-2 text-foreground">
1761
1949
  <Menubar className="h-8 border-0 bg-transparent p-0 shadow-none">
1762
1950
  <MenubarMenu>
1763
- <MenubarTrigger className="px-2 py-1 text-xs font-normal">文件</MenubarTrigger>
1951
+ <MenubarTrigger className="px-2 py-1 text-xs font-normal">{t("panel.menubar.file")}</MenubarTrigger>
1764
1952
  <MenubarContent className="z-[10100]">
1765
- {/* <MenubarItem onClick={handlePreviewLayer}>预览</MenubarItem> */}
1766
- <MenubarItem onClick={handleExport}>导出</MenubarItem>
1767
- <MenubarItem onClick={() => importInputRef.current?.click()}>导入</MenubarItem>
1953
+ {/* <MenubarItem onClick={handlePreviewLayer}>{t("panel.workspace.previewDocTitle")}</MenubarItem> */}
1954
+ <MenubarItem onClick={handleExport}>{t("panel.menubar.export")}</MenubarItem>
1955
+ <MenubarItem onClick={() => importInputRef.current?.click()}>{t("panel.menubar.import")}</MenubarItem>
1768
1956
  </MenubarContent>
1769
1957
  </MenubarMenu>
1770
1958
  <MenubarMenu>
1771
- <MenubarTrigger className="px-2 py-1 text-xs font-normal">编辑</MenubarTrigger>
1959
+ <MenubarTrigger className="px-2 py-1 text-xs font-normal">{t("panel.menubar.edit")}</MenubarTrigger>
1772
1960
  <MenubarContent className="z-[10100]">
1773
- <MenubarItem disabled={!canUndo} onClick={undo}>撤销</MenubarItem>
1774
- <MenubarItem disabled={!canRedo} onClick={redo}>重做</MenubarItem>
1961
+ <MenubarItem disabled={!canUndo} onClick={undo}>{t("panel.menubar.undo")}</MenubarItem>
1962
+ <MenubarItem disabled={!canRedo} onClick={redo}>{t("panel.menubar.redo")}</MenubarItem>
1775
1963
  <MenubarSeparator />
1776
- <MenubarItem disabled={!hasUnlockedSelection} onClick={() => bringElementsForward(selectedIds)}>上移一层</MenubarItem>
1777
- <MenubarItem disabled={!hasUnlockedSelection} onClick={() => sendElementsBackward(selectedIds)}>下移一层</MenubarItem>
1778
- <MenubarItem disabled={!hasUnlockedSelection} onClick={() => bringElementsToFront(selectedIds)}>置顶</MenubarItem>
1779
- <MenubarItem disabled={!hasUnlockedSelection} onClick={() => sendElementsToBack(selectedIds)}>置底</MenubarItem>
1964
+ <MenubarItem disabled={!hasUnlockedSelection} onClick={() => bringElementsForward(selectedIds)}>{t("panel.menubar.bringForward")}</MenubarItem>
1965
+ <MenubarItem disabled={!hasUnlockedSelection} onClick={() => sendElementsBackward(selectedIds)}>{t("panel.menubar.sendBackward")}</MenubarItem>
1966
+ <MenubarItem disabled={!hasUnlockedSelection} onClick={() => bringElementsToFront(selectedIds)}>{t("panel.menubar.bringToFront")}</MenubarItem>
1967
+ <MenubarItem disabled={!hasUnlockedSelection} onClick={() => sendElementsToBack(selectedIds)}>{t("panel.menubar.sendToBack")}</MenubarItem>
1780
1968
  </MenubarContent>
1781
1969
  </MenubarMenu>
1782
1970
  <MenubarMenu>
1783
- <MenubarTrigger className="px-2 py-1 text-xs font-normal">视图</MenubarTrigger>
1971
+ <MenubarTrigger className="px-2 py-1 text-xs font-normal">{t("panel.menubar.view")}</MenubarTrigger>
1784
1972
  <MenubarContent className="z-[10100]">
1785
- <MenubarItem onClick={() => adjustUniformZoom((z) => z - 0.1)}>缩小</MenubarItem>
1786
- <MenubarItem onClick={() => adjustUniformZoom((z) => z + 0.1)}>放大</MenubarItem>
1973
+ <MenubarItem onClick={() => adjustUniformZoom((z) => z - 0.1)}>{t("panel.menubar.zoomOut")}</MenubarItem>
1974
+ <MenubarItem onClick={() => adjustUniformZoom((z) => z + 0.1)}>{t("panel.menubar.zoomIn")}</MenubarItem>
1787
1975
  <MenubarSeparator />
1788
1976
  <MenubarItem
1789
1977
  onClick={() => {
1790
1978
  applyTheme(!isDark);
1791
1979
  }}
1792
1980
  >
1793
- {isDark ? "切换到浅色" : "切换到深色"}
1981
+ {isDark ? t("panel.menubar.switchToLight") : t("panel.menubar.switchToDark")}
1794
1982
  </MenubarItem>
1795
1983
  </MenubarContent>
1796
1984
  </MenubarMenu>
1797
1985
  <MenubarMenu>
1798
- <MenubarTrigger className="px-2 py-1 text-xs font-normal">蓝图</MenubarTrigger>
1986
+ <MenubarTrigger className="px-2 py-1 text-xs font-normal">{t("panel.menubar.blueprint")}</MenubarTrigger>
1799
1987
  <MenubarContent className="z-[10100]">
1800
- <MenubarItem onClick={openBlueprintExportDialog}>导出</MenubarItem>
1988
+ <MenubarItem onClick={openBlueprintExportDialog}>{t("panel.menubar.export")}</MenubarItem>
1801
1989
  <MenubarItem onClick={() => blueprintImportInputRef.current?.click()}>
1802
- 导入
1990
+ {t("panel.menubar.import")}
1803
1991
  </MenubarItem>
1804
1992
  </MenubarContent>
1805
1993
  </MenubarMenu>
1806
1994
  <MenubarMenu>
1807
- <MenubarTrigger className="px-2 py-1 text-xs font-normal">设置</MenubarTrigger>
1995
+ <MenubarTrigger className="px-2 py-1 text-xs font-normal">{t("panel.menubar.settings")}</MenubarTrigger>
1808
1996
  <MenubarContent className="z-[10100]">
1809
1997
  <MenubarRadioGroup
1810
1998
  value={panelFontSize}
1811
1999
  onValueChange={(value) => setPanelFontSize(value as "sm" | "md" | "lg")}
1812
2000
  >
1813
- <MenubarRadioItem value="sm">字体:小</MenubarRadioItem>
1814
- <MenubarRadioItem value="md">字体:中</MenubarRadioItem>
1815
- <MenubarRadioItem value="lg">字体:大</MenubarRadioItem>
2001
+ <MenubarRadioItem value="sm">{t("panel.menubar.fontSmall")}</MenubarRadioItem>
2002
+ <MenubarRadioItem value="md">{t("panel.menubar.fontMedium")}</MenubarRadioItem>
2003
+ <MenubarRadioItem value="lg">{t("panel.menubar.fontLarge")}</MenubarRadioItem>
2004
+ </MenubarRadioGroup>
2005
+ <MenubarSeparator />
2006
+ <div className="px-2 py-1.5 text-[11px] text-muted-foreground">
2007
+ {t("panel.theme.language")}
2008
+ </div>
2009
+ <MenubarRadioGroup
2010
+ value={locale}
2011
+ onValueChange={(value) => setLocale(value as "zh-CN" | "en-US")}
2012
+ >
2013
+ <MenubarRadioItem value="zh-CN">{t("panel.theme.zhCN")}</MenubarRadioItem>
2014
+ <MenubarRadioItem value="en-US">{t("panel.theme.enUS")}</MenubarRadioItem>
1816
2015
  </MenubarRadioGroup>
1817
2016
  </MenubarContent>
1818
2017
  </MenubarMenu>
@@ -1829,6 +2028,15 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1829
2028
  onPreviewProject={openOnlinePreviewForProject}
1830
2029
  />
1831
2030
  <div className="flex-1" />
2031
+ <Button
2032
+ type="button"
2033
+ variant={assistantOpen ? "secondary" : "outline"}
2034
+ size="sm"
2035
+ className="h-7 shrink-0 px-2 text-xs"
2036
+ onClick={() => setAssistantOpen((v) => !v)}
2037
+ >
2038
+ {assistantOpen ? t("panel.ai.hide") : t("panel.ai.open")}
2039
+ </Button>
1832
2040
  </nav>
1833
2041
  <ResizablePanelGroup direction="horizontal" className="min-h-0 w-full flex-1">
1834
2042
  <ResizablePanel defaultSize={15} minSize={15}>
@@ -1930,10 +2138,10 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1930
2138
  <Input
1931
2139
  value={productName}
1932
2140
  onChange={(e) => setProductName(e.target.value)}
1933
- placeholder="请输入当前构建产物名称"
2141
+ placeholder={t("panel.menubar.productNamePlaceholder")}
1934
2142
  className="h-7 w-[220px] text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none"
1935
2143
  />
1936
- <span className="text-[11px] text-muted-foreground">产物名称</span>
2144
+ <span className="text-[11px] text-muted-foreground">{t("panel.menubar.productName")}</span>
1937
2145
  <TooltipProvider delayDuration={150}>
1938
2146
  <Tooltip>
1939
2147
  <TooltipTrigger asChild>
@@ -1941,12 +2149,12 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1941
2149
  type="button"
1942
2150
  onClick={() => titleIconInputRef.current?.click()}
1943
2151
  className="rounded border border-border p-1 hover:bg-accent"
1944
- aria-label="上传 title 图标"
2152
+ aria-label={t("panel.menubar.uploadTitleIcon")}
1945
2153
  >
1946
2154
  <IconImage />
1947
2155
  </button>
1948
2156
  </TooltipTrigger>
1949
- <TooltipContent className="z-[10000]">上传 title 图标</TooltipContent>
2157
+ <TooltipContent className="z-[10000]">{t("panel.menubar.uploadTitleIcon")}</TooltipContent>
1950
2158
  </Tooltip>
1951
2159
  {titleIconDataUrl ? (
1952
2160
  <Tooltip>
@@ -1955,12 +2163,12 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1955
2163
  type="button"
1956
2164
  onClick={() => setTitleIconDataUrl("")}
1957
2165
  className="rounded border border-border p-1 hover:bg-accent"
1958
- aria-label="清除 title 图标"
2166
+ aria-label={t("panel.menubar.clearTitleIcon")}
1959
2167
  >
1960
2168
  <IconClose />
1961
2169
  </button>
1962
2170
  </TooltipTrigger>
1963
- <TooltipContent className="z-[10000]">清除 title 图标</TooltipContent>
2171
+ <TooltipContent className="z-[10000]">{t("panel.menubar.clearTitleIcon")}</TooltipContent>
1964
2172
  </Tooltip>
1965
2173
  ) : null}
1966
2174
  {titleIconDataUrl ? (
@@ -1970,17 +2178,17 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1970
2178
  type="button"
1971
2179
  onClick={() => setTitleIconPreviewOpen(true)}
1972
2180
  className="h-7 shrink-0 flex min-w-[92px] items-center gap-1.5 rounded border border-border bg-muted/30 px-2 py-1 hover:bg-accent/40"
1973
- aria-label="查看 title 图标大图"
2181
+ aria-label={t("panel.menubar.viewTitleIcon")}
1974
2182
  >
1975
2183
  <img
1976
2184
  src={titleIconDataUrl}
1977
- alt="title 图标缩略图"
2185
+ alt={t("panel.menubar.titleIconThumbAlt")}
1978
2186
  className="h-5 w-5 shrink-0 rounded border border-border/60 object-cover"
1979
2187
  />
1980
2188
  <span className="text-[10px] text-muted-foreground">Title Icon</span>
1981
2189
  </button>
1982
2190
  </TooltipTrigger>
1983
- <TooltipContent className="z-[10000]">点击查看 title 图标大图</TooltipContent>
2191
+ <TooltipContent className="z-[10000]">{t("panel.menubar.viewTitleIconHint")}</TooltipContent>
1984
2192
  </Tooltip>
1985
2193
  ) : null}
1986
2194
  <Tooltip>
@@ -1990,12 +2198,12 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
1990
2198
  onClick={undo}
1991
2199
  disabled={!canUndo}
1992
2200
  className="rounded border border-border p-1 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-40"
1993
- aria-label="撤销"
2201
+ aria-label={t("panel.menubar.undo")}
1994
2202
  >
1995
2203
  <IconUndo />
1996
2204
  </button>
1997
2205
  </TooltipTrigger>
1998
- <TooltipContent className="z-[10000]">撤销(Cmd/Ctrl + Z)</TooltipContent>
2206
+ <TooltipContent className="z-[10000]">{t("panel.menubar.undoShortcut")}</TooltipContent>
1999
2207
  </Tooltip>
2000
2208
  <Tooltip>
2001
2209
  <TooltipTrigger asChild>
@@ -2004,12 +2212,12 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2004
2212
  onClick={redo}
2005
2213
  disabled={!canRedo}
2006
2214
  className="rounded border border-border p-1 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-40"
2007
- aria-label="重做"
2215
+ aria-label={t("panel.menubar.redo")}
2008
2216
  >
2009
2217
  <IconRedo />
2010
2218
  </button>
2011
2219
  </TooltipTrigger>
2012
- <TooltipContent className="z-[10000]">重做(Cmd/Ctrl + Shift + Z)</TooltipContent>
2220
+ <TooltipContent className="z-[10000]">{t("panel.menubar.redoShortcut")}</TooltipContent>
2013
2221
  </Tooltip>
2014
2222
  <Tooltip>
2015
2223
  <TooltipTrigger asChild>
@@ -2021,7 +2229,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2021
2229
  "rounded border border-border p-1 hover:bg-accent",
2022
2230
  isHistoryPanelExpanded ? "bg-accent/60" : "",
2023
2231
  ].join(" ")}
2024
- aria-label={isHistoryPanelExpanded ? "收起操作历史" : "展开操作历史"}
2232
+ aria-label={isHistoryPanelExpanded ? t("panel.history.collapse") : t("panel.history.expand")}
2025
2233
  >
2026
2234
  <IconHistory />
2027
2235
  </button>
@@ -2031,7 +2239,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2031
2239
  style={{ zIndex: PANEL_Z_INDEX.historyPopover }}
2032
2240
  >
2033
2241
  <div className="mb-1 flex items-center justify-between">
2034
- <span className="text-[11px] font-semibold text-muted-foreground">操作历史</span>
2242
+ <span className="text-[11px] font-semibold text-muted-foreground">{t("panel.history.title")}</span>
2035
2243
  <span className="text-[11px] text-muted-foreground">
2036
2244
  {history.length > 0 ? `${historyCursor + 1}/${history.length}` : "0/0"}
2037
2245
  </span>
@@ -2039,7 +2247,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2039
2247
  <Input
2040
2248
  value={historyKeyword}
2041
2249
  onChange={(e) => setHistoryKeyword(e.target.value)}
2042
- placeholder="搜索历史,如:删除、图层、缩放..."
2250
+ placeholder={t("panel.history.searchPlaceholder")}
2043
2251
  className="mb-2 h-7 text-xs"
2044
2252
  />
2045
2253
  <div
@@ -2050,9 +2258,9 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2050
2258
  <EmptyIcon className="h-8 w-8">
2051
2259
  <IconHistory />
2052
2260
  </EmptyIcon>
2053
- <EmptyTitle className="text-xs">暂无操作历史</EmptyTitle>
2261
+ <EmptyTitle className="text-xs">{t("panel.history.emptyTitle")}</EmptyTitle>
2054
2262
  <EmptyDescription className="text-[11px]">
2055
- 进行一次操作后,这里会展示历史记录。
2263
+ {t("panel.history.emptyDesc")}
2056
2264
  </EmptyDescription>
2057
2265
  </Empty>
2058
2266
  ) : (
@@ -2083,7 +2291,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2083
2291
  <div className="flex items-center justify-between gap-2">
2084
2292
  <span className="truncate">{item.label}</span>
2085
2293
  <span className="shrink-0 text-[10px] text-muted-foreground">
2086
- {formatRelativeTime(item.timestamp, historyNow)}
2294
+ {formatRelativeTime(item.timestamp, historyNow, t)}
2087
2295
  </span>
2088
2296
  </div>
2089
2297
  </button>
@@ -2112,9 +2320,9 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2112
2320
  <path d="m20 20-3.5-3.5" />
2113
2321
  </svg>
2114
2322
  </EmptyIcon>
2115
- <EmptyTitle className="text-xs">未匹配到历史项</EmptyTitle>
2323
+ <EmptyTitle className="text-xs">{t("panel.history.noMatchTitle")}</EmptyTitle>
2116
2324
  <EmptyDescription className="text-[11px]">
2117
- 尝试更换关键词,例如删除、图层、缩放。
2325
+ {t("panel.history.noMatchDesc")}
2118
2326
  </EmptyDescription>
2119
2327
  </Empty>
2120
2328
  ) : null}
@@ -2124,7 +2332,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2124
2332
  </div>
2125
2333
  </TooltipTrigger>
2126
2334
  <TooltipContent className="z-[10000]">
2127
- {isHistoryPanelExpanded ? "收起操作历史" : "展开操作历史"}
2335
+ {isHistoryPanelExpanded ? t("panel.history.collapse") : t("panel.history.expand")}
2128
2336
  </TooltipContent>
2129
2337
  </Tooltip>
2130
2338
  </TooltipProvider>
@@ -2134,7 +2342,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2134
2342
  type="button"
2135
2343
  disabled={!hasUnlockedSelection}
2136
2344
  className="rounded border border-border p-1 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-40"
2137
- aria-label="节点层级操作"
2345
+ aria-label={t("panel.menubar.nodeZOrderAria")}
2138
2346
  >
2139
2347
  <IconLayers />
2140
2348
  </button>
@@ -2145,28 +2353,28 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2145
2353
  onClick={() => bringElementsForward(selectedIds)}
2146
2354
  >
2147
2355
  <span className="mr-2 inline-flex"><IconForward /></span>
2148
- 上移一层
2356
+ {t("panel.menubar.bringForward")}
2149
2357
  </DropdownMenuItem>
2150
2358
  <DropdownMenuItem
2151
2359
  disabled={!hasUnlockedSelection}
2152
2360
  onClick={() => sendElementsBackward(selectedIds)}
2153
2361
  >
2154
2362
  <span className="mr-2 inline-flex"><IconBackward /></span>
2155
- 下移一层
2363
+ {t("panel.menubar.sendBackward")}
2156
2364
  </DropdownMenuItem>
2157
2365
  <DropdownMenuItem
2158
2366
  disabled={!hasUnlockedSelection}
2159
2367
  onClick={() => bringElementsToFront(selectedIds)}
2160
2368
  >
2161
2369
  <span className="mr-2 inline-flex"><IconBringFront /></span>
2162
- 置顶
2370
+ {t("panel.menubar.bringToFront")}
2163
2371
  </DropdownMenuItem>
2164
2372
  <DropdownMenuItem
2165
2373
  disabled={!hasUnlockedSelection}
2166
2374
  onClick={() => sendElementsToBack(selectedIds)}
2167
2375
  >
2168
2376
  <span className="mr-2 inline-flex"><IconSendBack /></span>
2169
- 置底
2377
+ {t("panel.menubar.sendToBack")}
2170
2378
  </DropdownMenuItem>
2171
2379
  </DropdownMenuContent>
2172
2380
  </DropdownMenu>
@@ -2175,11 +2383,11 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2175
2383
  data-blueprint-toggle
2176
2384
  className="flex shrink-0 items-center gap-2 rounded-md border border-border bg-muted/30 px-2 py-1"
2177
2385
  >
2178
- <span className="text-[11px] text-muted-foreground">蓝图</span>
2386
+ <span className="text-[11px] text-muted-foreground">{t("panel.menubar.blueprint")}</span>
2179
2387
  <Switch
2180
2388
  checked={blueprintOpen}
2181
2389
  onCheckedChange={setBlueprintOpen}
2182
- aria-label="显示蓝图面板"
2390
+ aria-label={t("panel.menubar.showBlueprintPanel")}
2183
2391
  />
2184
2392
  </div>
2185
2393
  <TooltipProvider delayDuration={150}>
@@ -2189,7 +2397,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2189
2397
  <Switch
2190
2398
  checked={isDark}
2191
2399
  onCheckedChange={applyTheme}
2192
- aria-label="切换主题"
2400
+ aria-label={t("panel.menubar.toggleTheme")}
2193
2401
  className="data-[state=checked]:bg-primary/80 data-[state=unchecked]:bg-secondary"
2194
2402
  />
2195
2403
  <div
@@ -2223,7 +2431,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2223
2431
  </div>
2224
2432
  </div>
2225
2433
  </TooltipTrigger>
2226
- <TooltipContent className="z-[10000]">切换深色/浅色主题</TooltipContent>
2434
+ <TooltipContent className="z-[10000]">{t("panel.menubar.toggleThemeHint")}</TooltipContent>
2227
2435
  </Tooltip>
2228
2436
  <Tooltip>
2229
2437
  <TooltipTrigger asChild>
@@ -2235,7 +2443,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2235
2443
  -
2236
2444
  </button>
2237
2445
  </TooltipTrigger>
2238
- <TooltipContent className="z-[10000]">缩小画布</TooltipContent>
2446
+ <TooltipContent className="z-[10000]">{t("panel.menubar.zoomOutCanvas")}</TooltipContent>
2239
2447
  </Tooltip>
2240
2448
  </TooltipProvider>
2241
2449
  <span className="w-20 text-center text-xs">
@@ -2254,7 +2462,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2254
2462
  +
2255
2463
  </button>
2256
2464
  </TooltipTrigger>
2257
- <TooltipContent className="z-[10000]">放大画布</TooltipContent>
2465
+ <TooltipContent className="z-[10000]">{t("panel.menubar.zoomInCanvas")}</TooltipContent>
2258
2466
  </Tooltip>
2259
2467
  </TooltipProvider>
2260
2468
  </div>
@@ -2270,7 +2478,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2270
2478
  try {
2271
2479
  await handleImportFile(file);
2272
2480
  } catch {
2273
- window.alert("导入失败:JSON 解析错误");
2481
+ window.alert(messages.importJsonParseError);
2274
2482
  }
2275
2483
  }}
2276
2484
  />
@@ -2286,7 +2494,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2286
2494
  try {
2287
2495
  await handleBlueprintImportFile(file);
2288
2496
  } catch {
2289
- window.alert("蓝图导入失败:文件格式不正确");
2497
+ window.alert(messages.blueprintImportInvalid);
2290
2498
  }
2291
2499
  }}
2292
2500
  />
@@ -2303,7 +2511,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2303
2511
  const normalizedDataUrl = await normalizeTitleIconFile(file);
2304
2512
  setTitleIconDataUrl(normalizedDataUrl);
2305
2513
  } catch {
2306
- window.alert("图标读取失败,请重试");
2514
+ window.alert(messages.iconReadFailed);
2307
2515
  }
2308
2516
  }}
2309
2517
  />
@@ -2369,6 +2577,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2369
2577
  const nodeIdFromElement = nodeEl?.dataset.elementId ?? null;
2370
2578
  const onMoveable =
2371
2579
  !!target?.closest(".moveable-control-box") ||
2580
+ !!target?.closest(".moveable-group") ||
2372
2581
  !!target?.closest(".moveable-line") ||
2373
2582
  !!target?.closest(".moveable-control") ||
2374
2583
  !!target?.closest(".moveable-direction");
@@ -2394,8 +2603,9 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2394
2603
  ref={syncScrollRef}
2395
2604
  zoom={zoom}
2396
2605
  onZoomChange={setZoom}
2397
- onScrollChange={setScroll}
2606
+ onScrollChange={onViewportScrollChange}
2398
2607
  canvasRef={syncCanvasRef}
2608
+ viewportSyncRef={viewportSyncRef}
2399
2609
  viewportOverlay={
2400
2610
  <MoveableLayer
2401
2611
  zoomX={zoom.x}
@@ -2405,8 +2615,8 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2405
2615
  selectedTargets={selectedTargets}
2406
2616
  elementsById={byId}
2407
2617
  updateElement={updateElement}
2408
- refreshToken={`${historyCursor}|${scroll.left}|${scroll.top}|${zoom.x}|${zoom.y}`}
2409
- viewportScroll={scroll}
2618
+ refreshToken={historyCursor}
2619
+ viewportSyncRef={viewportSyncRef}
2410
2620
  />
2411
2621
  }
2412
2622
  onCanvasMouseDownCapture={(e) => {
@@ -2417,7 +2627,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2417
2627
  }}
2418
2628
  onDropMaterial={({ materialId, x, y }) => {
2419
2629
  if (activeLayer?.locked) {
2420
- showActionHint(PANEL_MESSAGES.activeLayerLockedCannotAdd);
2630
+ showActionHint(messages.activeLayerLockedCannotAdd);
2421
2631
  return;
2422
2632
  }
2423
2633
  addElementFromMaterial(materialId, x, y);
@@ -2431,6 +2641,9 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2431
2641
  onSelectIds={selectViewElements}
2432
2642
  updateElement={updateElement}
2433
2643
  layerLocked={Boolean(activeLayer?.locked)}
2644
+ onTableCellAction={(payload) => {
2645
+ void triggerBlueprintNode(payload.blueprintNodeId, payload);
2646
+ }}
2434
2647
  />
2435
2648
 
2436
2649
  <SelectLayer
@@ -2467,13 +2680,13 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2467
2680
  if (!node) return;
2468
2681
  const layer = layerById.get(node.layerId);
2469
2682
  if (layer?.locked) {
2470
- showActionHint(PANEL_MESSAGES.nodeLayerLockedCannotCopy);
2683
+ showActionHint(messages.nodeLayerLockedCannotCopy);
2471
2684
  return;
2472
2685
  }
2473
2686
  setCopiedNodeId(contextMenuNodeId);
2474
2687
  }}
2475
2688
  >
2476
- 复制节点
2689
+ {t("panel.menubar.duplicateNode")}
2477
2690
  </DropdownMenuItem>
2478
2691
  {contextMenuNodeId &&
2479
2692
  byId.get(contextMenuNodeId)?.mappingSourceNodeId ? (
@@ -2485,7 +2698,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2485
2698
  if (!sourceNodeId) return;
2486
2699
  const sourceNode = byId.get(sourceNodeId);
2487
2700
  if (!sourceNode) {
2488
- showActionHint(PANEL_MESSAGES.sourceNodeNotFound);
2701
+ showActionHint(messages.sourceNodeNotFound);
2489
2702
  return;
2490
2703
  }
2491
2704
  if (activeLayerId !== sourceNode.layerId) {
@@ -2495,7 +2708,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2495
2708
  setContextMenuNodeId(null);
2496
2709
  }}
2497
2710
  >
2498
- 定位到源节点
2711
+ {t("panel.menubar.locateSourceNode")}
2499
2712
  </DropdownMenuItem>
2500
2713
  ) : null}
2501
2714
  <DropdownMenuItem
@@ -2512,7 +2725,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2512
2725
  setContextMenuNodeId(null);
2513
2726
  }}
2514
2727
  >
2515
- 在映射图层打开
2728
+ {t("panel.menubar.openInMappingLayer")}
2516
2729
  </DropdownMenuItem>
2517
2730
  <DropdownMenuItem
2518
2731
  onClick={() => {
@@ -2543,7 +2756,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2543
2756
  return !!layerById.get(el.layerId)?.locked;
2544
2757
  });
2545
2758
  if (hasLockedLayerNode) {
2546
- showActionHint(PANEL_MESSAGES.selectionContainsLockedLayerNode);
2759
+ showActionHint(messages.selectionContainsLockedLayerNode);
2547
2760
  return;
2548
2761
  }
2549
2762
  deleteElements(selectedIds);
@@ -2567,8 +2780,8 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2567
2780
  }}
2568
2781
  >
2569
2782
  {contextMenuNodeId && selectedIds.includes(contextMenuNodeId) && selectedIds.length > 1
2570
- ? `删除选中节点(${selectedIds.length})`
2571
- : "删除节点"}
2783
+ ? t("panel.menubar.deleteSelectedNodes", { count: selectedIds.length })
2784
+ : t("panel.menubar.deleteNode")}
2572
2785
  </DropdownMenuItem>
2573
2786
  </>
2574
2787
  ) : (
@@ -2579,7 +2792,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2579
2792
  if (!copiedNodeId) return;
2580
2793
  const sourceNode = byId.get(copiedNodeId);
2581
2794
  if (sourceNode && layerById.get(sourceNode.layerId)?.locked) {
2582
- showActionHint(PANEL_MESSAGES.sourceLayerLockedCannotPaste);
2795
+ showActionHint(messages.sourceLayerLockedCannotPaste);
2583
2796
  return;
2584
2797
  }
2585
2798
  const canvasEl = canvasRef.current;
@@ -2599,10 +2812,10 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2599
2812
  const centerX = rect.left + rect.width / 2;
2600
2813
  const centerY = rect.top + rect.height / 2;
2601
2814
  targetX =
2602
- (centerX - rect.left + scroll.left) /
2815
+ (centerX - rect.left + scrollPosRef.current.left) /
2603
2816
  Math.max(0.0001, zoom.x);
2604
2817
  targetY =
2605
- (centerY - rect.top + scroll.top) /
2818
+ (centerY - rect.top + scrollPosRef.current.top) /
2606
2819
  Math.max(0.0001, zoom.y);
2607
2820
  }
2608
2821
  duplicateElement(
@@ -2613,7 +2826,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2613
2826
  );
2614
2827
  }}
2615
2828
  >
2616
- 粘贴节点
2829
+ {t("panel.menubar.pasteNode")}
2617
2830
  </DropdownMenuItem>
2618
2831
  <DropdownMenuItem
2619
2832
  disabled={elements.length === 0}
@@ -2621,7 +2834,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2621
2834
  selectViewElements(elements.map((el) => el.id));
2622
2835
  }}
2623
2836
  >
2624
- 全选当前图层
2837
+ {t("panel.menubar.selectAllInLayer")}
2625
2838
  </DropdownMenuItem>
2626
2839
  </>
2627
2840
  )}
@@ -2635,20 +2848,20 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2635
2848
  <TooltipProvider delayDuration={120}>
2636
2849
  <Tabs value={activeLayerId} onValueChange={setActiveLayer}>
2637
2850
  <div className="mb-1 flex items-center gap-2">
2638
- <div className="text-[11px] font-semibold text-muted-foreground">图层</div>
2851
+ <div className="text-[11px] font-semibold text-muted-foreground">{t("panel.layers.title")}</div>
2639
2852
  <Tooltip>
2640
2853
  <TooltipTrigger asChild>
2641
2854
  <button
2642
2855
  type="button"
2643
2856
  onClick={() => setIsLayerPanelExpanded((v) => !v)}
2644
- aria-label={isLayerPanelExpanded ? "收起图层输入区" : "展开图层输入区"}
2857
+ aria-label={isLayerPanelExpanded ? t("panel.layers.collapseInputAria") : t("panel.layers.expandInputAria")}
2645
2858
  className="rounded border border-border p-1 hover:bg-accent"
2646
2859
  >
2647
2860
  <IconChevron expanded={isLayerPanelExpanded} />
2648
2861
  </button>
2649
2862
  </TooltipTrigger>
2650
2863
  <TooltipContent className="z-[10000]">
2651
- {isLayerPanelExpanded ? "收起输入区" : "展开输入区"}
2864
+ {isLayerPanelExpanded ? t("panel.layers.collapseInput") : t("panel.layers.expandInput")}
2652
2865
  </TooltipContent>
2653
2866
  </Tooltip>
2654
2867
  <Tooltip>
@@ -2656,13 +2869,13 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2656
2869
  <button
2657
2870
  type="button"
2658
2871
  onClick={addLayer}
2659
- aria-label="新增图层"
2872
+ aria-label={t("panel.layers.add")}
2660
2873
  className="rounded border border-border p-1 hover:bg-accent"
2661
2874
  >
2662
2875
  <IconPlus />
2663
2876
  </button>
2664
2877
  </TooltipTrigger>
2665
- <TooltipContent className="z-[10000]">新增图层</TooltipContent>
2878
+ <TooltipContent className="z-[10000]">{t("panel.layers.add")}</TooltipContent>
2666
2879
  </Tooltip>
2667
2880
  <Tooltip>
2668
2881
  <TooltipTrigger asChild>
@@ -2672,7 +2885,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2672
2885
  if (!canMergeLayers) return;
2673
2886
  setIsMergingLayers(true);
2674
2887
  }}
2675
- aria-label="合并图层"
2888
+ aria-label={t("panel.layers.merge")}
2676
2889
  disabled={!canMergeLayers}
2677
2890
  className="rounded border border-border p-1 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-40"
2678
2891
  >
@@ -2680,7 +2893,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2680
2893
  </button>
2681
2894
  </TooltipTrigger>
2682
2895
  <TooltipContent className="z-[10000]">
2683
- {canMergeLayers ? "合并图层" : "至少勾选 2 个图层后可合并"}
2896
+ {canMergeLayers ? t("panel.layers.merge") : t("panel.layers.mergeNeedTwo")}
2684
2897
  </TooltipContent>
2685
2898
  </Tooltip>
2686
2899
  </div>
@@ -2697,12 +2910,12 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2697
2910
  <span className="truncate">{layer.name}</span>
2698
2911
  {layer.isMapping ? (
2699
2912
  <span className="rounded border border-primary/40 bg-primary/10 px-1 text-[10px] text-primary">
2700
- 映射
2913
+ {t("panel.layers.mapping")}
2701
2914
  </span>
2702
2915
  ) : null}
2703
2916
  {layer.isPrimary ? (
2704
2917
  <span className="rounded border border-emerald-500/40 bg-emerald-500/10 px-1 text-[10px] text-emerald-600">
2705
- 主
2918
+ {t("panel.layers.primary")}
2706
2919
  </span>
2707
2920
  ) : null}
2708
2921
  {layer.locked ? <IconLock locked /> : null}
@@ -2732,7 +2945,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2732
2945
  }
2733
2946
  }}
2734
2947
  className="h-7 min-w-0 flex-1"
2735
- placeholder="请输入图层名称"
2948
+ placeholder={t("panel.layers.namePlaceholder")}
2736
2949
  autoFocus
2737
2950
  />
2738
2951
  ) : (
@@ -2740,12 +2953,12 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2740
2953
  )}
2741
2954
  {activeLayer.isMapping ? (
2742
2955
  <span className="rounded border border-primary/40 bg-primary/10 px-1 text-[10px] text-primary">
2743
- 映射图层
2956
+ {t("panel.layers.mappingLayer")}
2744
2957
  </span>
2745
2958
  ) : null}
2746
2959
  {activeLayer.isPrimary ? (
2747
2960
  <span className="rounded border border-emerald-500/40 bg-emerald-500/10 px-1 text-[10px] text-emerald-600">
2748
- 主图层
2961
+ {t("panel.layers.primaryLayer")}
2749
2962
  </span>
2750
2963
  ) : null}
2751
2964
  <div className="flex-1" />
@@ -2755,7 +2968,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2755
2968
  type="button"
2756
2969
  onClick={() => setPrimaryLayer(activeLayer.id)}
2757
2970
  disabled={activeLayer.isPrimary}
2758
- aria-label={activeLayer.isPrimary ? "当前主图层" : "设为主图层"}
2971
+ aria-label={activeLayer.isPrimary ? t("panel.layers.isPrimary") : t("panel.layers.setPrimary")}
2759
2972
  className={[
2760
2973
  "rounded border px-2 py-1 text-[11px] disabled:cursor-not-allowed disabled:opacity-60",
2761
2974
  activeLayer.isPrimary
@@ -2763,11 +2976,11 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2763
2976
  : "border-border hover:bg-accent",
2764
2977
  ].join(" ")}
2765
2978
  >
2766
- {activeLayer.isPrimary ? "当前主图层" : "设为主图层"}
2979
+ {activeLayer.isPrimary ? t("panel.layers.isPrimary") : t("panel.layers.setPrimary")}
2767
2980
  </button>
2768
2981
  </TooltipTrigger>
2769
2982
  <TooltipContent className="z-[10000]">
2770
- {activeLayer.isPrimary ? "当前主图层(预览使用)" : "设为主图层(预览使用)"}
2983
+ {activeLayer.isPrimary ? t("panel.layers.isPrimaryHint") : t("panel.layers.setPrimaryHint")}
2771
2984
  </TooltipContent>
2772
2985
  </Tooltip>
2773
2986
  <Tooltip>
@@ -2776,14 +2989,14 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2776
2989
  type="button"
2777
2990
  onClick={() => toggleLayerLock(activeLayer.id)}
2778
2991
  disabled={!activeLayer.editable}
2779
- aria-label={activeLayer.locked ? "解锁图层" : "锁定图层"}
2992
+ aria-label={activeLayer.locked ? t("panel.layers.unlock") : t("panel.layers.lock")}
2780
2993
  className="rounded border border-border p-1 disabled:opacity-40"
2781
2994
  >
2782
2995
  <IconLock locked={activeLayer.locked} />
2783
2996
  </button>
2784
2997
  </TooltipTrigger>
2785
2998
  <TooltipContent className="z-[10000]">
2786
- {activeLayer.locked ? "解锁图层" : "锁定图层"}
2999
+ {activeLayer.locked ? t("panel.layers.unlock") : t("panel.layers.lock")}
2787
3000
  </TooltipContent>
2788
3001
  </Tooltip>
2789
3002
  <Tooltip>
@@ -2796,7 +3009,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2796
3009
  setEditingLayerId(null);
2797
3010
  setEditingLayerName("");
2798
3011
  }}
2799
- aria-label="保存图层名称"
3012
+ aria-label={t("panel.layers.saveName")}
2800
3013
  className="rounded border border-border p-1 hover:bg-accent"
2801
3014
  >
2802
3015
  <IconCheck />
@@ -2809,7 +3022,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2809
3022
  setEditingLayerName(activeLayer.name);
2810
3023
  }}
2811
3024
  disabled={!activeLayer.editable}
2812
- aria-label="重命名图层"
3025
+ aria-label={t("panel.layers.rename")}
2813
3026
  className="rounded border border-border p-1 disabled:opacity-40"
2814
3027
  >
2815
3028
  <IconEdit />
@@ -2817,7 +3030,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2817
3030
  )}
2818
3031
  </TooltipTrigger>
2819
3032
  <TooltipContent className="z-[10000]">
2820
- {editingLayerId === activeLayer.id ? "保存图层名称" : "重命名图层"}
3033
+ {editingLayerId === activeLayer.id ? t("panel.layers.saveName") : t("panel.layers.rename")}
2821
3034
  </TooltipContent>
2822
3035
  </Tooltip>
2823
3036
  {editingLayerId === activeLayer.id ? (
@@ -2829,13 +3042,13 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2829
3042
  setEditingLayerId(null);
2830
3043
  setEditingLayerName("");
2831
3044
  }}
2832
- aria-label="取消编辑图层名称"
3045
+ aria-label={t("panel.layers.cancelEditName")}
2833
3046
  className="rounded border border-border p-1 hover:bg-accent"
2834
3047
  >
2835
3048
  <IconClose />
2836
3049
  </button>
2837
3050
  </TooltipTrigger>
2838
- <TooltipContent className="z-[10000]">取消编辑</TooltipContent>
3051
+ <TooltipContent className="z-[10000]">{t("panel.layers.cancelEdit")}</TooltipContent>
2839
3052
  </Tooltip>
2840
3053
  ) : null}
2841
3054
  <Tooltip>
@@ -2856,10 +3069,10 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2856
3069
  disabled={!activeLayer.editable || activeLayer.locked}
2857
3070
  aria-label={
2858
3071
  !activeLayer.editable
2859
- ? "默认图层不可删除"
3072
+ ? t("panel.layers.cannotDeleteDefault")
2860
3073
  : activeLayer.locked
2861
- ? "锁定图层不可删除"
2862
- : "删除图层"
3074
+ ? t("panel.layers.cannotDeleteLocked")
3075
+ : t("panel.layers.delete")
2863
3076
  }
2864
3077
  className="rounded border border-border p-1 disabled:opacity-40"
2865
3078
  >
@@ -2868,10 +3081,10 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2868
3081
  </TooltipTrigger>
2869
3082
  <TooltipContent className="z-[10000]">
2870
3083
  {!activeLayer.editable
2871
- ? "默认图层不可删除"
3084
+ ? t("panel.layers.cannotDeleteDefault")
2872
3085
  : activeLayer.locked
2873
- ? "锁定图层不可删除"
2874
- : "删除图层"}
3086
+ ? t("panel.layers.cannotDeleteLocked")
3087
+ : t("panel.layers.delete")}
2875
3088
  </TooltipContent>
2876
3089
  </Tooltip>
2877
3090
  </div>
@@ -2879,7 +3092,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2879
3092
 
2880
3093
  {isLayerPanelExpanded ? (
2881
3094
  <div className="rounded border border-border bg-card px-2 py-1.5 text-xs">
2882
- <div className="mb-1 text-muted-foreground">选择合并图层</div>
3095
+ <div className="mb-1 text-muted-foreground">{t("panel.layers.selectMergeLayers")}</div>
2883
3096
  <div className="flex flex-wrap gap-2">
2884
3097
  {layers.map((layer) => (
2885
3098
  <label key={layer.id} className="flex items-center gap-1.5">
@@ -2891,10 +3104,10 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2891
3104
  />
2892
3105
  <span
2893
3106
  className="max-w-[140px] truncate"
2894
- title={layer.isMapping ? "映射图层不允许合并" : layer.name}
3107
+ title={layer.isMapping ? t("panel.layers.mappingCannotMerge") : layer.name}
2895
3108
  >
2896
3109
  {layer.name}
2897
- {layer.isMapping ? "(不可合并)" : ""}
3110
+ {layer.isMapping ? t("panel.layers.cannotMergeSuffix") : ""}
2898
3111
  </span>
2899
3112
  </label>
2900
3113
  ))}
@@ -2906,12 +3119,12 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2906
3119
 
2907
3120
  {isLayerPanelExpanded && isMergingLayers ? (
2908
3121
  <div className="mt-2 flex items-center gap-2 rounded border border-border bg-card px-2 py-1.5 text-xs">
2909
- <span className="text-muted-foreground">合并后图层名</span>
3122
+ <span className="text-muted-foreground">{t("panel.layers.mergedName")}</span>
2910
3123
  <Input
2911
3124
  value={mergeLayerName}
2912
3125
  onChange={(e) => setMergeLayerName(e.target.value)}
2913
3126
  className="h-7 min-w-0 flex-1"
2914
- placeholder="可空,留空将随机命名"
3127
+ placeholder={t("panel.layers.mergedNamePlaceholder")}
2915
3128
  autoFocus
2916
3129
  />
2917
3130
  <Tooltip>
@@ -2924,14 +3137,14 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2924
3137
  setIsMergingLayers(false);
2925
3138
  setMergeLayerName("");
2926
3139
  }}
2927
- aria-label="确认合并图层"
3140
+ aria-label={t("panel.layers.confirmMergeAria")}
2928
3141
  disabled={!canMergeLayers}
2929
3142
  className="rounded border border-border p-1 hover:bg-accent"
2930
3143
  >
2931
3144
  <IconCheck />
2932
3145
  </button>
2933
3146
  </TooltipTrigger>
2934
- <TooltipContent className="z-[10000]">确认合并</TooltipContent>
3147
+ <TooltipContent className="z-[10000]">{t("panel.layers.confirmMerge")}</TooltipContent>
2935
3148
  </Tooltip>
2936
3149
  <Tooltip>
2937
3150
  <TooltipTrigger asChild>
@@ -2941,13 +3154,13 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2941
3154
  setIsMergingLayers(false);
2942
3155
  setMergeLayerName("");
2943
3156
  }}
2944
- aria-label="取消合并图层"
3157
+ aria-label={t("panel.layers.cancelMergeAria")}
2945
3158
  className="rounded border border-border p-1 hover:bg-accent"
2946
3159
  >
2947
3160
  <IconClose />
2948
3161
  </button>
2949
3162
  </TooltipTrigger>
2950
- <TooltipContent className="z-[10000]">取消合并</TooltipContent>
3163
+ <TooltipContent className="z-[10000]">{t("panel.layers.cancelMerge")}</TooltipContent>
2951
3164
  </Tooltip>
2952
3165
  </div>
2953
3166
  ) : null}
@@ -2971,6 +3184,10 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
2971
3184
  }
2972
3185
  onUpdateBlueprintNode={handleUpdateBlueprintNode}
2973
3186
  blueprintLibraryOptions={blueprintLibraryOptions}
3187
+ blueprintNodeOptions={blueprintGraph.document.nodes.map((node) => ({
3188
+ id: node.id,
3189
+ label: `${node.label || node.id}${node.nodeType ? ` (${node.nodeType})` : ""}`,
3190
+ }))}
2974
3191
  selectedElement={selectedElement}
2975
3192
  selectedElements={selectedElements}
2976
3193
  viewElementScope={selectedElementScope}
@@ -3005,6 +3222,11 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
3005
3222
  </div>
3006
3223
  </ResizablePanel>
3007
3224
  </ResizablePanelGroup>
3225
+ <AssistantChatPanel
3226
+ deps={assistantDeps}
3227
+ open={assistantOpen}
3228
+ onOpenChange={setAssistantOpen}
3229
+ />
3008
3230
  <AlertDialog
3009
3231
  open={mappingDeleteConfirmOpen}
3010
3232
  onOpenChange={(open) => {
@@ -3014,13 +3236,15 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
3014
3236
  >
3015
3237
  <AlertDialogContent overlayClassName="bg-transparent pointer-events-none">
3016
3238
  <AlertDialogHeader>
3017
- <AlertDialogTitle>确认删除</AlertDialogTitle>
3239
+ <AlertDialogTitle>{t("panel.layers.confirmDeleteTitle")}</AlertDialogTitle>
3018
3240
  <AlertDialogDescription>
3019
- 将删除 {mappingDeleteImpactCount} 个关联映射图层,是否继续?
3241
+ {t("panel.layers.confirmDeleteMappingBody", {
3242
+ count: mappingDeleteImpactCount,
3243
+ })}
3020
3244
  </AlertDialogDescription>
3021
3245
  </AlertDialogHeader>
3022
3246
  <AlertDialogFooter>
3023
- <AlertDialogCancel>取消</AlertDialogCancel>
3247
+ <AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
3024
3248
  <AlertDialogAction
3025
3249
  onClick={() => {
3026
3250
  const fn = mappingDeleteProceedRef.current;
@@ -3029,7 +3253,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
3029
3253
  fn?.();
3030
3254
  }}
3031
3255
  >
3032
- 继续删除
3256
+ {t("panel.layers.continueDelete")}
3033
3257
  </AlertDialogAction>
3034
3258
  </AlertDialogFooter>
3035
3259
  </AlertDialogContent>
@@ -3048,9 +3272,11 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
3048
3272
  overlayClassName="bg-transparent pointer-events-none"
3049
3273
  >
3050
3274
  <DialogHeader>
3051
- <DialogTitle>确认删除图层</DialogTitle>
3275
+ <DialogTitle>{t("panel.layers.confirmDeleteLayerTitle")}</DialogTitle>
3052
3276
  <DialogDescription>
3053
- 即将删除图层:{deletingLayer?.name ?? "-"}
3277
+ {t("panel.layers.aboutToDeleteLayer", {
3278
+ name: deletingLayer?.name ?? "-",
3279
+ })}
3054
3280
  </DialogDescription>
3055
3281
  </DialogHeader>
3056
3282
  {deletingLayer ? (
@@ -3063,7 +3289,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
3063
3289
  <div className="flex items-center gap-3">
3064
3290
  <label className="flex items-center gap-1.5">
3065
3291
  <RadioGroupItem value="move" />
3066
- 节点迁移到
3292
+ {t("panel.layers.migrateNodesTo")}
3067
3293
  </label>
3068
3294
  <Select
3069
3295
  value={deleteTargetLayerId || "__none__"}
@@ -3073,10 +3299,10 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
3073
3299
  disabled={deletingLayerMode !== "move" || deleteTargetCandidates.length === 0}
3074
3300
  >
3075
3301
  <SelectTrigger className="h-8 w-[220px]">
3076
- <SelectValue placeholder="选择目标图层" />
3302
+ <SelectValue placeholder={t("panel.layers.selectTargetLayer")} />
3077
3303
  </SelectTrigger>
3078
3304
  <SelectContent>
3079
- <SelectItem value="__none__">请选择图层</SelectItem>
3305
+ <SelectItem value="__none__">{t("panel.layers.pleaseSelectLayer")}</SelectItem>
3080
3306
  {deleteTargetCandidates.map((l) => (
3081
3307
  <SelectItem key={l.id} value={l.id}>
3082
3308
  {l.name}
@@ -3088,7 +3314,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
3088
3314
  ) : null}
3089
3315
  <label className="flex items-center gap-1.5">
3090
3316
  <RadioGroupItem value="remove" />
3091
- {deletingLayer.isMapping ? "删除映射图层及其节点" : "同时删除该图层下所有节点"}
3317
+ {deletingLayer.isMapping ? t("panel.layers.deleteMappingWithNodes") : t("panel.layers.deleteAllNodesToo")}
3092
3318
  </label>
3093
3319
  </RadioGroup>
3094
3320
  ) : null}
@@ -3102,7 +3328,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
3102
3328
  }}
3103
3329
  className="rounded border border-border px-3 py-1.5 text-sm hover:bg-accent"
3104
3330
  >
3105
- 取消
3331
+ {t("common.cancel")}
3106
3332
  </button>
3107
3333
  <button
3108
3334
  type="button"
@@ -3122,7 +3348,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
3122
3348
  }}
3123
3349
  className="rounded bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90"
3124
3350
  >
3125
- 确认删除
3351
+ {t("panel.layers.confirmDeleteTitle")}
3126
3352
  </button>
3127
3353
  </DialogFooter>
3128
3354
  </DialogContent>
@@ -3136,8 +3362,8 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
3136
3362
  overlayClassName="bg-transparent pointer-events-none"
3137
3363
  >
3138
3364
  <DialogHeader>
3139
- <DialogTitle>Title 图标预览</DialogTitle>
3140
- <DialogDescription>用于预览页标签页 icon 的当前图片</DialogDescription>
3365
+ <DialogTitle>{t("panel.menubar.titleIconPreviewTitle")}</DialogTitle>
3366
+ <DialogDescription>{t("panel.menubar.titleIconPreviewDesc")}</DialogDescription>
3141
3367
  </DialogHeader>
3142
3368
  <div className="flex items-center justify-end gap-2">
3143
3369
  <button
@@ -3162,7 +3388,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
3162
3388
  onClick={() => setTitleIconZoom(1.6)}
3163
3389
  className="rounded border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
3164
3390
  >
3165
- 重置
3391
+ {t("common.reset")}
3166
3392
  </button>
3167
3393
  </div>
3168
3394
  {titleIconDataUrl ? (
@@ -3179,7 +3405,7 @@ export function ReactViewPanel({ initialZoom = 1, className }: ReactViewPanelPro
3179
3405
  >
3180
3406
  <img
3181
3407
  src={titleIconDataUrl}
3182
- alt="title 图标大图预览"
3408
+ alt={t("panel.menubar.titleIconPreviewAlt")}
3183
3409
  className="mx-auto rounded border border-border/70 object-contain"
3184
3410
  style={{
3185
3411
  width: 200,