@arronqzy/react-view 1.0.37 → 1.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arronqzy/react-view",
3
- "version": "1.0.37",
3
+ "version": "1.0.39",
4
4
  "files": [
5
5
  "src",
6
6
  "dist",
@@ -30,14 +30,14 @@
30
30
  "@codemirror/lang-json": "^6.0.1",
31
31
  "@codemirror/theme-one-dark": "^6.1.2",
32
32
  "@codemirror/view": "^6.36.2",
33
+ "@arronqzy/i18n": "0.1.17",
33
34
  "@arronqzy/ui": "1.0.10",
34
35
  "@arronqzy/blueprint-dsl": "1.0.17",
35
- "@arronqzy/i18n": "0.1.17",
36
36
  "@arronqzy/rx-store": "1.0.8",
37
37
  "@arronqzy/view-scene3d": "0.1.5",
38
38
  "@arronqzy/view-table": "0.1.8",
39
- "@arronqzy/react-blueprint": "1.0.22",
40
- "@arronqzy/webllm-assistant": "0.1.16"
39
+ "@arronqzy/webllm-assistant": "0.1.16",
40
+ "@arronqzy/react-blueprint": "1.0.24"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/react": "^19.0.1",
@@ -46,8 +46,8 @@
46
46
  "eslint": "^8.57.0",
47
47
  "typescript": "^5.5.4",
48
48
  "@arronqzy/eslint-config": "0.0.0",
49
- "@arronqzy/typescript-config": "1.0.0",
50
- "@arronqzy/tailwind": "1.0.2"
49
+ "@arronqzy/tailwind": "1.0.2",
50
+ "@arronqzy/typescript-config": "1.0.0"
51
51
  },
52
52
  "scripts": {
53
53
  "lint": "eslint \"src/**/*.{ts,tsx}\"",
package/readme.md CHANGED
@@ -57,7 +57,8 @@ export function Editor({ workspace }: { workspace?: WorkspaceProjectRecord | nul
57
57
  |------|------|------|
58
58
  | `className` | 根节点 class | — |
59
59
  | `initialZoom` | 初始缩放 | `1` |
60
- | `initialWorkspace` | 挂载后加载的完整工作区。空则空白画布 | — |
60
+ | `initialWorkspace` | 挂载后加载的完整工作区。空则空白画布。带 `id` 时默认同步进 IndexedDB | — |
61
+ | `workspaceId` | 按 IndexedDB id 打开(不 remount) | — |
61
62
 
62
63
  ## 在线预览
63
64
 
@@ -89,7 +90,7 @@ parseOnlinePreviewSearchParams("?preview=online&projectId=xxx");
89
90
 
90
91
  ## 工作区事件
91
92
 
92
- 回调带完整工作区,用来写自己的后端。IndexedDB 的增删改查不导出。
93
+ 回调带完整工作区,用来写自己的后端。`initialWorkspace` 会默认同步进 IndexedDB;本地副本可用导出的 list / get / delete / clear 清理。
93
94
 
94
95
  ```ts
95
96
  import {
@@ -111,9 +112,12 @@ syncSub.unsubscribe();
111
112
  | 事件 | 常量 | 何时触发 |
112
113
  |------|------|----------|
113
114
  | `workspace:add` | `AbuilderEvents.workspaceAdd` | 创建成功 |
114
- | `workspace:sync` | `AbuilderEvents.workspaceSync` | 同步成功 |
115
+ | `workspace:sync` | `AbuilderEvents.workspaceSync` | 同步成功(载荷含 `nameSpace`) |
116
+ | `workspace:delete` | `AbuilderEvents.workspaceDelete` | 删除工作区,参数 `{ ids, nameSpace }` |
117
+ | `blueprint-library:changed` | `AbuilderEvents.blueprintLibraryChanged` | 蓝图库变更 |
118
+ | `api-collection:changed` | `AbuilderEvents.apiCollectionChanged` | 接口集合变更 |
115
119
 
116
- 字段:`id`、`name`、`createdAt`、`updatedAt`、`panelState`、`blueprintDocument`、`blueprintMeta`、`productName`、`titleIconDataUrl`。
120
+ `workspace:add` / `workspace:sync` 的参数字段:上述工作区字段 + `nameSpace`。可用 `addEventSubscription(event, cb, { nameSpace })` 过滤。
117
121
 
118
122
  校验:
119
123
 
@@ -154,7 +158,14 @@ const dataUrl = await getPreviewSnapshot({
154
158
  | `addEventSubscription` / `AbuilderEvents` | 工作区事件 |
155
159
  | `getPreviewSnapshot` | 预览截图 data URL |
156
160
  | `parseWorkspaceData` 等 | 校验 / 解析 / 空工作区 |
157
- | `listWorkspaceProjects` / `deleteWorkspaceProject` / `clearWorkspaceProjects` | 列出 / 删除 / 清空浏览器 IndexedDB 中保存的工作区(参数 `nameSpace`) |
161
+ | `putWorkspaceProject` / `upsertWorkspaceProject` / `list` / `get` / `delete` / `clear` | 工作区完整 CRUD |
162
+ | `putBlueprintLibraryRecord` / `list` / `get` / `delete` / `clear` | 蓝图库完整 CRUD |
163
+ | `putExecutionRunRecord` / `list` / `get` / `delete` / `clear` / `purge*` / `trim*` | 执行日志 |
164
+ | `putApiCollection` / `upsertApiCollectionFromJson` / `list` / `delete` / `clear` | 接口集合 |
165
+ | `getCurrentWorkspace` / `openWorkspace` / `createWorkspace` / `saveWorkspace` / `syncWorkspace` | 命令式操作已挂载面板 |
166
+ | `clearAbuilderStorage` | 一次清空四类数据 |
167
+
168
+ 本地数据函数的 `nameSpace` 要和面板一致。面板 `ref` 类型为 `AbuilderViewHandle`。
158
169
  | `createView` / `createAction` | 视图 DSL 扩展点(`src/core`) |
159
170
 
160
171
  ## 样式
package/src/index.ts CHANGED
@@ -12,18 +12,68 @@ export {
12
12
  export type {
13
13
  AbuilderEventName,
14
14
  AbuilderEventPayloadMap,
15
+ AddEventSubscriptionOptions,
15
16
  WorkspaceAddEventPayload,
16
17
  WorkspaceSyncEventPayload,
18
+ WorkspaceDeleteEventPayload,
19
+ BlueprintLibraryChangedEventPayload,
17
20
  ApiCollectionUploadStartEventPayload,
18
21
  ApiCollectionUploadSuccessEventPayload,
22
+ ApiCollectionChangedEventPayload,
19
23
  WorkspaceData,
20
24
  } from "./panel/library/event-subscription";
21
25
  export type { WorkspaceProjectRecord } from "./panel/library/workspace-project-db";
22
26
  export {
23
27
  listWorkspaceProjects,
28
+ getWorkspaceProject,
29
+ putWorkspaceProject,
30
+ upsertWorkspaceProject,
24
31
  deleteWorkspaceProject,
25
32
  clearWorkspaceProjects,
26
33
  } from "./panel/library/workspace-storage";
27
- export type { WorkspaceProjectListItem } from "./panel/library/workspace-storage";
34
+ export type {
35
+ WorkspaceProjectListItem,
36
+ PutWorkspaceProjectOptions,
37
+ } from "./panel/library/workspace-storage";
38
+ export {
39
+ listBlueprintLibrary,
40
+ getBlueprintLibraryRecord,
41
+ putBlueprintLibraryRecord,
42
+ deleteBlueprintLibraryRecord,
43
+ clearBlueprintLibrary,
44
+ listExecutionRunRecords,
45
+ getExecutionRunRecord,
46
+ putExecutionRunRecord,
47
+ deleteExecutionRunRecord,
48
+ clearAllExecutionRunRecords,
49
+ purgeExecutionRunsOlderThan,
50
+ trimExecutionRunRecordsToMax,
51
+ readExecutionLogSettings,
52
+ writeExecutionLogSettings,
53
+ listApiCollectionRecords,
54
+ putApiCollection,
55
+ upsertApiCollectionFromJson,
56
+ deleteApiCollection,
57
+ clearApiCollections,
58
+ clearAbuilderStorage,
59
+ } from "./panel/library/abuilder-storage";
60
+ export type {
61
+ AbuilderStorageClearResult,
62
+ BlueprintLibraryListItem,
63
+ BlueprintLibraryRecord,
64
+ ExecutionRunRecord,
65
+ ExecutionLogSettings,
66
+ ApiCollectionRecord,
67
+ UpsertApiCollectionInput,
68
+ UpsertApiCollectionResult,
69
+ } from "./panel/library/abuilder-storage";
70
+ export {
71
+ getCurrentWorkspace,
72
+ openWorkspace,
73
+ createWorkspace,
74
+ saveWorkspace,
75
+ syncWorkspace,
76
+ } from "./panel/library/abuilder-view-handle";
77
+ export type { AbuilderViewHandle } from "./panel/library/abuilder-view-handle";
28
78
  export { getPreviewSnapshot } from "./panel/library/preview-snapshot";
29
79
  export type { GetPreviewSnapshotOptions } from "./panel/library/preview-snapshot";
@@ -1,4 +1,16 @@
1
- import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
1
+ import React, {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useImperativeHandle,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import {
11
+ registerAbuilderViewHandle,
12
+ type AbuilderViewHandle,
13
+ } from "./library/abuilder-view-handle";
2
14
  import { useI18n } from "@arronqzy/i18n/react";
3
15
  import { relocalizeMessage, type Locale } from "@arronqzy/i18n";
4
16
  import type { State } from "@arronqzy/rx-store";
@@ -7,6 +19,7 @@ import type { PanelElement } from "./types";
7
19
  import { usePanelElements } from "./hooks/usePanelElements";
8
20
  import { useWorkspaceProjects } from "./hooks/useWorkspaceProjects";
9
21
  import type { WorkspaceProjectRecord } from "./library/workspace-project-db";
22
+ import { subscribeBlueprintLibraryChanged } from "./library/blueprint-library-sync";
10
23
  import { WorkspaceProjectNav } from "./components/WorkspaceProjectNav";
11
24
  import { BusyOverlay } from "./components/BusyOverlay";
12
25
  import { PanelCanvas } from "./components/PanelCanvas";
@@ -140,6 +153,11 @@ export type ReactViewPanelProps = {
140
153
  className?: string;
141
154
  /** 外部传入的完整工作区数据,挂载后按这份数据完整渲染;空则显示空白,不自动打开 IndexedDB 记录 */
142
155
  initialWorkspace?: WorkspaceProjectRecord | null;
156
+ /**
157
+ * 按 IndexedDB id 打开工作区(不 remount)。
158
+ * 与 `initialWorkspace` 同时存在时:首次优先完整记录;之后 id 变化从 IndexedDB 打开。
159
+ */
160
+ workspaceId?: string | null;
143
161
  /** 界面语言;省略时按 localStorage / 浏览器语言解析 */
144
162
  locale?: Locale | null;
145
163
  onLocaleChange?: (locale: Locale) => void;
@@ -155,14 +173,18 @@ export type ReactViewPanelProps = {
155
173
  packageVersion?: string | null;
156
174
  };
157
175
 
158
- function ReactViewPanelInner({
176
+ const ReactViewPanelInner = forwardRef<AbuilderViewHandle, ReactViewPanelProps>(function ReactViewPanelInner(
177
+ {
159
178
  initialZoom = 1,
160
179
  className,
161
180
  initialWorkspace = null,
181
+ workspaceId = null,
162
182
  nameSpace = null,
163
183
  packageName = null,
164
184
  packageVersion = null,
165
- }: ReactViewPanelProps) {
185
+ }: ReactViewPanelProps,
186
+ ref
187
+ ) {
166
188
  const { t, locale, setLocale } = useI18n();
167
189
  const messages = useMemo(() => getPanelMessages(t), [t]);
168
190
  const THEME_STORAGE_KEY = appStorageKey("panel:theme", nameSpace);
@@ -803,6 +825,11 @@ function ReactViewPanelInner({
803
825
  void refreshBlueprintLibrary();
804
826
  }, [refreshBlueprintLibrary]);
805
827
 
828
+ useEffect(
829
+ () => subscribeBlueprintLibraryChanged(() => void refreshBlueprintLibrary(), nameSpace),
830
+ [nameSpace, refreshBlueprintLibrary]
831
+ );
832
+
806
833
  const loadBlueprintFromLibrary = useCallback(
807
834
  async (id: string) => {
808
835
  const record = await getBlueprintLibraryRecord(id, nameSpace);
@@ -1070,9 +1097,26 @@ function ReactViewPanelInner({
1070
1097
  panelRevision: `${historyCursor}|${allElements.length}`,
1071
1098
  onProjectApplied: handleWorkspaceProjectApplied,
1072
1099
  initialWorkspace,
1100
+ workspaceId,
1073
1101
  nameSpace,
1074
1102
  });
1075
1103
 
1104
+ const viewHandle = useMemo<AbuilderViewHandle>(
1105
+ () => ({
1106
+ getNameSpace: () => nameSpace ?? null,
1107
+ getCurrentWorkspace: () => workspaceProjects.getCurrentWorkspaceRecord(),
1108
+ openWorkspace: (idOrRecord) => workspaceProjects.handleOpenProject(idOrRecord),
1109
+ createWorkspace: () => workspaceProjects.handleCreateProject(),
1110
+ saveWorkspace: () => workspaceProjects.handleSaveProject(),
1111
+ syncWorkspace: () => workspaceProjects.handleSyncProject(),
1112
+ }),
1113
+ [nameSpace, workspaceProjects]
1114
+ );
1115
+
1116
+ useImperativeHandle(ref, () => viewHandle, [viewHandle]);
1117
+
1118
+ useEffect(() => registerAbuilderViewHandle(viewHandle), [viewHandle]);
1119
+
1076
1120
  useEffect(() => {
1077
1121
  registerPreviewSnapshotProvider(async () => {
1078
1122
  return captureEditorPreviewSnapshot({
@@ -2792,10 +2836,16 @@ function ReactViewPanelInner({
2792
2836
  open={apiCollectionManagerOpen}
2793
2837
  onOpenChange={setApiCollectionManagerOpen}
2794
2838
  onUploadStart={(info) => {
2795
- emitAbuilderEvent(AbuilderEvents.apiCollectionUploadStart, info);
2839
+ emitAbuilderEvent(AbuilderEvents.apiCollectionUploadStart, {
2840
+ ...info,
2841
+ nameSpace: nameSpace ?? null,
2842
+ });
2796
2843
  }}
2797
2844
  onUploadSuccess={(info) => {
2798
- emitAbuilderEvent(AbuilderEvents.apiCollectionUploadSuccess, info);
2845
+ emitAbuilderEvent(AbuilderEvents.apiCollectionUploadSuccess, {
2846
+ ...info,
2847
+ nameSpace: nameSpace ?? null,
2848
+ });
2799
2849
  }}
2800
2850
  onSyncCollection={(record) => {
2801
2851
  const patches = buildApiCollectionSyncPatches({
@@ -2840,17 +2890,14 @@ function ReactViewPanelInner({
2840
2890
  <Toaster />
2841
2891
  </div>
2842
2892
  );
2843
- }
2844
-
2845
- export function ReactViewPanel({
2846
- locale,
2847
- onLocaleChange,
2848
- nameSpace,
2849
- ...rest
2850
- }: ReactViewPanelProps) {
2851
- return (
2852
- <I18nRoot locale={locale} onLocaleChange={onLocaleChange} nameSpace={nameSpace}>
2853
- <ReactViewPanelInner nameSpace={nameSpace} {...rest} />
2854
- </I18nRoot>
2855
- );
2856
- }
2893
+ });
2894
+
2895
+ export const ReactViewPanel = forwardRef<AbuilderViewHandle, ReactViewPanelProps>(
2896
+ function ReactViewPanel({ locale, onLocaleChange, nameSpace, ...rest }, ref) {
2897
+ return (
2898
+ <I18nRoot locale={locale} onLocaleChange={onLocaleChange} nameSpace={nameSpace}>
2899
+ <ReactViewPanelInner ref={ref} nameSpace={nameSpace} {...rest} />
2900
+ </I18nRoot>
2901
+ );
2902
+ }
2903
+ );
@@ -7,16 +7,16 @@ import {
7
7
  createWorkspaceProjectId,
8
8
  getWorkspaceProject,
9
9
  listWorkspaceProjects,
10
- putWorkspaceProject,
11
10
  type WorkspaceProjectListItem,
12
11
  type WorkspaceProjectRecord,
13
12
  } from "../library/workspace-project-db";
14
- import { deleteWorkspaceProject } from "../library/workspace-storage";
15
13
  import {
16
- broadcastWorkspaceProjectUpdate,
17
- subscribeWorkspaceProjectsChanged,
18
- } from "../library/workspace-project-sync";
14
+ deleteWorkspaceProject,
15
+ putWorkspaceProject,
16
+ } from "../library/workspace-storage";
17
+ import { subscribeWorkspaceProjectsChanged } from "../library/workspace-project-sync";
19
18
  import { writeWorkspacePreviewCache, readWorkspacePreviewCache } from "../library/workspace-project-cache";
19
+ import { normalizeAppNameSpace } from "@arronqzy/blueprint-dsl";
20
20
  import {
21
21
  cloneWorkspaceSnapshot,
22
22
  workspaceSnapshotsEqual,
@@ -45,6 +45,11 @@ export type UseWorkspaceProjectsOptions = {
45
45
  onProjectApplied?: (record: WorkspaceProjectRecord) => void;
46
46
  /** 外部传入的初始工作区,挂载后自动加载(不触发 onProjectApplied) */
47
47
  initialWorkspace?: WorkspaceProjectRecord | null;
48
+ /**
49
+ * 按 IndexedDB id 打开工作区。与 `initialWorkspace` 同时存在时:
50
+ * 首次优先 `initialWorkspace`;之后 `workspaceId` 变化会从 IndexedDB 打开。
51
+ */
52
+ workspaceId?: string | null;
48
53
  /** 隔离 IndexedDB / 预览缓存 / 同步频道 */
49
54
  nameSpace?: string | null;
50
55
  };
@@ -103,17 +108,20 @@ export function useWorkspaceProjects({
103
108
  panelRevision,
104
109
  onProjectApplied,
105
110
  initialWorkspace,
111
+ workspaceId = null,
106
112
  nameSpace = null,
107
113
  }: UseWorkspaceProjectsOptions) {
108
114
  const { t } = useI18nOptional();
109
115
  const [projects, setProjects] = useState<WorkspaceProjectListItem[]>([]);
110
116
  const [activeProjectId, setActiveProjectId] = useState<string | null>(null);
111
117
  const [activeProjectName, setActiveProjectName] = useState<string | null>(null);
118
+ const activeCreatedAtRef = useRef<number | null>(null);
112
119
  const syncedSnapshotRef = useRef<WorkspaceSnapshot | null>(null);
113
120
  const syncedPanelRevisionRef = useRef<unknown>(null);
114
121
  const pendingRevisionSyncRef = useRef(false);
115
122
  const previewWindowsRef = useRef(new Map<string, Window>());
116
123
  const [previewingProjectIds, setPreviewingProjectIds] = useState<string[]>([]);
124
+ const eventNameSpace = normalizeAppNameSpace(nameSpace) || null;
117
125
 
118
126
  const refreshPreviewing = useCallback(() => {
119
127
  const ids: string[] = [];
@@ -166,7 +174,9 @@ export function useWorkspaceProjects({
166
174
  return subscribeWorkspaceProjectsChanged((message) => {
167
175
  const removedActive =
168
176
  message.type === "cleared" ||
169
- (activeProjectId !== null && message.projectIds.includes(activeProjectId));
177
+ (message.type === "deleted" &&
178
+ activeProjectId !== null &&
179
+ message.projectIds.includes(activeProjectId));
170
180
  if (removedActive) {
171
181
  setActiveProjectId(null);
172
182
  setActiveProjectName(null);
@@ -197,8 +207,67 @@ export function useWorkspaceProjects({
197
207
  buildCurrentSnapshot,
198
208
  ]);
199
209
 
210
+ const ensureWorkspaceInIndexedDb = useCallback(
211
+ async (record: WorkspaceProjectRecord) => {
212
+ const id = typeof record.id === "string" ? record.id.trim() : "";
213
+ if (!id) return;
214
+ try {
215
+ const existing = await getWorkspaceProject(id, nameSpace);
216
+ const incomingUpdatedAt =
217
+ typeof record.updatedAt === "number" && Number.isFinite(record.updatedAt)
218
+ ? record.updatedAt
219
+ : 0;
220
+ if (existing && existing.updatedAt >= incomingUpdatedAt) {
221
+ return;
222
+ }
223
+ const now = Date.now();
224
+ const name =
225
+ (typeof record.name === "string" && record.name.trim()) ||
226
+ (typeof record.productName === "string" && record.productName.trim()) ||
227
+ t("panel.defaults.unnamedProduct");
228
+ const toStore: WorkspaceProjectRecord = {
229
+ ...record,
230
+ id,
231
+ name,
232
+ createdAt: existing?.createdAt ?? record.createdAt ?? now,
233
+ updatedAt: incomingUpdatedAt || now,
234
+ panelState:
235
+ normalizeImportedPanelState(record.panelState) ??
236
+ existing?.panelState ??
237
+ createEmptyPanelState(),
238
+ blueprintDocument:
239
+ parseBlueprintData(record.blueprintDocument).value ??
240
+ existing?.blueprintDocument ??
241
+ BlueprintGraph.empty().document,
242
+ blueprintMeta: {
243
+ name:
244
+ record.blueprintMeta?.name ??
245
+ existing?.blueprintMeta?.name ??
246
+ t("panel.defaults.unnamedBlueprint"),
247
+ remark:
248
+ record.blueprintMeta?.remark ?? existing?.blueprintMeta?.remark ?? "",
249
+ },
250
+ productName:
251
+ typeof record.productName === "string" && record.productName.trim()
252
+ ? record.productName
253
+ : existing?.productName ?? t("panel.defaults.unnamedProduct"),
254
+ titleIconDataUrl:
255
+ record.titleIconDataUrl ?? existing?.titleIconDataUrl,
256
+ };
257
+ await putWorkspaceProject(toStore, nameSpace);
258
+ await refreshProjects();
259
+ } catch (error) {
260
+ console.warn("[abuilder] ensureWorkspaceInIndexedDb failed", error);
261
+ }
262
+ },
263
+ [nameSpace, refreshProjects, t]
264
+ );
265
+
200
266
  const applyProjectRecord = useCallback(
201
- (record: WorkspaceProjectRecord, options?: { notifyApplied?: boolean }) => {
267
+ (
268
+ record: WorkspaceProjectRecord,
269
+ options?: { notifyApplied?: boolean; persistToIndexedDb?: boolean }
270
+ ) => {
202
271
  const parsedBlueprint = parseBlueprintData(record.blueprintDocument);
203
272
  const blueprintDocument =
204
273
  parsedBlueprint.value ?? BlueprintGraph.empty().document;
@@ -221,6 +290,10 @@ export function useWorkspaceProjects({
221
290
  setTitleIconDataUrl(titleIconDataUrl);
222
291
  setActiveProjectId(record.id || null);
223
292
  setActiveProjectName(record.name ?? null);
293
+ activeCreatedAtRef.current =
294
+ typeof record.createdAt === "number" && Number.isFinite(record.createdAt)
295
+ ? record.createdAt
296
+ : Date.now();
224
297
  syncedSnapshotRef.current = cloneWorkspaceSnapshot({
225
298
  blueprintDocument,
226
299
  blueprintMeta,
@@ -229,18 +302,27 @@ export function useWorkspaceProjects({
229
302
  });
230
303
  syncedPanelRevisionRef.current = panelRevision;
231
304
  pendingRevisionSyncRef.current = true;
305
+ const normalized: WorkspaceProjectRecord = {
306
+ ...record,
307
+ id: record.id,
308
+ name: record.name ?? productName,
309
+ createdAt: activeCreatedAtRef.current,
310
+ updatedAt: record.updatedAt ?? Date.now(),
311
+ blueprintDocument,
312
+ blueprintMeta,
313
+ panelState,
314
+ productName,
315
+ titleIconDataUrl: titleIconDataUrl || undefined,
316
+ };
232
317
  if (options?.notifyApplied !== false) {
233
- onProjectApplied?.({
234
- ...record,
235
- blueprintDocument,
236
- blueprintMeta,
237
- panelState,
238
- productName,
239
- titleIconDataUrl: titleIconDataUrl || undefined,
240
- });
318
+ onProjectApplied?.(normalized);
319
+ }
320
+ if (options?.persistToIndexedDb) {
321
+ void ensureWorkspaceInIndexedDb(normalized);
241
322
  }
242
323
  },
243
324
  [
325
+ ensureWorkspaceInIndexedDb,
244
326
  importPanelData,
245
327
  onProjectApplied,
246
328
  setBlueprintGraph,
@@ -263,6 +345,7 @@ export function useWorkspaceProjects({
263
345
  setTitleIconDataUrl("");
264
346
  setActiveProjectId(null);
265
347
  setActiveProjectName(null);
348
+ activeCreatedAtRef.current = null;
266
349
  syncedSnapshotRef.current = null;
267
350
  }, [
268
351
  importPanelData,
@@ -275,23 +358,7 @@ export function useWorkspaceProjects({
275
358
 
276
359
  const initialWorkspaceAppliedKeyRef = useRef<string | null>(null);
277
360
  const bootstrappedRef = useRef(false);
278
- useEffect(() => {
279
- if (!bootstrappedRef.current) {
280
- bootstrappedRef.current = true;
281
- if (hasWorkspacePayload(initialWorkspace)) {
282
- applyProjectRecord(initialWorkspace, { notifyApplied: false });
283
- initialWorkspaceAppliedKeyRef.current = workspaceApplyKey(initialWorkspace);
284
- } else {
285
- applyEmptyWorkspace();
286
- }
287
- return;
288
- }
289
- if (!hasWorkspacePayload(initialWorkspace)) return;
290
- const key = workspaceApplyKey(initialWorkspace);
291
- if (initialWorkspaceAppliedKeyRef.current === key) return;
292
- initialWorkspaceAppliedKeyRef.current = key;
293
- applyProjectRecord(initialWorkspace, { notifyApplied: false });
294
- }, [applyEmptyWorkspace, applyProjectRecord, initialWorkspace]);
361
+ const workspaceIdAppliedRef = useRef<string | null>(null);
295
362
 
296
363
  const persistProject = useCallback(
297
364
  async (options: { id?: string; name: string; createdAt?: number }) => {
@@ -299,7 +366,7 @@ export function useWorkspaceProjects({
299
366
  const snapshot = cloneWorkspaceSnapshot(buildCurrentSnapshot());
300
367
  const now = Date.now();
301
368
  const id = options.id ?? createWorkspaceProjectId();
302
- const createdAt = options.createdAt ?? now;
369
+ const createdAt = options.createdAt ?? activeCreatedAtRef.current ?? now;
303
370
  const name = (options.name ?? "").trim() || resolveProjectName();
304
371
 
305
372
  let panelState: State;
@@ -325,10 +392,9 @@ export function useWorkspaceProjects({
325
392
  };
326
393
 
327
394
  await putWorkspaceProject(record, nameSpace);
328
- writeWorkspacePreviewCache(record, nameSpace);
329
- broadcastWorkspaceProjectUpdate(id, now, nameSpace);
330
395
  setActiveProjectId(id);
331
396
  setActiveProjectName(name);
397
+ activeCreatedAtRef.current = createdAt;
332
398
  syncedSnapshotRef.current = snapshot;
333
399
  syncedPanelRevisionRef.current = panelRevision;
334
400
  pendingRevisionSyncRef.current = false;
@@ -349,14 +415,49 @@ export function useWorkspaceProjects({
349
415
  ]
350
416
  );
351
417
 
418
+ const getCurrentWorkspaceRecord = useCallback((): WorkspaceProjectRecord | null => {
419
+ if (!activeProjectId) return null;
420
+ try {
421
+ const snapshot = buildCurrentSnapshot();
422
+ const panelState = exportPanelData();
423
+ const now = Date.now();
424
+ return {
425
+ id: activeProjectId,
426
+ name: activeProjectName || resolveProjectName(),
427
+ createdAt: activeCreatedAtRef.current ?? now,
428
+ updatedAt: now,
429
+ panelState,
430
+ blueprintDocument: snapshot.blueprintDocument,
431
+ blueprintMeta: snapshot.blueprintMeta,
432
+ productName:
433
+ typeof snapshot.productName === "string"
434
+ ? snapshot.productName
435
+ : resolveProjectName(),
436
+ titleIconDataUrl: snapshot.titleIconDataUrl || undefined,
437
+ };
438
+ } catch (error) {
439
+ console.warn("[abuilder] getCurrentWorkspace failed", error);
440
+ return null;
441
+ }
442
+ }, [
443
+ activeProjectId,
444
+ activeProjectName,
445
+ buildCurrentSnapshot,
446
+ exportPanelData,
447
+ resolveProjectName,
448
+ ]);
449
+
352
450
  const handleCreateProject = useCallback(async () => {
353
451
  return runBusyTask(t("common.savingWorkspace"), async () => {
354
452
  const name = resolveProjectName();
355
453
  const record = await persistProject({ name });
356
- emitAbuilderEvent(AbuilderEvents.workspaceAdd, record);
454
+ emitAbuilderEvent(AbuilderEvents.workspaceAdd, {
455
+ ...record,
456
+ nameSpace: eventNameSpace,
457
+ });
357
458
  return { name, id: record.id };
358
459
  });
359
- }, [persistProject, resolveProjectName, t]);
460
+ }, [eventNameSpace, persistProject, resolveProjectName, t]);
360
461
 
361
462
  const handleSaveProject = useCallback(async () => {
362
463
  if (!activeProjectId) {
@@ -368,17 +469,33 @@ export function useWorkspaceProjects({
368
469
  const record = await persistProject({
369
470
  id: activeProjectId,
370
471
  name,
371
- createdAt: existing?.createdAt,
472
+ createdAt: existing?.createdAt ?? activeCreatedAtRef.current ?? undefined,
473
+ });
474
+ emitAbuilderEvent(AbuilderEvents.workspaceSync, {
475
+ ...record,
476
+ nameSpace: eventNameSpace,
372
477
  });
373
478
  return { name, id: record.id };
374
479
  });
375
- }, [activeProjectId, handleCreateProject, nameSpace, persistProject, resolveProjectName, t]);
480
+ }, [
481
+ activeProjectId,
482
+ eventNameSpace,
483
+ handleCreateProject,
484
+ nameSpace,
485
+ persistProject,
486
+ resolveProjectName,
487
+ t,
488
+ ]);
376
489
 
377
490
  const handleOpenProject = useCallback(
378
- async (id: string) => {
491
+ async (idOrRecord: string | WorkspaceProjectRecord) => {
379
492
  try {
380
493
  await runBusyTask(t("common.loadingWorkspace"), async () => {
381
- const record = await getWorkspaceProject(id, nameSpace);
494
+ if (typeof idOrRecord !== "string") {
495
+ applyProjectRecord(idOrRecord, { persistToIndexedDb: true });
496
+ return;
497
+ }
498
+ const record = await getWorkspaceProject(idOrRecord, nameSpace);
382
499
  if (!record) {
383
500
  await refreshProjects();
384
501
  throw new Error(t("panel.messages.workspaceNotFound"));
@@ -397,24 +514,29 @@ export function useWorkspaceProjects({
397
514
  if (!activeProjectId) {
398
515
  throw new Error(t("panel.messages.saveWorkspaceFirst"));
399
516
  }
517
+ // 本地无记录时 upsert,避免宿主 initialWorkspace 未落盘后同步报错
400
518
  const existing = await getWorkspaceProject(activeProjectId, nameSpace);
401
- if (!existing) {
402
- setActiveProjectId(null);
403
- setActiveProjectName(null);
404
- syncedSnapshotRef.current = null;
405
- await refreshProjects();
406
- throw new Error(t("panel.messages.workspaceNotFound"));
407
- }
408
- const name = resolveProjectName() || existing.name;
519
+ const name = resolveProjectName() || existing?.name || activeProjectName || "";
409
520
  const record = await persistProject({
410
521
  id: activeProjectId,
411
522
  name,
412
- createdAt: existing.createdAt,
523
+ createdAt: existing?.createdAt ?? activeCreatedAtRef.current ?? undefined,
524
+ });
525
+ emitAbuilderEvent(AbuilderEvents.workspaceSync, {
526
+ ...record,
527
+ nameSpace: eventNameSpace,
413
528
  });
414
- emitAbuilderEvent(AbuilderEvents.workspaceSync, record);
415
529
  return name;
416
530
  });
417
- }, [activeProjectId, nameSpace, persistProject, refreshProjects, resolveProjectName, t]);
531
+ }, [
532
+ activeProjectId,
533
+ activeProjectName,
534
+ eventNameSpace,
535
+ nameSpace,
536
+ persistProject,
537
+ resolveProjectName,
538
+ t,
539
+ ]);
418
540
 
419
541
  const handleDeleteProject = useCallback(
420
542
  async (id: string) => {
@@ -424,6 +546,7 @@ export function useWorkspaceProjects({
424
546
  if (activeProjectId === id) {
425
547
  setActiveProjectId(null);
426
548
  setActiveProjectName(null);
549
+ activeCreatedAtRef.current = null;
427
550
  syncedSnapshotRef.current = null;
428
551
  }
429
552
  previewWindowsRef.current.delete(id);
@@ -478,6 +601,58 @@ export function useWorkspaceProjects({
478
601
  [activeProjectId, dirty, handleSyncProject, nameSpace, refreshPreviewing, t]
479
602
  );
480
603
 
604
+ useEffect(() => {
605
+ if (!bootstrappedRef.current) {
606
+ bootstrappedRef.current = true;
607
+ if (hasWorkspacePayload(initialWorkspace)) {
608
+ applyProjectRecord(initialWorkspace, {
609
+ notifyApplied: false,
610
+ persistToIndexedDb: true,
611
+ });
612
+ initialWorkspaceAppliedKeyRef.current = workspaceApplyKey(initialWorkspace);
613
+ if (initialWorkspace.id) {
614
+ workspaceIdAppliedRef.current = initialWorkspace.id;
615
+ }
616
+ } else if (typeof workspaceId === "string" && workspaceId.trim()) {
617
+ workspaceIdAppliedRef.current = workspaceId.trim();
618
+ void handleOpenProject(workspaceId.trim());
619
+ } else {
620
+ applyEmptyWorkspace();
621
+ }
622
+ return;
623
+ }
624
+ if (!hasWorkspacePayload(initialWorkspace)) return;
625
+ const key = workspaceApplyKey(initialWorkspace);
626
+ if (initialWorkspaceAppliedKeyRef.current === key) return;
627
+ initialWorkspaceAppliedKeyRef.current = key;
628
+ if (initialWorkspace.id) {
629
+ workspaceIdAppliedRef.current = initialWorkspace.id;
630
+ }
631
+ applyProjectRecord(initialWorkspace, {
632
+ notifyApplied: false,
633
+ persistToIndexedDb: true,
634
+ });
635
+ }, [
636
+ applyEmptyWorkspace,
637
+ applyProjectRecord,
638
+ handleOpenProject,
639
+ initialWorkspace,
640
+ workspaceId,
641
+ ]);
642
+
643
+ useEffect(() => {
644
+ if (!bootstrappedRef.current) return;
645
+ const id = typeof workspaceId === "string" ? workspaceId.trim() : "";
646
+ if (!id) return;
647
+ if (workspaceIdAppliedRef.current === id) return;
648
+ if (activeProjectId === id) {
649
+ workspaceIdAppliedRef.current = id;
650
+ return;
651
+ }
652
+ workspaceIdAppliedRef.current = id;
653
+ void handleOpenProject(id);
654
+ }, [activeProjectId, handleOpenProject, workspaceId]);
655
+
481
656
  return {
482
657
  projects,
483
658
  activeProjectId,
@@ -490,6 +665,7 @@ export function useWorkspaceProjects({
490
665
  handleSyncProject,
491
666
  handleDeleteProject,
492
667
  handlePreviewProject,
668
+ getCurrentWorkspaceRecord,
493
669
  refreshProjects,
494
670
  };
495
671
  }
@@ -0,0 +1,168 @@
1
+ import { normalizeAppNameSpace } from "@arronqzy/blueprint-dsl";
2
+ import {
3
+ clearAllExecutionRunRecords,
4
+ clearApiCollections as clearApiCollectionsRaw,
5
+ clearBlueprintLibrary as clearBlueprintLibraryRecords,
6
+ deleteApiCollection as deleteApiCollectionRaw,
7
+ deleteBlueprintLibraryRecord as deleteBlueprintLibraryRecordRaw,
8
+ putApiCollection as putApiCollectionRaw,
9
+ putBlueprintLibraryRecord as putBlueprintLibraryRecordRaw,
10
+ putExecutionRunRecord as putExecutionRunRecordRaw,
11
+ upsertApiCollectionFromJson as upsertApiCollectionFromJsonRaw,
12
+ type BlueprintLibraryRecord,
13
+ type ExecutionRunRecord,
14
+ type UpsertApiCollectionInput,
15
+ type UpsertApiCollectionResult,
16
+ } from "@arronqzy/react-blueprint";
17
+ import type { ApiCollectionRecord } from "@arronqzy/blueprint-dsl";
18
+ import { broadcastBlueprintLibraryChanged } from "./blueprint-library-sync";
19
+ import { AbuilderEvents, emitAbuilderEvent } from "./event-subscription";
20
+ import { clearWorkspaceProjects } from "./workspace-storage";
21
+
22
+ export {
23
+ listBlueprintLibrary,
24
+ getBlueprintLibraryRecord,
25
+ listExecutionRunRecords,
26
+ getExecutionRunRecord,
27
+ deleteExecutionRunRecord,
28
+ clearAllExecutionRunRecords,
29
+ purgeExecutionRunsOlderThan,
30
+ trimExecutionRunRecordsToMax,
31
+ readExecutionLogSettings,
32
+ writeExecutionLogSettings,
33
+ listApiCollectionRecords,
34
+ } from "@arronqzy/react-blueprint";
35
+ export type {
36
+ BlueprintLibraryListItem,
37
+ BlueprintLibraryRecord,
38
+ ExecutionRunRecord,
39
+ ExecutionLogSettings,
40
+ UpsertApiCollectionInput,
41
+ UpsertApiCollectionResult,
42
+ } from "@arronqzy/react-blueprint";
43
+ export type { ApiCollectionRecord } from "@arronqzy/blueprint-dsl";
44
+
45
+ function resolvedNameSpace(nameSpace?: string | null): string | null {
46
+ return normalizeAppNameSpace(nameSpace) || null;
47
+ }
48
+
49
+ /** 写入 / 覆盖蓝图库记录;已挂载的编辑器会刷新蓝图库列表 */
50
+ export async function putBlueprintLibraryRecord(
51
+ record: BlueprintLibraryRecord,
52
+ nameSpace?: string | null
53
+ ): Promise<BlueprintLibraryRecord> {
54
+ const saved = await putBlueprintLibraryRecordRaw(record, nameSpace);
55
+ broadcastBlueprintLibraryChanged(nameSpace);
56
+ emitAbuilderEvent(AbuilderEvents.blueprintLibraryChanged, {
57
+ nameSpace: resolvedNameSpace(nameSpace),
58
+ action: "put",
59
+ ids: [saved.id],
60
+ });
61
+ return saved;
62
+ }
63
+
64
+ /** 删除蓝图库中的一条蓝图;已挂载的编辑器会刷新蓝图库列表 */
65
+ export async function deleteBlueprintLibraryRecord(
66
+ id: string,
67
+ nameSpace?: string | null
68
+ ): Promise<void> {
69
+ await deleteBlueprintLibraryRecordRaw(id, nameSpace);
70
+ broadcastBlueprintLibraryChanged(nameSpace);
71
+ emitAbuilderEvent(AbuilderEvents.blueprintLibraryChanged, {
72
+ nameSpace: resolvedNameSpace(nameSpace),
73
+ action: "delete",
74
+ ids: [id],
75
+ });
76
+ }
77
+
78
+ /** 清空该 nameSpace 下的蓝图库,返回删除数量;已挂载的编辑器会刷新蓝图库列表 */
79
+ export async function clearBlueprintLibrary(nameSpace?: string | null): Promise<number> {
80
+ const count = await clearBlueprintLibraryRecords(nameSpace);
81
+ broadcastBlueprintLibraryChanged(nameSpace);
82
+ emitAbuilderEvent(AbuilderEvents.blueprintLibraryChanged, {
83
+ nameSpace: resolvedNameSpace(nameSpace),
84
+ action: "clear",
85
+ });
86
+ return count;
87
+ }
88
+
89
+ /** 写入一条蓝图执行日志 */
90
+ export async function putExecutionRunRecord(
91
+ record: ExecutionRunRecord,
92
+ nameSpace?: string | null
93
+ ): Promise<ExecutionRunRecord> {
94
+ return putExecutionRunRecordRaw(record, nameSpace);
95
+ }
96
+
97
+ /** 写入接口集合;persistLocal 时落 IndexedDB,并同步已挂载编辑器的内存列表 */
98
+ export async function putApiCollection(
99
+ record: ApiCollectionRecord,
100
+ nameSpace?: string | null
101
+ ): Promise<ApiCollectionRecord> {
102
+ const saved = await putApiCollectionRaw(record, nameSpace);
103
+ emitAbuilderEvent(AbuilderEvents.apiCollectionChanged, {
104
+ nameSpace: resolvedNameSpace(nameSpace),
105
+ action: "put",
106
+ ids: [saved.id],
107
+ });
108
+ return saved;
109
+ }
110
+
111
+ /** 从 JSON 文本 upsert 接口集合;可传 nameSpace */
112
+ export async function upsertApiCollectionFromJson(
113
+ input: UpsertApiCollectionInput
114
+ ): Promise<UpsertApiCollectionResult> {
115
+ const result = await upsertApiCollectionFromJsonRaw(input);
116
+ if (result.ok) {
117
+ emitAbuilderEvent(AbuilderEvents.apiCollectionChanged, {
118
+ nameSpace: resolvedNameSpace(input.nameSpace),
119
+ action: "put",
120
+ ids: [result.record.id],
121
+ });
122
+ }
123
+ return result;
124
+ }
125
+
126
+ export async function deleteApiCollection(
127
+ id: string,
128
+ nameSpace?: string | null
129
+ ): Promise<void> {
130
+ await deleteApiCollectionRaw(id, nameSpace);
131
+ emitAbuilderEvent(AbuilderEvents.apiCollectionChanged, {
132
+ nameSpace: resolvedNameSpace(nameSpace),
133
+ action: "delete",
134
+ ids: [id],
135
+ });
136
+ }
137
+
138
+ export async function clearApiCollections(nameSpace?: string | null): Promise<number> {
139
+ const count = await clearApiCollectionsRaw(nameSpace);
140
+ emitAbuilderEvent(AbuilderEvents.apiCollectionChanged, {
141
+ nameSpace: resolvedNameSpace(nameSpace),
142
+ action: "clear",
143
+ });
144
+ return count;
145
+ }
146
+
147
+ export type AbuilderStorageClearResult = {
148
+ workspaces: number;
149
+ blueprints: number;
150
+ executionRuns: number;
151
+ apiCollections: number;
152
+ };
153
+
154
+ /**
155
+ * 清空该 nameSpace 下编辑器写入浏览器的全部数据:工作区、蓝图库、执行日志、接口集合。
156
+ * 主题、字号、日志保留设置等偏好不受影响。
157
+ */
158
+ export async function clearAbuilderStorage(
159
+ nameSpace?: string | null
160
+ ): Promise<AbuilderStorageClearResult> {
161
+ const [workspaces, blueprints, executionRuns, apiCollections] = await Promise.all([
162
+ clearWorkspaceProjects(nameSpace),
163
+ clearBlueprintLibrary(nameSpace),
164
+ clearAllExecutionRunRecords(nameSpace),
165
+ clearApiCollections(nameSpace),
166
+ ]);
167
+ return { workspaces, blueprints, executionRuns, apiCollections };
168
+ }
@@ -0,0 +1,100 @@
1
+ import { normalizeAppNameSpace } from "@arronqzy/blueprint-dsl";
2
+ import type { WorkspaceProjectRecord } from "./workspace-project-db";
3
+
4
+ /** 已挂载编辑面板的命令式句柄(也可通过 `ref` 拿到) */
5
+ export type AbuilderViewHandle = {
6
+ /** 当前 nameSpace */
7
+ getNameSpace: () => string | null;
8
+ /** 导出当前编辑器中的完整工作区;尚无关联 id 时返回 null */
9
+ getCurrentWorkspace: () => WorkspaceProjectRecord | null;
10
+ /** 按 id 从 IndexedDB 打开,或直接应用完整记录 */
11
+ openWorkspace: (idOrRecord: string | WorkspaceProjectRecord) => Promise<void>;
12
+ /** 创建新工作区并写入 IndexedDB,发出 workspace:add */
13
+ createWorkspace: () => Promise<{ name: string; id: string }>;
14
+ /** 保存当前工作区到 IndexedDB,发出 workspace:sync;无 active 时等同创建 */
15
+ saveWorkspace: () => Promise<{ name: string; id: string }>;
16
+ /** 同步当前工作区到 IndexedDB,发出 workspace:sync */
17
+ syncWorkspace: () => Promise<string>;
18
+ };
19
+
20
+ const handles = new Set<AbuilderViewHandle>();
21
+
22
+ export function registerAbuilderViewHandle(handle: AbuilderViewHandle): () => void {
23
+ handles.add(handle);
24
+ return () => {
25
+ handles.delete(handle);
26
+ };
27
+ }
28
+
29
+ function findHandle(nameSpace?: string | null): AbuilderViewHandle | null {
30
+ if (handles.size === 0) return null;
31
+ if (nameSpace === undefined) {
32
+ return handles.values().next().value ?? null;
33
+ }
34
+ const wanted = normalizeAppNameSpace(nameSpace);
35
+ for (const handle of handles) {
36
+ if (normalizeAppNameSpace(handle.getNameSpace()) === wanted) return handle;
37
+ }
38
+ return null;
39
+ }
40
+
41
+ /**
42
+ * 读取当前已挂载编辑器中的工作区快照。
43
+ * 同页多个实例时请传入 `nameSpace`;未挂载或尚无 active 工作区时返回 null。
44
+ */
45
+ export function getCurrentWorkspace(
46
+ nameSpace?: string | null
47
+ ): WorkspaceProjectRecord | null {
48
+ return findHandle(nameSpace)?.getCurrentWorkspace() ?? null;
49
+ }
50
+
51
+ /**
52
+ * 命令式打开工作区(不 remount)。
53
+ * 同页多个实例时请传入 `nameSpace`。
54
+ */
55
+ export async function openWorkspace(
56
+ idOrRecord: string | WorkspaceProjectRecord,
57
+ nameSpace?: string | null
58
+ ): Promise<void> {
59
+ const handle = findHandle(nameSpace);
60
+ if (!handle) {
61
+ throw new Error(
62
+ "No mounted Abuilder panel. Pass initialWorkspace or mount <App> first."
63
+ );
64
+ }
65
+ await handle.openWorkspace(idOrRecord);
66
+ }
67
+
68
+ export async function createWorkspace(
69
+ nameSpace?: string | null
70
+ ): Promise<{ name: string; id: string }> {
71
+ const handle = findHandle(nameSpace);
72
+ if (!handle) {
73
+ throw new Error(
74
+ "No mounted Abuilder panel. Pass initialWorkspace or mount <App> first."
75
+ );
76
+ }
77
+ return handle.createWorkspace();
78
+ }
79
+
80
+ export async function saveWorkspace(
81
+ nameSpace?: string | null
82
+ ): Promise<{ name: string; id: string }> {
83
+ const handle = findHandle(nameSpace);
84
+ if (!handle) {
85
+ throw new Error(
86
+ "No mounted Abuilder panel. Pass initialWorkspace or mount <App> first."
87
+ );
88
+ }
89
+ return handle.saveWorkspace();
90
+ }
91
+
92
+ export async function syncWorkspace(nameSpace?: string | null): Promise<string> {
93
+ const handle = findHandle(nameSpace);
94
+ if (!handle) {
95
+ throw new Error(
96
+ "No mounted Abuilder panel. Pass initialWorkspace or mount <App> first."
97
+ );
98
+ }
99
+ return handle.syncWorkspace();
100
+ }
@@ -0,0 +1,22 @@
1
+ import { appStorageKey } from "@arronqzy/blueprint-dsl";
2
+
3
+ const CHANNEL = "arronqzy-blueprint-library-changed";
4
+
5
+ export function broadcastBlueprintLibraryChanged(nameSpace?: string | null): void {
6
+ if (typeof BroadcastChannel === "undefined") return;
7
+ const channel = new BroadcastChannel(appStorageKey(CHANNEL, nameSpace));
8
+ channel.postMessage({ type: "changed" });
9
+ channel.close();
10
+ }
11
+
12
+ export function subscribeBlueprintLibraryChanged(
13
+ onChange: () => void,
14
+ nameSpace?: string | null
15
+ ): () => void {
16
+ if (typeof BroadcastChannel === "undefined") {
17
+ return () => {};
18
+ }
19
+ const channel = new BroadcastChannel(appStorageKey(CHANNEL, nameSpace));
20
+ channel.onmessage = () => onChange();
21
+ return () => channel.close();
22
+ }
@@ -1,3 +1,4 @@
1
+ import { normalizeAppNameSpace } from "@arronqzy/blueprint-dsl";
1
2
  import type { WorkspaceProjectRecord } from "./workspace-project-db";
2
3
 
3
4
  export const AbuilderEvents = {
@@ -5,10 +6,16 @@ export const AbuilderEvents = {
5
6
  workspaceAdd: "workspace:add",
6
7
  /** 同步工作区 */
7
8
  workspaceSync: "workspace:sync",
9
+ /** 删除工作区(界面删除,或调用 deleteWorkspaceProject / clearWorkspaceProjects) */
10
+ workspaceDelete: "workspace:delete",
11
+ /** 蓝图库写入 / 删除 / 清空 */
12
+ blueprintLibraryChanged: "blueprint-library:changed",
8
13
  /** 开始上传接口集合 */
9
14
  apiCollectionUploadStart: "api-collection:upload-start",
10
15
  /** 上传接口集合成功 */
11
16
  apiCollectionUploadSuccess: "api-collection:upload-success",
17
+ /** 接口集合写入 / 删除 / 清空 */
18
+ apiCollectionChanged: "api-collection:changed",
12
19
  } as const;
13
20
 
14
21
  export type AbuilderEventName = (typeof AbuilderEvents)[keyof typeof AbuilderEvents];
@@ -16,12 +23,32 @@ export type AbuilderEventName = (typeof AbuilderEvents)[keyof typeof AbuilderEve
16
23
  /** 工作区完整数据(面板 + 蓝图),可用于外部持久化与再次加载 */
17
24
  export type WorkspaceData = WorkspaceProjectRecord;
18
25
 
19
- export type WorkspaceAddEventPayload = WorkspaceData;
20
- export type WorkspaceSyncEventPayload = WorkspaceData;
26
+ export type WorkspaceAddEventPayload = WorkspaceData & {
27
+ /** 所属 nameSpace,默认库为 null */
28
+ nameSpace: string | null;
29
+ };
30
+ export type WorkspaceSyncEventPayload = WorkspaceData & {
31
+ /** 所属 nameSpace,默认库为 null */
32
+ nameSpace: string | null;
33
+ };
34
+ export type WorkspaceDeleteEventPayload = {
35
+ /** 被删除的工作区 id */
36
+ ids: string[];
37
+ /** 所属 nameSpace,默认库为 null */
38
+ nameSpace: string | null;
39
+ };
40
+
41
+ export type BlueprintLibraryChangedEventPayload = {
42
+ nameSpace: string | null;
43
+ action: "put" | "delete" | "clear";
44
+ /** 涉及的蓝图 id;clear 时可能为空 */
45
+ ids?: string[];
46
+ };
21
47
 
22
48
  export type ApiCollectionUploadStartEventPayload = {
23
49
  fileNames: string[];
24
50
  count: number;
51
+ nameSpace: string | null;
25
52
  };
26
53
 
27
54
  export type ApiCollectionUploadSuccessEventPayload = {
@@ -31,19 +58,57 @@ export type ApiCollectionUploadSuccessEventPayload = {
31
58
  apiCount: number;
32
59
  persistLocal: boolean;
33
60
  }>;
61
+ nameSpace: string | null;
62
+ };
63
+
64
+ export type ApiCollectionChangedEventPayload = {
65
+ nameSpace: string | null;
66
+ action: "put" | "delete" | "clear";
67
+ ids?: string[];
34
68
  };
35
69
 
36
70
  export type AbuilderEventPayloadMap = {
37
71
  [AbuilderEvents.workspaceAdd]: WorkspaceAddEventPayload;
38
72
  [AbuilderEvents.workspaceSync]: WorkspaceSyncEventPayload;
73
+ [AbuilderEvents.workspaceDelete]: WorkspaceDeleteEventPayload;
74
+ [AbuilderEvents.blueprintLibraryChanged]: BlueprintLibraryChangedEventPayload;
39
75
  [AbuilderEvents.apiCollectionUploadStart]: ApiCollectionUploadStartEventPayload;
40
76
  [AbuilderEvents.apiCollectionUploadSuccess]: ApiCollectionUploadSuccessEventPayload;
77
+ [AbuilderEvents.apiCollectionChanged]: ApiCollectionChangedEventPayload;
78
+ };
79
+
80
+ export type AddEventSubscriptionOptions = {
81
+ /**
82
+ * 仅接收该 nameSpace 的事件。
83
+ * 省略则接收全部;传入空字符串 / null 表示默认库。
84
+ */
85
+ nameSpace?: string | null;
41
86
  };
42
87
 
43
88
  type EventCallback = (payload: unknown) => void;
44
89
 
45
90
  const listeners = new Map<string, Set<EventCallback>>();
46
91
 
92
+ function resolvePayloadNameSpace(payload: unknown): string | null | undefined {
93
+ if (!payload || typeof payload !== "object") return undefined;
94
+ if (!("nameSpace" in payload)) return undefined;
95
+ const value = (payload as { nameSpace?: unknown }).nameSpace;
96
+ if (value === null || value === undefined) return null;
97
+ if (typeof value === "string") return value;
98
+ return undefined;
99
+ }
100
+
101
+ function matchesNameSpaceFilter(
102
+ payload: unknown,
103
+ filter: string | null | undefined
104
+ ): boolean {
105
+ if (filter === undefined) return true;
106
+ const wanted = normalizeAppNameSpace(filter);
107
+ const actual = resolvePayloadNameSpace(payload);
108
+ if (actual === undefined) return true;
109
+ return normalizeAppNameSpace(actual) === wanted;
110
+ }
111
+
47
112
  export function emitAbuilderEvent<T extends AbuilderEventName>(
48
113
  eventName: T,
49
114
  payload: AbuilderEventPayloadMap[T]
@@ -61,17 +126,26 @@ export function emitAbuilderEvent<T extends AbuilderEventName>(
61
126
 
62
127
  export function addEventSubscription<T extends AbuilderEventName>(
63
128
  eventName: T,
64
- callback: (payload: AbuilderEventPayloadMap[T]) => void
129
+ callback: (payload: AbuilderEventPayloadMap[T]) => void,
130
+ options?: AddEventSubscriptionOptions
65
131
  ): { unsubscribe: () => void } {
66
132
  let set = listeners.get(eventName);
67
133
  if (!set) {
68
134
  set = new Set();
69
135
  listeners.set(eventName, set);
70
136
  }
71
- set.add(callback as EventCallback);
137
+ const filter =
138
+ options && Object.prototype.hasOwnProperty.call(options, "nameSpace")
139
+ ? options.nameSpace
140
+ : undefined;
141
+ const wrapped: EventCallback = (payload) => {
142
+ if (!matchesNameSpaceFilter(payload, filter)) return;
143
+ callback(payload as AbuilderEventPayloadMap[T]);
144
+ };
145
+ set.add(wrapped);
72
146
  return {
73
147
  unsubscribe: () => {
74
- set.delete(callback as EventCallback);
148
+ set.delete(wrapped);
75
149
  if (set.size === 0) {
76
150
  listeners.delete(eventName);
77
151
  }
@@ -11,6 +11,7 @@ export type WorkspaceProjectSyncMessage = {
11
11
 
12
12
  export type WorkspaceProjectsChangedMessage =
13
13
  | { type: "deleted"; projectIds: string[] }
14
+ | { type: "upserted"; projectIds: string[] }
14
15
  | { type: "cleared" };
15
16
 
16
17
  export function broadcastWorkspaceProjectsChanged(
@@ -33,7 +34,12 @@ export function subscribeWorkspaceProjectsChanged(
33
34
  const channel = new BroadcastChannel(appStorageKey(LIST_CHANNEL, nameSpace));
34
35
  channel.onmessage = (event: MessageEvent<WorkspaceProjectsChangedMessage>) => {
35
36
  const data = event.data;
36
- if (!data || (data.type !== "deleted" && data.type !== "cleared")) return;
37
+ if (
38
+ !data ||
39
+ (data.type !== "deleted" && data.type !== "cleared" && data.type !== "upserted")
40
+ ) {
41
+ return;
42
+ }
37
43
  onChange(data);
38
44
  };
39
45
  return () => channel.close();
@@ -1,16 +1,73 @@
1
+ import { normalizeAppNameSpace } from "@arronqzy/blueprint-dsl";
1
2
  import {
2
3
  clearWorkspaceProjectStore,
3
4
  deleteWorkspaceProject as deleteWorkspaceProjectRecord,
5
+ getWorkspaceProject,
4
6
  listWorkspaceProjects,
7
+ putWorkspaceProject as putWorkspaceProjectRecord,
8
+ type WorkspaceProjectRecord,
5
9
  } from "./workspace-project-db";
6
10
  import {
7
11
  clearWorkspacePreviewCaches,
8
12
  removeWorkspacePreviewCache,
13
+ writeWorkspacePreviewCache,
9
14
  } from "./workspace-project-cache";
10
- import { broadcastWorkspaceProjectsChanged } from "./workspace-project-sync";
15
+ import {
16
+ broadcastWorkspaceProjectUpdate,
17
+ broadcastWorkspaceProjectsChanged,
18
+ } from "./workspace-project-sync";
19
+ import { AbuilderEvents, emitAbuilderEvent } from "./event-subscription";
20
+
21
+ export { listWorkspaceProjects, getWorkspaceProject };
22
+ export type { WorkspaceProjectListItem, WorkspaceProjectRecord } from "./workspace-project-db";
23
+
24
+ export type PutWorkspaceProjectOptions = {
25
+ /**
26
+ * 写入后是否发出工作区事件。
27
+ * - `false` / 省略:只写 IndexedDB(宿主自己管后端时常用)
28
+ * - `"add"`:发出 `workspace:add`
29
+ * - `"sync"`:发出 `workspace:sync`
30
+ */
31
+ emitEvent?: false | "add" | "sync";
32
+ };
33
+
34
+ function resolvedNameSpace(nameSpace?: string | null): string | null {
35
+ return normalizeAppNameSpace(nameSpace) || null;
36
+ }
11
37
 
12
- export { listWorkspaceProjects };
13
- export type { WorkspaceProjectListItem } from "./workspace-project-db";
38
+ /**
39
+ * 写入 / 覆盖 IndexedDB 中的工作区(upsert),并更新预览缓存、通知其他标签页。
40
+ * `nameSpace` 需与面板的 `nameSpace` 一致。
41
+ */
42
+ export async function putWorkspaceProject(
43
+ record: WorkspaceProjectRecord,
44
+ nameSpace?: string | null,
45
+ options?: PutWorkspaceProjectOptions
46
+ ): Promise<WorkspaceProjectRecord> {
47
+ const saved = await putWorkspaceProjectRecord(record, nameSpace);
48
+ writeWorkspacePreviewCache(saved, nameSpace);
49
+ broadcastWorkspaceProjectUpdate(saved.id, saved.updatedAt, nameSpace);
50
+ broadcastWorkspaceProjectsChanged(
51
+ { type: "upserted", projectIds: [saved.id] },
52
+ nameSpace
53
+ );
54
+ const ns = resolvedNameSpace(nameSpace);
55
+ if (options?.emitEvent === "add") {
56
+ emitAbuilderEvent(AbuilderEvents.workspaceAdd, { ...saved, nameSpace: ns });
57
+ } else if (options?.emitEvent === "sync") {
58
+ emitAbuilderEvent(AbuilderEvents.workspaceSync, { ...saved, nameSpace: ns });
59
+ }
60
+ return saved;
61
+ }
62
+
63
+ /** `putWorkspaceProject` 的别名 */
64
+ export async function upsertWorkspaceProject(
65
+ record: WorkspaceProjectRecord,
66
+ nameSpace?: string | null,
67
+ options?: PutWorkspaceProjectOptions
68
+ ): Promise<WorkspaceProjectRecord> {
69
+ return putWorkspaceProject(record, nameSpace, options);
70
+ }
14
71
 
15
72
  /**
16
73
  * 删除 IndexedDB 中保存的单个工作区(及其 localStorage 预览标记)。
@@ -23,6 +80,10 @@ export async function deleteWorkspaceProject(
23
80
  await deleteWorkspaceProjectRecord(id, nameSpace);
24
81
  removeWorkspacePreviewCache(id, nameSpace);
25
82
  broadcastWorkspaceProjectsChanged({ type: "deleted", projectIds: [id] }, nameSpace);
83
+ emitAbuilderEvent(AbuilderEvents.workspaceDelete, {
84
+ ids: [id],
85
+ nameSpace: resolvedNameSpace(nameSpace),
86
+ });
26
87
  }
27
88
 
28
89
  /**
@@ -34,5 +95,11 @@ export async function clearWorkspaceProjects(nameSpace?: string | null): Promise
34
95
  await clearWorkspaceProjectStore(nameSpace);
35
96
  clearWorkspacePreviewCaches(nameSpace);
36
97
  broadcastWorkspaceProjectsChanged({ type: "cleared" }, nameSpace);
98
+ if (items.length > 0) {
99
+ emitAbuilderEvent(AbuilderEvents.workspaceDelete, {
100
+ ids: items.map((item) => item.id),
101
+ nameSpace: resolvedNameSpace(nameSpace),
102
+ });
103
+ }
37
104
  return items.length;
38
105
  }