@object-ui/app-shell 11.3.0 → 11.5.0

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 (90) hide show
  1. package/CHANGELOG.md +700 -0
  2. package/README.md +32 -0
  3. package/dist/chrome/KeyboardShortcutsDialog.js +2 -1
  4. package/dist/console/AppContent.js +145 -26
  5. package/dist/console/ConsoleShell.js +25 -3
  6. package/dist/console/home/CloudOnboardingNext.d.ts +9 -0
  7. package/dist/console/home/CloudOnboardingNext.js +14 -4
  8. package/dist/console/home/HomePage.js +34 -7
  9. package/dist/console/organizations/CreateWorkspaceDialog.js +33 -3
  10. package/dist/console/organizations/OrganizationsPage.js +16 -7
  11. package/dist/context/CommandPaletteProvider.js +2 -1
  12. package/dist/hooks/useConsoleActionRuntime.js +32 -3
  13. package/dist/hooks/useObjectActions.js +16 -4
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.js +3 -0
  16. package/dist/layout/AppHeader.js +13 -5
  17. package/dist/layout/AppSidebar.js +10 -4
  18. package/dist/observability/sentry.d.ts +5 -0
  19. package/dist/observability/sentry.js +6 -1
  20. package/dist/preview/DraftChangesPanel.d.ts +31 -1
  21. package/dist/preview/DraftChangesPanel.js +146 -18
  22. package/dist/urlParams.d.ts +68 -0
  23. package/dist/urlParams.js +76 -0
  24. package/dist/utils/appRoute.d.ts +15 -0
  25. package/dist/utils/appRoute.js +22 -0
  26. package/dist/utils/deriveRelatedLists.d.ts +20 -5
  27. package/dist/utils/deriveRelatedLists.js +31 -13
  28. package/dist/utils/index.d.ts +1 -1
  29. package/dist/utils/index.js +1 -1
  30. package/dist/utils/pageTabsUrlSync.d.ts +32 -0
  31. package/dist/utils/pageTabsUrlSync.js +43 -0
  32. package/dist/utils/recordFormNavigation.d.ts +40 -0
  33. package/dist/utils/recordFormNavigation.js +30 -0
  34. package/dist/utils/resolveViewId.d.ts +23 -0
  35. package/dist/utils/resolveViewId.js +37 -0
  36. package/dist/utils/warnSuppressedListNav.d.ts +10 -0
  37. package/dist/utils/warnSuppressedListNav.js +40 -0
  38. package/dist/views/InterfaceListPage.d.ts +1 -0
  39. package/dist/views/InterfaceListPage.js +7 -5
  40. package/dist/views/ObjectDataPage.d.ts +29 -0
  41. package/dist/views/ObjectDataPage.js +227 -0
  42. package/dist/views/ObjectView.js +65 -13
  43. package/dist/views/RecordDetailView.js +191 -123
  44. package/dist/views/RecordFormPage.js +7 -1
  45. package/dist/views/RelatedRecordActionsBridge.d.ts +33 -0
  46. package/dist/views/RelatedRecordActionsBridge.js +147 -0
  47. package/dist/views/metadata-admin/PackagesPage.js +18 -7
  48. package/dist/views/metadata-admin/PermissionMatrixEditor.d.ts +18 -1
  49. package/dist/views/metadata-admin/PermissionMatrixEditor.js +73 -14
  50. package/dist/views/metadata-admin/ResourceEditPage.js +39 -0
  51. package/dist/views/metadata-admin/i18n.d.ts +12 -21
  52. package/dist/views/metadata-admin/i18n.js +557 -6
  53. package/dist/views/metadata-admin/inspectors/AppNavInspector.d.ts +11 -4
  54. package/dist/views/metadata-admin/inspectors/AppNavInspector.js +141 -7
  55. package/dist/views/metadata-admin/inspectors/FlowReferenceField.d.ts +14 -0
  56. package/dist/views/metadata-admin/inspectors/FlowReferenceField.js +76 -5
  57. package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +60 -30
  58. package/dist/views/metadata-admin/inspectors/flow-node-config.d.ts +8 -1
  59. package/dist/views/metadata-admin/inspectors/flow-node-config.js +3 -2
  60. package/dist/views/metadata-admin/inspectors/nav-target.d.ts +52 -0
  61. package/dist/views/metadata-admin/inspectors/nav-target.js +149 -0
  62. package/dist/views/metadata-admin/nav-selection.d.ts +20 -0
  63. package/dist/views/metadata-admin/nav-selection.js +81 -0
  64. package/dist/views/metadata-admin/permission-slice.d.ts +66 -0
  65. package/dist/views/metadata-admin/permission-slice.js +70 -0
  66. package/dist/views/metadata-admin/previews/AppNavCanvas.js +19 -7
  67. package/dist/views/metadata-admin/previews/AppPreview.js +4 -2
  68. package/dist/views/metadata-admin/previews/FlowRunsPanel.d.ts +16 -7
  69. package/dist/views/metadata-admin/previews/FlowRunsPanel.js +18 -2
  70. package/dist/views/studio-design/BuilderLanding.d.ts +15 -0
  71. package/dist/views/studio-design/BuilderLanding.js +126 -0
  72. package/dist/views/studio-design/ObjectFormDesigner.d.ts +33 -0
  73. package/dist/views/studio-design/ObjectFormDesigner.js +231 -0
  74. package/dist/views/studio-design/ObjectSettingsPanel.d.ts +30 -0
  75. package/dist/views/studio-design/ObjectSettingsPanel.js +46 -0
  76. package/dist/views/studio-design/ObjectValidationsPanel.d.ts +30 -0
  77. package/dist/views/studio-design/ObjectValidationsPanel.js +80 -0
  78. package/dist/views/studio-design/PackageIdInput.d.ts +31 -0
  79. package/dist/views/studio-design/PackageIdInput.js +40 -0
  80. package/dist/views/studio-design/StudioDesignSurface.d.ts +13 -0
  81. package/dist/views/studio-design/StudioDesignSurface.js +962 -145
  82. package/dist/views/studio-design/metadataError.d.ts +23 -0
  83. package/dist/views/studio-design/metadataError.js +44 -0
  84. package/dist/views/studio-design/packageSurfaces.d.ts +49 -0
  85. package/dist/views/studio-design/packageSurfaces.js +34 -0
  86. package/dist/views/studio-design/packages-io.d.ts +38 -0
  87. package/dist/views/studio-design/packages-io.js +73 -0
  88. package/dist/views/studio-design/skeletons.d.ts +16 -0
  89. package/dist/views/studio-design/skeletons.js +51 -0
  90. package/package.json +42 -39
@@ -14,21 +14,36 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
14
14
  * surface — it is an injected slot (`aiSlot`) the cloud edition fills.
15
15
  */
16
16
  import * as React from 'react';
17
- import { useParams, Link } from 'react-router-dom';
17
+ import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom';
18
18
  import { SchemaRenderer, useAdapter, SchemaRendererProvider } from '@object-ui/react';
19
19
  import { GridFieldAuthoringProvider } from '@object-ui/components';
20
20
  import { ObjectView as PluginObjectView } from '@object-ui/plugin-view';
21
21
  import { ListView } from '@object-ui/plugin-list';
22
- import { Boxes, FileText, Database, LayoutDashboard, BarChart3, Table2, Folder, Compass, Workflow, SlidersHorizontal, MousePointer2, Eye, Loader2, Save, Pencil, Check, Plus, X, } from 'lucide-react';
22
+ import { ObjectForm } from '@object-ui/plugin-form';
23
+ import { Boxes, FileText, Database, LayoutDashboard, BarChart3, Table2, Folder, Compass, Workflow, SlidersHorizontal, MousePointer2, Eye, Loader2, Save, Pencil, Check, Plus, X, GitBranch, Rocket, ChevronDown, Lock, ExternalLink, Home as HomeIcon, Shield, } from 'lucide-react';
23
24
  import { getMetadataPreview } from '../metadata-admin/preview-registry';
25
+ import { PermissionMatrixEditPage } from '../metadata-admin/PermissionMatrixEditor';
24
26
  import { getMetadataInspector } from '../metadata-admin/inspector-registry';
25
27
  import { useMetadataClient } from '../metadata-admin/useMetadata';
28
+ import { DESIGNER_SEL_PARAM, parseNavSelParam, formatNavSelParam, findNavPositionById, navIdAtPosition, } from '../metadata-admin/nav-selection';
29
+ import { formatMetadataError, formatPublishFailures } from './metadataError';
30
+ import { loadPackageSurfaces } from './packageSurfaces';
31
+ import { buildObjectSkeleton, buildFlowSkeleton, buildAppSkeleton, buildPermissionSkeleton } from './skeletons';
32
+ import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n';
26
33
  import { AppNavCanvas } from '../metadata-admin/previews/AppNavCanvas';
27
- import { readFields, writeFields, newField } from '../metadata-admin/previews/object-fields-io';
34
+ import { readFields, writeFields, newField, toFieldName, toFieldNameLoose, } from '../metadata-admin/previews/object-fields-io';
35
+ import { ObjectFormDesigner } from './ObjectFormDesigner';
36
+ import { ObjectValidationsPanel } from './ObjectValidationsPanel';
37
+ import { ObjectSettingsPanel } from './ObjectSettingsPanel';
38
+ import { fetchPackages, createBasePackage, PACKAGE_ID_RE } from './packages-io';
39
+ import { PackageIdInput, PackageIdSuggestionHint } from './PackageIdInput';
40
+ import { DraftChangesPanel } from '../../preview/DraftChangesPanel';
41
+ import { toast } from 'sonner';
28
42
  const PILLARS = [
29
43
  { key: 'data', label: 'Data', Icon: Database },
30
44
  { key: 'automations', label: 'Automations', Icon: Workflow },
31
45
  { key: 'interfaces', label: 'Interfaces', Icon: LayoutDashboard },
46
+ { key: 'access', label: 'Access', Icon: Shield },
32
47
  ];
33
48
  const KIND_ICON = {
34
49
  group: Folder,
@@ -75,14 +90,283 @@ function extractDraftBody(resp) {
75
90
  return null;
76
91
  return Object.keys(body).length > 0 ? body : null;
77
92
  }
93
+ /** Top-bar package switcher: list app packages (可写 base vs 只读 code), switch by
94
+ * navigation, and create a new writable base inline (POST /packages {id,name}). */
95
+ function PackageSwitcher({ packageId, tab }) {
96
+ const navigate = useNavigate();
97
+ const locale = useMetadataLocale();
98
+ const [open, setOpen] = React.useState(false);
99
+ const [pkgs, setPkgs] = React.useState(null);
100
+ const [creating, setCreating] = React.useState(false);
101
+ const [newName, setNewName] = React.useState('');
102
+ const [newId, setNewId] = React.useState('');
103
+ const [idTouched, setIdTouched] = React.useState(false);
104
+ const [busy, setBusy] = React.useState(false);
105
+ const [err, setErr] = React.useState(null);
106
+ React.useEffect(() => {
107
+ let cancelled = false;
108
+ fetchPackages()
109
+ .then((parsed) => {
110
+ if (!cancelled)
111
+ setPkgs(parsed);
112
+ })
113
+ .catch(() => {
114
+ /* leave null — switcher still works for navigation-free display */
115
+ });
116
+ return () => {
117
+ cancelled = true;
118
+ };
119
+ }, [packageId]);
120
+ const current = pkgs?.find((p) => p.id === packageId) ?? null;
121
+ const doCreate = React.useCallback(async () => {
122
+ const name = newName.trim();
123
+ const id = newId.trim();
124
+ if (!name || !PACKAGE_ID_RE.test(id))
125
+ return;
126
+ setBusy(true);
127
+ setErr(null);
128
+ try {
129
+ await createBasePackage(id, name);
130
+ toast.success(tFormat('engine.studio.pkg.created', locale, { name }));
131
+ setOpen(false);
132
+ setCreating(false);
133
+ setNewName('');
134
+ setNewId('');
135
+ setIdTouched(false);
136
+ navigate(`/studio/${encodeURIComponent(id)}/data`);
137
+ }
138
+ catch (e) {
139
+ setErr(formatMetadataError(e));
140
+ }
141
+ finally {
142
+ setBusy(false);
143
+ }
144
+ }, [newName, newId, navigate]);
145
+ return (_jsxs("div", { className: "relative", children: [_jsxs("button", { type: "button", onClick: () => setOpen((v) => !v), className: "flex items-center gap-1.5 whitespace-nowrap rounded-md px-1.5 py-0.5 text-[13px] font-medium hover:bg-muted", title: t('engine.studio.pkg.switchTitle', locale), children: [_jsx(Boxes, { className: "h-4 w-4" }), " ", current?.name ?? packageId, current && !current.writable && (_jsxs("span", { className: "inline-flex items-center gap-0.5 rounded bg-amber-400/15 px-1.5 py-0.5 text-[10px] font-normal text-amber-600 dark:text-amber-300", children: [_jsx(Lock, { className: "h-2.5 w-2.5" }), " ", t('engine.studio.pkg.readonly', locale)] })), _jsx(ChevronDown, { className: "h-3.5 w-3.5 text-muted-foreground" })] }), open && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-40", onClick: () => setOpen(false) }), _jsxs("div", { className: "absolute left-0 top-full z-50 mt-1 w-80 rounded-lg border bg-background p-1.5 shadow-lg", children: [_jsx("p", { className: "px-2 pb-1 pt-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground", children: t('engine.studio.pkg.heading', locale) }), _jsxs("div", { className: "max-h-64 overflow-auto", children: [pkgs === null && _jsx("p", { className: "px-2 py-2 text-[11px] text-muted-foreground", children: t('engine.studio.loading', locale) }), pkgs?.length === 0 && _jsx("p", { className: "px-2 py-2 text-[11px] text-muted-foreground", children: t('engine.studio.pkg.none', locale) }), pkgs?.map((p) => (_jsxs("button", { type: "button", onClick: () => {
146
+ setOpen(false);
147
+ navigate(`/studio/${encodeURIComponent(p.id)}/${tab}`);
148
+ }, className: 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs ' +
149
+ (p.id === packageId ? 'bg-muted font-medium' : 'hover:bg-muted/60'), children: [_jsx(Boxes, { className: "h-3.5 w-3.5 shrink-0 text-muted-foreground" }), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block truncate", children: p.name }), _jsx("span", { className: "block truncate font-mono text-[10px] text-muted-foreground", children: p.id })] }), p.writable ? (_jsx("span", { className: "rounded bg-emerald-400/15 px-1.5 py-0.5 text-[10px] text-emerald-600 dark:text-emerald-300", children: t('engine.studio.pkg.writable', locale) })) : (_jsxs("span", { className: "inline-flex items-center gap-0.5 rounded bg-amber-400/15 px-1.5 py-0.5 text-[10px] text-amber-600 dark:text-amber-300", children: [_jsx(Lock, { className: "h-2.5 w-2.5" }), " ", t('engine.studio.pkg.readonly', locale)] }))] }, p.id)))] }), _jsx("div", { className: "mt-1 border-t pt-1.5", children: creating ? (_jsxs("div", { className: "flex flex-col gap-1.5 px-1 pb-1", children: [_jsx("input", { autoFocus: true, value: newName, onChange: (e) => {
150
+ setNewName(e.target.value);
151
+ if (!idTouched) {
152
+ const slug = toFieldNameLoose(e.target.value).replace(/_/g, '-');
153
+ setNewId(slug ? `com.example.${slug}` : '');
154
+ }
155
+ }, onKeyDown: (e) => {
156
+ if (e.key === 'Enter')
157
+ void doCreate();
158
+ if (e.key === 'Escape')
159
+ setCreating(false);
160
+ }, placeholder: t('engine.studio.pkg.namePlaceholder', locale), className: "h-7 w-full rounded-md border bg-background px-2 text-[11px] outline-none focus:ring-1 focus:ring-primary" }), _jsx(PackageIdSuggestionHint, { show: !idTouched && !!newName.trim() && !newId, locale: locale }), _jsx(PackageIdInput, { value: newId, onChange: (v) => {
161
+ setIdTouched(true);
162
+ setNewId(v);
163
+ }, onEnter: () => void doCreate(), onEscape: () => setCreating(false), placeholder: t('engine.studio.pkg.idPlaceholder', locale), locale: locale, testId: "pkg-switcher-id-input" }), err && _jsx("p", { className: "text-[10px] text-destructive", children: err }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsxs("button", { type: "button", onClick: () => void doCreate(), disabled: busy || !newName.trim() || !PACKAGE_ID_RE.test(newId.trim()), className: "inline-flex flex-1 items-center justify-center gap-1 rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground disabled:opacity-50", children: [busy ? _jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : _jsx(Plus, { className: "h-3 w-3" }), t('engine.studio.pkg.createWritable', locale)] }), _jsx("button", { type: "button", onClick: () => setCreating(false), className: "rounded-md border px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted", children: t('engine.studio.cancel', locale) })] })] })) : (_jsxs("button", { type: "button", onClick: () => setCreating(true), className: "flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), " ", t('engine.studio.pkg.new', locale)] })) })] })] }))] }));
164
+ }
78
165
  export function StudioDesignSurface({ aiSlot }) {
79
166
  const params = useParams();
80
167
  const packageId = params.packageId ?? 'com.example.showcase';
81
168
  const tab = params.tab ?? 'interfaces';
82
- return (_jsxs("div", { className: "flex h-screen w-full overflow-hidden bg-background text-foreground", children: [aiSlot ? _jsx("aside", { className: "w-64 shrink-0 overflow-auto border-r bg-muted/40", children: aiSlot }) : null, _jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsxs("header", { className: "flex items-center gap-3 border-b px-3 py-2", children: [_jsxs("span", { className: "flex items-center gap-1.5 whitespace-nowrap text-[13px] font-medium", children: [_jsx(Boxes, { className: "h-4 w-4" }), " ", packageId] }), _jsx("span", { className: "text-muted-foreground", children: "\u00B7" }), _jsx("nav", { className: "flex gap-1", children: PILLARS.map((p) => (_jsxs(Link, { to: `/studio/${packageId}/${p.key}`, className: 'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs transition-colors ' +
169
+ const locale = useMetadataLocale();
170
+ // Courtesy gate (ADR-0057 D10): a read-only code/installed package refuses
171
+ // authoring server-side (ADR-0070), so don't let the user build up doomed
172
+ // local edits first — disable the authoring affordances up front. Unknown
173
+ // writability (fetch failed / still loading) stays ungated; the server gate
174
+ // remains the authority either way.
175
+ const [pkgWritable, setPkgWritable] = React.useState(null);
176
+ React.useEffect(() => {
177
+ let cancelled = false;
178
+ setPkgWritable(null);
179
+ fetchPackages()
180
+ .then((list) => {
181
+ if (!cancelled)
182
+ setPkgWritable(list.find((p) => p.id === packageId)?.writable ?? null);
183
+ })
184
+ .catch(() => {
185
+ /* unknown — leave ungated */
186
+ });
187
+ return () => {
188
+ cancelled = true;
189
+ };
190
+ }, [packageId]);
191
+ const readOnly = pkgWritable === false;
192
+ // Package-level publish (ADR-0033/0037/0048): edits accumulate as per-item
193
+ // drafts STAMPED with this package (each save passes packageId → the draft row's
194
+ // sys_metadata.package_id). Publishing promotes exactly THIS package's drafts in
195
+ // one atomic pass (POST /packages/:id/publish-drafts), reviewed as a whole in
196
+ // DraftChangesPanel. There is no per-item publish.
197
+ const [changesOpen, setChangesOpen] = React.useState(false);
198
+ const [pendingCount, setPendingCount] = React.useState(null);
199
+ const [publishing, setPublishing] = React.useState(false);
200
+ const [publishNonce, setPublishNonce] = React.useState(0); // ↑ → pillars re-read the published baseline
201
+ const [draftNonce, setDraftNonce] = React.useState(0); // ↑ → refresh the pending-draft count
202
+ const refreshPending = React.useCallback(async () => {
203
+ try {
204
+ const res = await fetch(`/api/v1/meta/_drafts?packageId=${encodeURIComponent(packageId)}`, {
205
+ credentials: 'include',
206
+ headers: { Accept: 'application/json' },
207
+ cache: 'no-store',
208
+ });
209
+ if (!res.ok)
210
+ return setPendingCount(null);
211
+ const data = (await res.json());
212
+ const list = (Array.isArray(data) ? data : (data?.drafts ?? []));
213
+ setPendingCount(list.length);
214
+ }
215
+ catch {
216
+ setPendingCount(null);
217
+ }
218
+ }, [packageId]);
219
+ React.useEffect(() => {
220
+ void refreshPending();
221
+ }, [refreshPending, publishNonce, draftNonce]);
222
+ const doPublish = React.useCallback(async () => {
223
+ setPublishing(true);
224
+ try {
225
+ const res = await fetch(`/api/v1/packages/${encodeURIComponent(packageId)}/publish-drafts`, {
226
+ method: 'POST',
227
+ credentials: 'include',
228
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
229
+ body: '{}',
230
+ });
231
+ const payload = (await res.json().catch(() => null));
232
+ if (!res.ok || payload?.success === false) {
233
+ // Hard failure (e.g. package not found) — carry the field-anchored issues.
234
+ throw Object.assign(new Error(payload?.error?.message || `HTTP ${res.status}`), {
235
+ issues: payload?.error?.details?.issues,
236
+ });
237
+ }
238
+ const failed = payload?.data?.failed ?? [];
239
+ if (failed.length > 0) {
240
+ // Partial publish: some drafts did NOT go live. The server returns 200
241
+ // with them buried in `failed[]`, so the UI used to claim success and
242
+ // swallow the reason — surface which drafts failed and why instead.
243
+ toast.error(formatPublishFailures(failed));
244
+ }
245
+ else {
246
+ toast.success(t('engine.studio.publishedAll', locale));
247
+ setChangesOpen(false);
248
+ }
249
+ setPublishNonce((n) => n + 1);
250
+ }
251
+ catch (e) {
252
+ toast.error(formatMetadataError(e));
253
+ }
254
+ finally {
255
+ setPublishing(false);
256
+ }
257
+ await refreshPending();
258
+ }, [packageId, refreshPending]);
259
+ const onDraftSaved = React.useCallback(() => setDraftNonce((n) => n + 1), []);
260
+ const hasPending = (pendingCount ?? 0) > 0;
261
+ // Builder → running-app bridge (Airtable's Launch): the builder edits the
262
+ // package (设计界面), the app is its published front-end. If this package
263
+ // ships an app, offer 打开应用 — opened in a new tab so the builder context
264
+ // survives. (App → builder is the reverse bridge, tracked separately.)
265
+ const shellNavigate = useNavigate();
266
+ const shellClient = useMetadataClient();
267
+ const [packageApp, setPackageApp] = React.useState(null);
268
+ // 创建应用 (package has no app yet): create a draft `app` item — the published
269
+ // front-end's on-ramp. The button flips to 打开应用 after the package publish.
270
+ const [appCreating, setAppCreating] = React.useState(false);
271
+ const [appLabel, setAppLabel] = React.useState('');
272
+ const [appName, setAppName] = React.useState('');
273
+ const [appNameTouched, setAppNameTouched] = React.useState(false);
274
+ const [appBusy, setAppBusy] = React.useState(false);
275
+ const [appDraftPending, setAppDraftPending] = React.useState(null);
276
+ // Scaffold the new app's navigation from the package's objects (default on) —
277
+ // otherwise a fresh app has zero menu items and every object must be wired by
278
+ // hand in the Interfaces pillar (objectui#2262).
279
+ const [appAddObjects, setAppAddObjects] = React.useState(true);
280
+ const loadPackageObjects = React.useCallback(async () => {
281
+ // Published objects + pending DRAFT objects, merged — a fresh package's
282
+ // objects are usually still drafts (same merge the Data pillar rail does).
283
+ const [list, draftHeaders] = await Promise.all([
284
+ shellClient.list('object', { packageId }),
285
+ shellClient.listDrafts({ packageId, type: 'object' }).catch(() => []),
286
+ ]);
287
+ const items = (list || [])
288
+ .map((o) => ({ name: String(o.name ?? ''), label: String(o.label ?? o.name ?? '') }))
289
+ .filter((o) => o.name);
290
+ const known = new Set(items.map((o) => o.name));
291
+ for (const d of draftHeaders) {
292
+ if (d.name && !known.has(d.name))
293
+ items.push({ name: d.name, label: d.name });
294
+ }
295
+ return items;
296
+ }, [shellClient, packageId]);
297
+ const doCreateApp = React.useCallback(async () => {
298
+ const label = appLabel.trim();
299
+ const name = toFieldName(appName.trim() || label);
300
+ if (!label || !name || name === 'field')
301
+ return;
302
+ setAppBusy(true);
303
+ try {
304
+ const navObjects = appAddObjects ? await loadPackageObjects().catch(() => []) : [];
305
+ await shellClient.save('app', name, buildAppSkeleton(name, label, navObjects), { mode: 'draft', packageId });
306
+ toast.success(tFormat('engine.studio.app.savedDraft', locale, { label }));
307
+ setAppDraftPending(label);
308
+ setAppCreating(false);
309
+ setAppLabel('');
310
+ setAppName('');
311
+ setAppNameTouched(false);
312
+ setDraftNonce((n) => n + 1);
313
+ }
314
+ catch (e) {
315
+ toast.error(formatMetadataError(e));
316
+ }
317
+ finally {
318
+ setAppBusy(false);
319
+ }
320
+ }, [appLabel, appName, appAddObjects, loadPackageObjects, shellClient, packageId]);
321
+ React.useEffect(() => {
322
+ let cancelled = false;
323
+ (async () => {
324
+ try {
325
+ const apps = (await shellClient.list('app', { packageId }));
326
+ const first = (apps || [])
327
+ .map((a) => ({ name: String(a.name ?? ''), label: String(a.label ?? a.name ?? '') }))
328
+ .filter((a) => a.name)[0];
329
+ if (!cancelled)
330
+ setPackageApp(first ?? null);
331
+ }
332
+ catch {
333
+ if (!cancelled)
334
+ setPackageApp(null);
335
+ }
336
+ })();
337
+ return () => {
338
+ cancelled = true;
339
+ };
340
+ }, [shellClient, packageId, publishNonce]);
341
+ return (_jsxs("div", { className: "flex h-screen w-full overflow-hidden bg-background text-foreground", children: [aiSlot ? _jsx("aside", { className: "w-64 shrink-0 overflow-auto border-r bg-muted/40", children: aiSlot }) : null, _jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsxs("header", { className: "flex items-center gap-3 border-b px-3 py-2", children: [_jsx("button", { type: "button", onClick: () => shellNavigate('/home'), title: t('engine.studio.home', locale), className: "rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground", children: _jsx(HomeIcon, { className: "h-4 w-4" }) }), _jsx(PackageSwitcher, { packageId: packageId, tab: tab }), _jsx("span", { className: "text-muted-foreground", children: "\u00B7" }), _jsx("nav", { className: "flex gap-1", children: PILLARS.map((p) => (_jsxs(Link, { to: `/studio/${packageId}/${p.key}`, className: 'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs transition-colors ' +
83
342
  (tab === p.key
84
343
  ? 'bg-primary/10 font-medium text-primary'
85
- : 'text-muted-foreground hover:bg-muted hover:text-foreground'), children: [_jsx(p.Icon, { className: "h-3.5 w-3.5" }), p.label] }, p.key))) })] }), _jsx("div", { className: "min-h-0 flex-1", children: tab === 'data' ? (_jsx(DataPillar, { packageId: packageId })) : tab === 'automations' ? (_jsx(AutomationsPillar, { packageId: packageId })) : (_jsx(InterfacesPillar, { packageId: packageId })) })] })] }));
344
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground'), children: [_jsx(p.Icon, { className: "h-3.5 w-3.5" }), t(`engine.studio.pillar.${p.key}`, locale)] }, p.key))) }), _jsxs("div", { className: "ml-auto flex items-center gap-2", children: [packageApp ? (_jsxs("button", { type: "button", onClick: () => window.open(`/apps/${encodeURIComponent(packageApp.name)}`, '_blank'), title: tFormat('engine.studio.app.openTitle', locale, { label: packageApp.label }), className: "inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(ExternalLink, { className: "h-3.5 w-3.5" }), t('engine.studio.app.open', locale)] })) : appDraftPending ? (_jsx("span", { title: t('engine.studio.app.willOpenAfterPublish', locale), className: "rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: tFormat('engine.studio.app.pending', locale, { label: appDraftPending }) })) : (_jsxs("div", { className: "relative", children: [_jsxs("button", { type: "button", onClick: () => setAppCreating((v) => !v), disabled: readOnly, title: readOnly ? t('engine.studio.pkg.readonlyHint', locale) : t('engine.studio.app.noneTitle', locale), className: "inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), t('engine.studio.app.create', locale)] }), appCreating && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-40", onClick: () => setAppCreating(false) }), _jsxs("div", { className: "absolute right-0 top-full z-50 mt-1 flex w-72 flex-col gap-1.5 rounded-lg border bg-background p-2 shadow-lg", children: [_jsx("input", { autoFocus: true, value: appLabel, onChange: (e) => {
345
+ setAppLabel(e.target.value);
346
+ if (!appNameTouched)
347
+ setAppName(toFieldNameLoose(e.target.value));
348
+ }, onKeyDown: (e) => {
349
+ if (e.key === 'Enter')
350
+ void doCreateApp();
351
+ if (e.key === 'Escape')
352
+ setAppCreating(false);
353
+ }, placeholder: t('engine.studio.app.namePlaceholder', locale), className: "h-7 w-full rounded-md border bg-background px-2 text-[11px] outline-none focus:ring-1 focus:ring-primary" }), _jsx("input", { value: appName, onChange: (e) => {
354
+ setAppNameTouched(true);
355
+ setAppName(toFieldNameLoose(e.target.value));
356
+ }, onKeyDown: (e) => {
357
+ if (e.key === 'Enter')
358
+ void doCreateApp();
359
+ if (e.key === 'Escape')
360
+ setAppCreating(false);
361
+ }, placeholder: t('engine.studio.app.idPlaceholder', locale), className: "h-7 w-full rounded-md border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-primary" }), _jsxs("label", { className: "flex cursor-pointer items-center gap-1.5 px-0.5 py-0.5 text-[11px] text-muted-foreground", children: [_jsx("input", { type: "checkbox", checked: appAddObjects, onChange: (e) => setAppAddObjects(e.target.checked), className: "h-3 w-3 accent-primary" }), t('engine.studio.app.scaffoldNav', locale)] }), _jsxs("button", { type: "button", onClick: () => void doCreateApp(), disabled: appBusy || !appLabel.trim() || !toFieldName(appName.trim() || appLabel) || toFieldName(appName.trim() || appLabel) === 'field', className: "inline-flex items-center justify-center gap-1 rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground disabled:opacity-50", children: [appBusy ? _jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : _jsx(Plus, { className: "h-3 w-3" }), t('engine.studio.createDraft', locale)] })] })] }))] })), _jsxs("button", { type: "button", onClick: () => setChangesOpen(true), className: "inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(GitBranch, { className: "h-3.5 w-3.5" }), t('engine.studio.changes', locale), hasPending ? ` · ${pendingCount}` : ''] }), _jsxs("button", { type: "button",
362
+ // Publish is review-then-confirm: open the pending-changes panel,
363
+ // whose footer button fires the actual atomic package publish —
364
+ // never straight from this header click (objectui#2261).
365
+ onClick: () => setChangesOpen(true), disabled: publishing || !hasPending || readOnly, title: readOnly
366
+ ? t('engine.studio.pkg.readonlyHint', locale)
367
+ : hasPending
368
+ ? t('engine.studio.publishTitle', locale)
369
+ : t('engine.studio.publishNoneTitle', locale), className: "inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground disabled:opacity-50", children: [publishing ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : _jsx(Rocket, { className: "h-3.5 w-3.5" }), t('engine.studio.publish', locale)] })] })] }), _jsx("div", { className: "min-h-0 flex-1", children: tab === 'data' ? (_jsx(DataPillar, { packageId: packageId, publishNonce: publishNonce, onDraftSaved: onDraftSaved, readOnly: readOnly })) : tab === 'automations' ? (_jsx(AutomationsPillar, { packageId: packageId, publishNonce: publishNonce, onDraftSaved: onDraftSaved, readOnly: readOnly })) : tab === 'access' ? (_jsx(AccessPillar, { packageId: packageId, publishNonce: publishNonce, onDraftSaved: onDraftSaved, readOnly: readOnly })) : (_jsx(InterfacesPillar, { packageId: packageId, publishNonce: publishNonce, draftNonce: draftNonce, onDraftSaved: onDraftSaved, onCreateApp: readOnly ? undefined : () => setAppCreating(true), readOnly: readOnly })) })] }), _jsx(DraftChangesPanel, { open: changesOpen, onOpenChange: setChangesOpen, packageId: packageId, onPublish: readOnly ? undefined : doPublish, publishing: publishing })] }));
86
370
  }
87
371
  /** Recursive App-navigation tree (groups + typed leaves). */
88
372
  function NavTree({ nodes, active, onPick, }) {
@@ -98,9 +382,60 @@ function NavTree({ nodes, active, onPick, }) {
98
382
  }) }));
99
383
  }
100
384
  /** Interfaces pillar — real App nav · live canvas · inspector. */
101
- function InterfacesPillar({ packageId }) {
385
+ /**
386
+ * StudioNavItemInspector — right-panel editor for the selected nav item while
387
+ * editing an app's navigation. The Studio adds flat top-level items
388
+ * (`navigation[i]`), so binding is a business-friendly object picker rather
389
+ * than the raw path field of the generic AppNavInspector: picking an object
390
+ * writes `{ object }` (which the runtime resolves to that object's record
391
+ * list) and, if the label is still the placeholder, adopts the object's label.
392
+ */
393
+ function StudioNavItemInspector({ navId, appDraft, objects, onNavPatch, onClear, }) {
394
+ const locale = useMetadataLocale();
395
+ const idx = React.useMemo(() => {
396
+ const m = /^navigation\[(\d+)\]$/.exec(navId);
397
+ return m ? Number(m[1]) : -1;
398
+ }, [navId]);
399
+ const nav = React.useMemo(() => (Array.isArray(appDraft.navigation) ? appDraft.navigation : []), [appDraft]);
400
+ const node = idx >= 0 ? nav[idx] : null;
401
+ if (!node) {
402
+ return (_jsx("div", { className: "px-2 py-10 text-center text-xs text-muted-foreground", children: t('engine.studio.nav.selectItem', locale) }));
403
+ }
404
+ const patch = (updates) => {
405
+ onNavPatch({ navigation: nav.map((n, i) => (i === idx ? { ...n, ...updates } : n)) });
406
+ };
407
+ const boundObject = String(node.object ?? node.objectName ?? '');
408
+ const curLabel = String(node.label ?? node.title ?? node.name ?? '');
409
+ // A nav card is a placeholder until its label is edited or a target adopts a
410
+ // real label. Match both the legacy English sentinel and the locale-specific
411
+ // default from AppNavCanvas so items created in any locale are recognized.
412
+ const isPlaceholder = !curLabel || curLabel === 'New item' || curLabel === t('engine.appNav.newItem', locale);
413
+ return (_jsxs("div", { className: "space-y-3", children: [_jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-[11px] font-medium text-muted-foreground", children: t('engine.studio.nav.label', locale) }), _jsx("input", { value: curLabel, onChange: (e) => patch({ label: e.target.value }), placeholder: t('engine.studio.nav.labelPlaceholder', locale), className: "w-full rounded border bg-background px-2 py-1 text-xs" })] }), _jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-[11px] font-medium text-muted-foreground", children: t('engine.studio.nav.linkObject', locale) }), _jsxs("select", { value: boundObject, onChange: (e) => {
414
+ const objName = e.target.value;
415
+ const obj = objects.find((o) => o.name === objName);
416
+ if (!objName) {
417
+ // Unbind → back to an (invalid, dropped-on-save) placeholder.
418
+ patch({ type: undefined, objectName: undefined, object: undefined });
419
+ return;
420
+ }
421
+ // Emit a spec-complete ObjectNavItem: the app schema's nav is a
422
+ // discriminated union on `type` and BaseNavItem requires a
423
+ // snake_case `id`. Missing either fails "navigation.0: Invalid
424
+ // input" at save. `object`/`path` are cleared so no stray keys
425
+ // linger from the blank placeholder.
426
+ patch({
427
+ id: node.id || `nav_${objName}`,
428
+ type: 'object',
429
+ objectName: objName,
430
+ object: undefined,
431
+ path: undefined,
432
+ label: isPlaceholder && obj ? obj.label : curLabel,
433
+ });
434
+ }, className: "w-full rounded border bg-background px-2 py-1 text-xs", children: [_jsx("option", { value: "", children: t('engine.studio.nav.chooseObject', locale) }), objects.map((o) => (_jsxs("option", { value: o.name, children: [o.label, " (", o.name, ")"] }, o.name)))] }), _jsx("p", { className: "mt-1 text-[11px] text-muted-foreground", children: boundObject ? t('engine.studio.nav.boundHint', locale) : t('engine.studio.nav.unboundHint', locale) }), objects.length === 0 && (_jsx("p", { className: "mt-1 text-[11px] text-amber-600 dark:text-amber-400", children: t('engine.studio.nav.noObjects', locale) }))] }), _jsx("button", { type: "button", onClick: onClear, className: "text-[11px] text-muted-foreground underline-offset-2 hover:underline", children: t('engine.studio.deselect', locale) })] }));
435
+ }
436
+ function InterfacesPillar({ packageId, publishNonce = 0, draftNonce = 0, onDraftSaved, onCreateApp, readOnly = false, }) {
102
437
  const client = useMetadataClient();
103
- const locale = 'zh-CN';
438
+ const locale = useMetadataLocale();
104
439
  const [appLabel, setAppLabel] = React.useState(packageId);
105
440
  const [appName, setAppName] = React.useState(null);
106
441
  const [appDraft, setAppDraft] = React.useState({});
@@ -108,6 +443,37 @@ function InterfacesPillar({ packageId }) {
108
443
  // nav editing — drag-drop reorder / rename / add / remove via AppNavCanvas
109
444
  const [editNav, setEditNav] = React.useState(false);
110
445
  const [navSel, setNavSel] = React.useState(null);
446
+ // #2272 — designer deep-link: `?sel=nav:<id>` selects the nav item with
447
+ // that spec `id` and switches the pillar into nav editing. The id is the
448
+ // stable external contract; positional `navigation[i]` selection ids stay
449
+ // internal. Selection changes mirror back to the URL (replace).
450
+ const [searchParams, setSearchParams] = useSearchParams();
451
+ const navSelParam = parseNavSelParam(searchParams.get(DESIGNER_SEL_PARAM));
452
+ const appliedNavSelRef = React.useRef(null);
453
+ React.useEffect(() => {
454
+ if (!navSelParam || navTree.length === 0)
455
+ return;
456
+ if (appliedNavSelRef.current === navSelParam)
457
+ return;
458
+ const hit = findNavPositionById({ navigation: navTree }, navSelParam);
459
+ if (!hit)
460
+ return;
461
+ appliedNavSelRef.current = navSelParam;
462
+ setEditNav(true);
463
+ setNavSel({ kind: 'nav', id: hit.selectionId });
464
+ }, [navSelParam, navTree]);
465
+ React.useEffect(() => {
466
+ const navId = navSel ? navIdAtPosition({ navigation: navTree }, navSel.id) : null;
467
+ setSearchParams((prev) => {
468
+ const next = new URLSearchParams(prev);
469
+ if (navId)
470
+ next.set(DESIGNER_SEL_PARAM, formatNavSelParam(navId));
471
+ else
472
+ next.delete(DESIGNER_SEL_PARAM);
473
+ return next;
474
+ }, { replace: true });
475
+ // eslint-disable-next-line react-hooks/exhaustive-deps
476
+ }, [navSel]);
111
477
  const [navDirty, setNavDirty] = React.useState(false);
112
478
  const [navHasDraft, setNavHasDraft] = React.useState(false);
113
479
  const [navSaving, setNavSaving] = React.useState(false);
@@ -118,22 +484,77 @@ function InterfacesPillar({ packageId }) {
118
484
  const [saving, setSaving] = React.useState(false);
119
485
  const [hasDraft, setHasDraft] = React.useState(false);
120
486
  const [error, setError] = React.useState(null);
121
- // Resolve the App by package id load its navigation tree.
487
+ // App resolution status tells "still loading" apart from "this package has
488
+ // no app", so the canvas shows a real empty state instead of an endless
489
+ // spinner.
490
+ const [appStatus, setAppStatus] = React.useState('loading');
491
+ // Objects in THIS package (published ∪ draft) — the nav item inspector's
492
+ // object picker, so nav can be wired to sibling objects before publishing.
493
+ const [pkgObjects, setPkgObjects] = React.useState([]);
122
494
  React.useEffect(() => {
123
495
  let cancelled = false;
124
496
  (async () => {
125
497
  try {
126
- const apps = (await client.list('app'));
498
+ const [pub, drafts] = await Promise.all([
499
+ client.list('object', { packageId }),
500
+ client.listDrafts({ packageId, type: 'object' }).catch(() => []),
501
+ ]);
127
502
  if (cancelled)
128
503
  return;
129
- const app = (apps || []).find((a) => a._packageId === packageId || a.packageId === packageId || a.name === packageId) ?? (apps || [])[0];
130
- if (!app)
504
+ const byName = new Map();
505
+ for (const raw of [...(pub || []), ...(drafts || [])]) {
506
+ const o = raw;
507
+ const name = String(o.name ?? '');
508
+ if (!name || byName.has(name))
509
+ continue;
510
+ byName.set(name, { name, label: String(o.label ?? o.name ?? name) });
511
+ }
512
+ setPkgObjects([...byName.values()]);
513
+ }
514
+ catch {
515
+ /* non-fatal — picker just stays empty */
516
+ }
517
+ })();
518
+ return () => {
519
+ cancelled = true;
520
+ };
521
+ }, [client, packageId, publishNonce, draftNonce]);
522
+ // Resolve THIS package's App → load its navigation tree. The query is scoped
523
+ // to the package (`list('app', { packageId })`) so a design surface only ever
524
+ // shows the current package's app — never another package's. `list()` sees
525
+ // published metadata only, so a freshly-created (unpublished) app is found via
526
+ // `listDrafts()` instead, keeping it designable before its first publish.
527
+ React.useEffect(() => {
528
+ let cancelled = false;
529
+ setAppStatus('loading');
530
+ (async () => {
531
+ try {
532
+ const published = (await client.list('app', { packageId }));
533
+ if (cancelled)
131
534
  return;
132
- setAppLabel(String(app.label ?? app.name ?? packageId));
133
- setAppName(String(app.name));
535
+ let name = published?.[0]?.name ? String(published[0].name) : null;
536
+ let label = published?.[0]
537
+ ? String(published[0].label ?? published[0].name ?? packageId)
538
+ : packageId;
539
+ if (!name) {
540
+ const drafts = await client.listDrafts({ packageId, type: 'app' });
541
+ if (cancelled)
542
+ return;
543
+ const d = drafts?.[0];
544
+ if (d?.name) {
545
+ name = String(d.name);
546
+ label = String(d.name);
547
+ }
548
+ }
549
+ if (!name) {
550
+ setAppStatus('missing');
551
+ return;
552
+ }
553
+ setAppLabel(label);
554
+ setAppName(name);
134
555
  const [layRaw, appDraftResp] = await Promise.all([
135
- client.layered('app', String(app.name)),
136
- client.getDraft('app', String(app.name)).catch(() => null),
556
+ client.layered('app', name),
557
+ client.getDraft('app', name).catch(() => null),
137
558
  ]);
138
559
  if (cancelled)
139
560
  return;
@@ -141,8 +562,12 @@ function InterfacesPillar({ packageId }) {
141
562
  const eff = (lay.effective ?? lay.code ?? {});
142
563
  const appDraftBody = extractDraftBody(appDraftResp);
143
564
  const body = appDraftBody ? { ...eff, ...appDraftBody } : eff;
565
+ if (typeof body.label === 'string' || typeof body.name === 'string') {
566
+ setAppLabel(String(body.label ?? body.name ?? label));
567
+ }
144
568
  setAppDraft(body);
145
569
  setNavHasDraft(!!appDraftBody);
570
+ setAppStatus('ready');
146
571
  const tree = Array.isArray(body.navigation) ? body.navigation : [];
147
572
  // auto-open the first resolvable leaf
148
573
  const firstLeaf = (function find(nodes) {
@@ -163,14 +588,16 @@ function InterfacesPillar({ packageId }) {
163
588
  setCurrent((cur) => cur ?? firstLeaf);
164
589
  }
165
590
  catch (e) {
166
- if (!cancelled)
167
- setError(e instanceof Error ? e.message : String(e));
591
+ if (!cancelled) {
592
+ setError(formatMetadataError(e));
593
+ setAppStatus('missing');
594
+ }
168
595
  }
169
596
  })();
170
597
  return () => {
171
598
  cancelled = true;
172
599
  };
173
- }, [client, packageId]);
600
+ }, [client, packageId, publishNonce, draftNonce]);
174
601
  const Preview = getMetadataPreview(current?.type ?? '');
175
602
  const Inspector = getMetadataInspector(current?.type ?? '');
176
603
  // Object leaves render as a runtime records grid (preview = runtime); schema
@@ -204,7 +631,7 @@ function InterfacesPillar({ packageId }) {
204
631
  }
205
632
  catch (e) {
206
633
  if (!cancelled)
207
- setError(e instanceof Error ? e.message : String(e));
634
+ setError(formatMetadataError(e));
208
635
  }
209
636
  finally {
210
637
  if (!cancelled)
@@ -214,38 +641,24 @@ function InterfacesPillar({ packageId }) {
214
641
  return () => {
215
642
  cancelled = true;
216
643
  };
217
- }, [client, current, isEditable]);
644
+ }, [client, current, isEditable, publishNonce]);
218
645
  const onPatch = React.useCallback((patch) => setDraft((d) => ({ ...d, ...patch })), []);
219
646
  const doSave = React.useCallback(async () => {
220
647
  if (!current)
221
648
  return;
222
649
  setSaving('draft');
223
650
  try {
224
- await client.save(current.type, current.name, draft, { mode: 'draft' });
651
+ await client.save(current.type, current.name, draft, { mode: 'draft', packageId });
225
652
  setHasDraft(true);
653
+ onDraftSaved?.();
226
654
  }
227
655
  catch (e) {
228
- setError(e instanceof Error ? e.message : String(e));
229
- }
230
- finally {
231
- setSaving(false);
232
- }
233
- }, [client, current, draft]);
234
- const doPublish = React.useCallback(async () => {
235
- if (!current)
236
- return;
237
- setSaving('publish');
238
- try {
239
- await client.publish(current.type, current.name);
240
- setHasDraft(false);
241
- }
242
- catch (e) {
243
- setError(e instanceof Error ? e.message : String(e));
656
+ setError(formatMetadataError(e));
244
657
  }
245
658
  finally {
246
659
  setSaving(false);
247
660
  }
248
- }, [client, current]);
661
+ }, [client, current, draft, onDraftSaved]);
249
662
  // nav editing — patch appDraft.navigation, then save/publish the App overlay
250
663
  const onNavPatch = React.useCallback((patch) => {
251
664
  setAppDraft((d) => ({ ...d, ...patch }));
@@ -256,37 +669,39 @@ function InterfacesPillar({ packageId }) {
256
669
  return;
257
670
  setNavSaving('draft');
258
671
  try {
259
- await client.save('app', appName, appDraft, { mode: 'draft' });
672
+ // "Add nav item" inserts a blank placeholder that only becomes a valid,
673
+ // spec-conformant item once a target is picked in the inspector. Drop
674
+ // still-untargeted placeholders (no `type`) so one stray blank can't fail
675
+ // the whole app's spec validation ("navigation.N: Invalid input"), and
676
+ // backfill a snake_case id defensively.
677
+ const rawNav = Array.isArray(appDraft.navigation) ? appDraft.navigation : [];
678
+ const cleanedNav = rawNav
679
+ .filter((n) => n && typeof n.type === 'string')
680
+ .map((n, i) => {
681
+ const item = n;
682
+ return typeof item.id === 'string' && item.id ? item : { ...item, id: `nav_item_${i + 1}` };
683
+ });
684
+ await client.save('app', appName, { ...appDraft, navigation: cleanedNav }, { mode: 'draft', packageId });
260
685
  setNavHasDraft(true);
261
686
  setNavDirty(false);
687
+ onDraftSaved?.();
262
688
  }
263
689
  catch (e) {
264
- setError(e instanceof Error ? e.message : String(e));
265
- }
266
- finally {
267
- setNavSaving(false);
268
- }
269
- }, [client, appName, appDraft]);
270
- const doNavPublish = React.useCallback(async () => {
271
- if (!appName)
272
- return;
273
- setNavSaving('publish');
274
- try {
275
- await client.publish('app', appName);
276
- setNavHasDraft(false);
277
- }
278
- catch (e) {
279
- setError(e instanceof Error ? e.message : String(e));
690
+ setError(formatMetadataError(e));
280
691
  }
281
692
  finally {
282
693
  setNavSaving(false);
283
694
  }
284
- }, [client, appName]);
285
- return (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center gap-2 border-b px-3 py-1.5", children: [current ? (_jsxs("span", { className: "flex items-center gap-1.5 text-[11px] text-muted-foreground", children: [_jsx("span", { className: "text-[13px] font-medium text-foreground", children: current.label }), _jsxs("span", { className: "rounded bg-muted px-1.5 py-0.5", children: [current.type, " \u00B7 ", current.name] })] })) : (_jsx("span", { className: "text-[11px] text-muted-foreground", children: "\u4ECE\u5DE6\u4FA7\u9009\u62E9\u4E00\u4E2A\u83DC\u5355\u9879" })), hasDraft && (_jsx("span", { className: "rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: "\u672A\u53D1\u5E03\u8349\u7A3F" })), _jsxs("button", { onClick: doSave, disabled: !current || !isEditable || !!saving, className: "ml-auto inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-muted disabled:opacity-50", children: [saving === 'draft' ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : _jsx(Save, { className: "h-3.5 w-3.5" }), "\u4FDD\u5B58\u8349\u7A3F"] }), _jsxs("button", { onClick: doPublish, disabled: !current || !isEditable || !hasDraft || !!saving, className: "inline-flex items-center gap-1 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground disabled:opacity-50", children: [saving === 'publish' ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : null, "\u53D1\u5E03"] })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsxs("nav", { className: (editNav ? 'w-72' : 'w-52') + ' flex shrink-0 flex-col border-r', children: [_jsxs("div", { className: "shrink-0 border-b px-2 py-1.5", children: [_jsxs("div", { className: "flex items-center justify-between gap-1", children: [_jsxs("p", { className: "truncate text-[11px] font-medium text-muted-foreground", children: [appLabel, " \u00B7 \u5BFC\u822A"] }), _jsx("button", { type: "button", onClick: () => {
695
+ }, [client, appName, appDraft, onDraftSaved]);
696
+ return (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center gap-2 border-b px-3 py-1.5", children: [current ? (_jsxs("span", { className: "flex items-center gap-1.5 text-[11px] text-muted-foreground", children: [_jsx("span", { className: "text-[13px] font-medium text-foreground", children: current.label }), _jsxs("span", { className: "rounded bg-muted px-1.5 py-0.5", children: [current.type, " \u00B7 ", current.name] })] })) : (_jsx("span", { className: "text-[11px] text-muted-foreground", children: t('engine.studio.if.pickLeft', locale) })), hasDraft && (_jsx("span", { className: "rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: t('engine.studio.unpublishedDraft', locale) })), _jsxs("button", { onClick: doSave, disabled: !current || !isEditable || !!saving || readOnly, title: readOnly ? t('engine.studio.pkg.readonlyHint', locale) : undefined, className: "ml-auto inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-muted disabled:opacity-50", children: [saving === 'draft' ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : _jsx(Save, { className: "h-3.5 w-3.5" }), t('engine.studio.saveDraft', locale)] })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsxs("nav", { className: (editNav ? 'w-72' : 'w-52') + ' flex shrink-0 flex-col border-r', children: [_jsxs("div", { className: "shrink-0 border-b px-2 py-1.5", children: [_jsxs("div", { className: "flex items-center justify-between gap-1", children: [_jsx("p", { className: "truncate text-[11px] font-medium text-muted-foreground", children: tFormat('engine.studio.if.navHeading', locale, { app: appLabel }) }), appStatus === 'ready' && !readOnly && (_jsx("button", { type: "button", onClick: () => {
286
697
  setEditNav((v) => !v);
287
698
  setNavSel(null);
288
- }, title: editNav ? '完成编辑' : '编辑导航(拖拽排序 / 重命名 / 增删)', className: 'inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[10px] ' +
289
- (editNav ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-muted'), children: editNav ? (_jsxs(_Fragment, { children: [_jsx(Check, { className: "h-3 w-3" }), " \u5B8C\u6210"] })) : (_jsxs(_Fragment, { children: [_jsx(Pencil, { className: "h-3 w-3" }), " \u7F16\u8F91"] })) })] }), editNav && (_jsxs("div", { className: "mt-1.5 flex items-center gap-1.5", children: [navHasDraft && (_jsx("span", { className: "rounded bg-amber-400/15 px-1.5 py-0.5 text-[10px] text-amber-600 dark:text-amber-300", children: "\u672A\u53D1\u5E03" })), _jsxs("button", { onClick: doNavSave, disabled: !navDirty || !!navSaving, className: "inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] hover:bg-muted disabled:opacity-50", children: [navSaving === 'draft' ? (_jsx(Loader2, { className: "h-3 w-3 animate-spin" })) : (_jsx(Save, { className: "h-3 w-3" })), "\u4FDD\u5B58\u8349\u7A3F"] }), _jsxs("button", { onClick: doNavPublish, disabled: !navHasDraft || !!navSaving, className: "inline-flex items-center gap-1 rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground disabled:opacity-50", children: [navSaving === 'publish' ? _jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : null, "\u53D1\u5E03"] })] }))] }), _jsx("div", { className: "min-h-0 flex-1 overflow-auto p-2", children: navTree.length === 0 ? (_jsx("p", { className: "px-2 py-3 text-[11px] text-muted-foreground", children: error ? '加载失败' : '加载中…' })) : editNav ? (_jsx(AppNavCanvas, { draft: appDraft, rootKey: "navigation", onPatch: onNavPatch, selection: navSel, onSelectionChange: (s) => setNavSel(s ? { kind: s.kind, id: s.id } : null) })) : (_jsx(NavTree, { nodes: navTree, active: current, onPick: setCurrent })) })] }), _jsxs("main", { className: "min-w-0 flex-1 overflow-auto bg-muted/30 p-4", children: [_jsxs("div", { className: "mb-3 flex items-center gap-2", children: [_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary", children: [_jsx(Eye, { className: "h-3 w-3" }), " \u9884\u89C8\u5373\u8FD0\u884C \u00B7 \u540C\u4E00\u6E32\u67D3\u5668"] }), current && (_jsxs("span", { className: "text-[11px] text-muted-foreground", children: [current.type, " \u00B7 ", current.name] }))] }), error && (_jsx("div", { className: "mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive", children: error })), _jsx("div", { className: "rounded-lg border bg-background p-4", children: !current ? (_jsx("div", { className: "py-16 text-center text-sm text-muted-foreground", children: "\u4ECE\u5DE6\u4FA7\u9009\u62E9\u4E00\u4E2A\u83DC\u5355\u9879" })) : loading ? (_jsxs("div", { className: "flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin" }), " \u52A0\u8F7D\u4E2D\u2026"] })) : current.type === 'object' ? (_jsx(SchemaRenderer, { schema: { type: 'object-view', objectName: current.name } })) : Preview ? (_jsx(Preview, { type: current.type, name: current.name, draft: draft, editing: true, selection: selection, onSelectionChange: setSelection, onPatch: onPatch, locale: locale })) : (_jsxs("div", { className: "py-12 text-center text-xs text-muted-foreground", children: [current.type, " \u6682\u7528\u53EA\u8BFB\u9884\u89C8,\u8BBE\u8BA1\u80FD\u529B\u5EFA\u8BBE\u4E2D\u3002"] })) }), isEditable ? (_jsxs("p", { className: "mt-2 flex items-center gap-1 text-[11px] text-muted-foreground", children: [_jsx(MousePointer2, { className: "h-3 w-3" }), " \u70B9\u9009\u79EF\u6728 \u2192 \u53F3\u4FA7\u76F4\u63A5\u6539 \u00B7 \u6539\u5B8C\u300C\u4FDD\u5B58\u8349\u7A3F\u300D\u2192\u300C\u53D1\u5E03\u300D"] })) : current?.type === 'object' ? (_jsxs("p", { className: "mt-2 flex items-center gap-1 text-[11px] text-muted-foreground", children: [_jsx(Database, { className: "h-3 w-3" }), " \u8FD0\u884C\u6001\u5217\u8868\u9884\u89C8 \u00B7 \u6539\u5B57\u6BB5 / \u7ED3\u6784\u8BF7\u5230 ", _jsx("span", { className: "font-medium", children: "Data" }), " \u652F\u67F1"] })) : null] }), _jsxs("aside", { className: "w-72 shrink-0 overflow-auto border-l", children: [_jsxs("header", { className: "sticky top-0 z-10 flex items-center gap-2 border-b bg-background/95 px-3 py-2 backdrop-blur", children: [_jsx(SlidersHorizontal, { className: "h-3.5 w-3.5" }), _jsx("span", { className: "text-[13px] font-medium", children: "\u5C5E\u6027" }), selection && (_jsx("button", { type: "button", onClick: () => setSelection(null), className: "ml-auto rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground", "aria-label": "\u53D6\u6D88\u9009\u62E9", children: _jsx(X, { className: "h-3.5 w-3.5" }) }))] }), _jsx("div", { className: "p-3", children: selection && Inspector && current ? (_jsx(Inspector, { type: current.type, name: current.name, draft: draft, selection: selection, onPatch: onPatch, onClearSelection: () => setSelection(null), onSelectionChange: setSelection, readOnly: false, locale: locale })) : (_jsxs("div", { className: "flex flex-col items-center gap-2 px-2 py-10 text-center text-xs text-muted-foreground", children: [_jsx(MousePointer2, { className: "h-5 w-5" }), "\u5728\u753B\u5E03\u91CC\u70B9\u9009\u4E00\u4E2A\u79EF\u6728,", _jsx("br", {}), "\u5B83\u7684\u5C5E\u6027\u4F1A\u5728\u8FD9\u91CC\u76F4\u63A5\u7F16\u8F91\u3002"] })) })] })] })] }));
699
+ }, title: editNav ? t('engine.studio.if.doneEditTitle', locale) : t('engine.studio.if.editNavTitle', locale), className: 'inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[10px] ' +
700
+ (editNav ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-muted'), children: editNav ? (_jsxs(_Fragment, { children: [_jsx(Check, { className: "h-3 w-3" }), " ", t('engine.studio.done', locale)] })) : (_jsxs(_Fragment, { children: [_jsx(Pencil, { className: "h-3 w-3" }), " ", t('engine.studio.edit', locale)] })) }))] }), editNav && (_jsxs("div", { className: "mt-1.5 flex items-center gap-1.5", children: [navHasDraft && (_jsx("span", { className: "rounded bg-amber-400/15 px-1.5 py-0.5 text-[10px] text-amber-600 dark:text-amber-300", children: t('engine.studio.unpublished', locale) })), _jsxs("button", { onClick: doNavSave, disabled: !navDirty || !!navSaving, className: "inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] hover:bg-muted disabled:opacity-50", children: [navSaving === 'draft' ? (_jsx(Loader2, { className: "h-3 w-3 animate-spin" })) : (_jsx(Save, { className: "h-3 w-3" })), t('engine.studio.saveDraft', locale)] })] }))] }), _jsx("div", { className: "min-h-0 flex-1 overflow-auto p-2", children: appStatus === 'missing' && !error ? (_jsxs("div", { className: "px-2 py-3", children: [_jsx("p", { className: "text-[11px] text-muted-foreground", children: t('engine.studio.if.noApp', locale) }), onCreateApp && (_jsxs("button", { type: "button", onClick: onCreateApp, className: "mt-2 inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] hover:bg-muted", children: [_jsx(Plus, { className: "h-3 w-3" }), " ", t('engine.studio.app.create', locale)] }))] })) : editNav ? (_jsx(AppNavCanvas, { draft: appDraft, rootKey: "navigation", onPatch: onNavPatch, selection: navSel, onSelectionChange: (s) => setNavSel(s ? { kind: s.kind, id: s.id } : null) })) : navTree.length === 0 ? (_jsx("p", { className: "px-2 py-3 text-[11px] text-muted-foreground", children: error
701
+ ? t('engine.studio.loadFailed', locale)
702
+ : appStatus === 'loading'
703
+ ? t('engine.studio.loading', locale)
704
+ : t('engine.studio.if.noNavItems', locale) })) : (_jsx(NavTree, { nodes: navTree, active: current, onPick: setCurrent })) })] }), _jsxs("main", { className: "min-w-0 flex-1 overflow-auto bg-muted/30 p-4", children: [_jsxs("div", { className: "mb-3 flex items-center gap-2", children: [_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary", children: [_jsx(Eye, { className: "h-3 w-3" }), " ", t('engine.studio.if.previewIsRuntime', locale)] }), current && (_jsxs("span", { className: "text-[11px] text-muted-foreground", children: [current.type, " \u00B7 ", current.name] }))] }), error && (_jsx("div", { className: "mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive whitespace-pre-line", children: error })), _jsx("div", { className: "rounded-lg border bg-background p-4", children: appStatus === 'missing' && !error ? (_jsxs("div", { className: "flex flex-col items-center justify-center gap-3 py-16 text-center", children: [_jsx("div", { className: "flex h-12 w-12 items-center justify-center rounded-full bg-muted", children: _jsx(LayoutDashboard, { className: "h-6 w-6 text-muted-foreground" }) }), _jsxs("div", { children: [_jsx("p", { className: "text-sm font-medium", children: t('engine.studio.if.noAppTitle', locale) }), _jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: t('engine.studio.if.noAppHint', locale) })] }), onCreateApp && (_jsxs("button", { type: "button", onClick: onCreateApp, className: "inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), " ", t('engine.studio.app.create', locale)] }))] })) : !current ? (_jsx("div", { className: "py-16 text-center text-sm text-muted-foreground", children: t('engine.studio.if.pickLeft', locale) })) : loading ? (_jsxs("div", { className: "flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin" }), " ", t('engine.studio.loading', locale)] })) : current.type === 'object' ? (_jsx(SchemaRenderer, { schema: { type: 'object-view', objectName: current.name } })) : Preview ? (_jsx(Preview, { type: current.type, name: current.name, draft: draft, editing: true, selection: selection, onSelectionChange: setSelection, onPatch: onPatch, locale: locale })) : (_jsx("div", { className: "py-12 text-center text-xs text-muted-foreground", children: tFormat('engine.studio.if.readonlyPreview', locale, { type: current.type }) })) }), isEditable ? (_jsxs("p", { className: "mt-2 flex items-center gap-1 text-[11px] text-muted-foreground", children: [_jsx(MousePointer2, { className: "h-3 w-3" }), " ", t('engine.studio.if.editHint', locale)] })) : current?.type === 'object' ? (_jsxs("p", { className: "mt-2 flex items-center gap-1 text-[11px] text-muted-foreground", children: [_jsx(Database, { className: "h-3 w-3" }), " ", t('engine.studio.if.objectHintPre', locale), _jsx("span", { className: "font-medium", children: "Data" }), t('engine.studio.if.objectHintPost', locale)] })) : null] }), _jsxs("aside", { className: "w-72 shrink-0 overflow-auto border-l", children: [_jsxs("header", { className: "sticky top-0 z-10 flex items-center gap-2 border-b bg-background/95 px-3 py-2 backdrop-blur", children: [_jsx(SlidersHorizontal, { className: "h-3.5 w-3.5" }), _jsx("span", { className: "text-[13px] font-medium", children: t('engine.studio.inspector.props', locale) }), selection && (_jsx("button", { type: "button", onClick: () => setSelection(null), className: "ml-auto rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground", "aria-label": t('engine.studio.deselect', locale), children: _jsx(X, { className: "h-3.5 w-3.5" }) }))] }), _jsx("div", { className: "p-3", children: editNav && navSel ? (_jsx(StudioNavItemInspector, { navId: navSel.id, appDraft: appDraft, objects: pkgObjects, onNavPatch: onNavPatch, onClear: () => setNavSel(null) })) : selection && Inspector && current ? (_jsx(Inspector, { type: current.type, name: current.name, draft: draft, selection: selection, onPatch: onPatch, onClearSelection: () => setSelection(null), onSelectionChange: setSelection, readOnly: false, locale: locale })) : (_jsxs("div", { className: "flex flex-col items-center gap-2 px-2 py-10 text-center text-xs text-muted-foreground", children: [_jsx(MousePointer2, { className: "h-5 w-5" }), t('engine.studio.inspector.emptyLine1', locale), _jsx("br", {}), t('engine.studio.inspector.emptyLine2', locale)] })) })] })] })] }));
290
705
  }
291
706
  /** Next unused `field_N` name for a freshly-added field. */
292
707
  function nextFieldName(existing) {
@@ -336,11 +751,12 @@ function renderStudioGridList(props) {
336
751
  addDeleteRecordsInline: true,
337
752
  }, dataSource: ds, onEdit: onEdit, className: className, refreshKey: refreshKey }));
338
753
  }
339
- function DataPillar({ packageId }) {
754
+ function DataPillar({ packageId, publishNonce = 0, onDraftSaved, readOnly = false, }) {
340
755
  const client = useMetadataClient();
341
756
  const adapter = useAdapter();
342
- const locale = 'zh-CN';
757
+ const locale = useMetadataLocale();
343
758
  const [objects, setObjects] = React.useState([]);
759
+ const [objectsLoaded, setObjectsLoaded] = React.useState(false);
344
760
  const [current, setCurrent] = React.useState(null);
345
761
  const [objDraft, setObjDraft] = React.useState({});
346
762
  const [loading, setLoading] = React.useState(false);
@@ -350,32 +766,88 @@ function DataPillar({ packageId }) {
350
766
  const [dirty, setDirty] = React.useState(false);
351
767
  const [hasDraft, setHasDraft] = React.useState(false);
352
768
  const [saving, setSaving] = React.useState(false);
769
+ // Timestamp of the last successful draft save — renders a "last saved HH:MM"
770
+ // hint next to the Save button (framework#2615 P3: nothing confirmed a draft
771
+ // save persisted, unlike the sibling pillars which toast).
772
+ const [savedAt, setSavedAt] = React.useState(null);
353
773
  const [gridVer, setGridVer] = React.useState(0);
774
+ // Records grid ⇄ Form ⇄ Validations ⇄ Settings — four views of the SAME
775
+ // object. Grid/Form are the runtime renderer (same-renderer principle);
776
+ // Validations edits `validations` rules; Settings edits object basics +
777
+ // the ADR-0085 semantic roles. All patch the one `objDraft`.
778
+ const [viewMode, setViewMode] = React.useState('grid');
779
+ // Within the Form view: 布局 (WYSIWYG drag/section designer) ⇄ 预览 (live form).
780
+ const [formMode, setFormMode] = React.useState('layout');
781
+ // Tracks which object's baseline is currently loaded — so we (re)load exactly
782
+ // once per selected object and never clobber an in-progress draft.
783
+ const loadedNameRef = React.useRef(null);
784
+ // Left-rail search + inline "new object" creator (design §4: rail = search + New).
785
+ const [query, setQuery] = React.useState('');
786
+ const [creating, setCreating] = React.useState(false);
787
+ const [newLabel, setNewLabel] = React.useState('');
788
+ const [newName, setNewName] = React.useState('');
789
+ const [nameTouched, setNameTouched] = React.useState(false);
790
+ const [createBusy, setCreateBusy] = React.useState(false);
791
+ // Whether the selected object exists beyond the draft (published/code baseline).
792
+ // A draft-only object has NO physical table yet (DDL lands at publish), so the
793
+ // Records grid must not fire data SQL against it.
794
+ const [hasBaseline, setHasBaseline] = React.useState(true);
354
795
  React.useEffect(() => {
355
796
  let cancelled = false;
356
797
  (async () => {
357
798
  try {
358
- const list = (await client.list('object', { packageId }));
799
+ // Published objects + pending DRAFT objects, merged. `list()` only
800
+ // sees published/active metadata, so a freshly-created writable base
801
+ // whose objects are all drafts would render an empty (previously:
802
+ // forever-"loading") rail. Draft headers carry no label — show the
803
+ // machine name until the draft body loads on selection.
804
+ const [list, draftHeaders] = await Promise.all([
805
+ client.list('object', { packageId }),
806
+ client.listDrafts({ packageId, type: 'object' }).catch(() => []),
807
+ ]);
359
808
  if (cancelled)
360
809
  return;
361
810
  const items = (list || [])
362
811
  .map((o) => ({ type: 'object', name: String(o.name ?? ''), label: String(o.label ?? o.name ?? '') }))
363
812
  .filter((o) => o.name);
813
+ const known = new Set(items.map((o) => o.name));
814
+ for (const d of draftHeaders) {
815
+ if (d.name && !known.has(d.name)) {
816
+ items.push({ type: 'object', name: d.name, label: d.name });
817
+ }
818
+ }
364
819
  setObjects(items);
365
820
  setCurrent((c) => c ?? items[0] ?? null);
821
+ // First-run: an empty writable package opens the creator right away —
822
+ // the first thing to do here is make an object, so put the inputs up.
823
+ if (items.length === 0 && !readOnly)
824
+ setCreating(true);
366
825
  }
367
826
  catch (e) {
368
827
  if (!cancelled)
369
- setError(e instanceof Error ? e.message : String(e));
828
+ setError(formatMetadataError(e));
829
+ }
830
+ finally {
831
+ if (!cancelled)
832
+ setObjectsLoaded(true);
370
833
  }
371
834
  })();
372
835
  return () => {
373
836
  cancelled = true;
374
837
  };
375
- }, [client, packageId]);
838
+ }, [client, packageId, readOnly]);
376
839
  React.useEffect(() => {
377
840
  if (!current)
378
841
  return;
842
+ // Load once per selected object. Bail if this object's baseline is already
843
+ // loaded — a client-identity churn or a child remount must NOT re-fetch and
844
+ // clobber the in-progress form-layout draft the designer is editing.
845
+ // Keyed by object + publishNonce: a package publish (nonce++) re-reads the
846
+ // fresh published baseline; otherwise we never clobber an in-progress draft.
847
+ const loadKey = `${current.name}#${publishNonce}`;
848
+ if (loadedNameRef.current === loadKey)
849
+ return;
850
+ loadedNameRef.current = loadKey;
379
851
  let cancelled = false;
380
852
  setLoading(true);
381
853
  setError(null);
@@ -394,10 +866,11 @@ function DataPillar({ packageId }) {
394
866
  const draftBody = extractDraftBody(draftResp);
395
867
  setObjDraft(draftBody ? { ...baseline, ...draftBody } : baseline);
396
868
  setHasDraft(!!draftBody);
869
+ setHasBaseline(!!(lay.effective ?? lay.code));
397
870
  }
398
871
  catch (e) {
399
872
  if (!cancelled)
400
- setError(e instanceof Error ? e.message : String(e));
873
+ setError(formatMetadataError(e));
401
874
  }
402
875
  finally {
403
876
  if (!cancelled)
@@ -407,61 +880,87 @@ function DataPillar({ packageId }) {
407
880
  return () => {
408
881
  cancelled = true;
409
882
  };
410
- }, [client, current]);
883
+ }, [client, current, publishNonce]);
411
884
  const fieldCount = React.useMemo(() => readFields(objDraft.fields).entries.length, [objDraft]);
412
885
  const onPatch = React.useCallback((patch) => {
413
886
  setObjDraft((d) => ({ ...d, ...patch }));
414
887
  setDirty(true);
415
888
  }, []);
416
889
  // "+ add field": append a fresh text field and select it for editing in the panel.
890
+ // Guarded in addition to being hidden — it's also reachable through
891
+ // GridFieldAuthoringProvider/ObjectFormDesigner.
417
892
  const addField = React.useCallback(() => {
893
+ if (readOnly)
894
+ return;
418
895
  const view = readFields(objDraft.fields);
419
896
  const name = nextFieldName(view.entries.map((e) => e.name));
420
- view.entries.push(newField(name, 'text', '新字段'));
897
+ view.entries.push(newField(name, 'text', t('engine.studio.data.newFieldLabel', locale)));
421
898
  setObjDraft((d) => ({ ...d, fields: writeFields(view) }));
422
899
  setDirty(true);
423
900
  setFieldSel({ kind: 'field', id: name });
424
- }, [objDraft]);
425
- const doSave = React.useCallback(async () => {
426
- if (!current)
901
+ }, [objDraft, readOnly]);
902
+ // "+ new object": create a fresh object as a DRAFT in this package (runtime
903
+ // create — same path the classic Studio editor uses), seeded with one text
904
+ // field so the form/grid isn't empty. It stays draft-only (no physical table)
905
+ // until the package publish, so we land on 表单·布局 — the metadata-level
906
+ // surface that never fires data SQL.
907
+ const doCreateObject = React.useCallback(async () => {
908
+ if (readOnly)
427
909
  return;
428
- setSaving('draft');
910
+ const label = newLabel.trim();
911
+ const name = toFieldName(newName.trim() || label);
912
+ if (!label || !name || name === 'field')
913
+ return; // CJK label → identifier must be typed
914
+ if (objects.some((o) => o.name === name)) {
915
+ setError(tFormat('engine.studio.data.idExists', locale, { name }));
916
+ return;
917
+ }
918
+ setCreateBusy(true);
429
919
  setError(null);
430
920
  try {
431
- await client.save('object', current.name, objDraft, { mode: 'draft' });
432
- setHasDraft(true);
433
- setDirty(false);
921
+ const body = buildObjectSkeleton(name, label, t('engine.studio.data.nameFieldLabel', locale));
922
+ await client.save('object', name, body, { mode: 'draft', packageId });
923
+ const surface = { type: 'object', name, label };
924
+ setObjects((prev) => [...prev, surface]);
925
+ setCurrent(surface);
926
+ setViewMode('form');
927
+ setFormMode('layout');
928
+ setCreating(false);
929
+ setNewLabel('');
930
+ setNewName('');
931
+ setNameTouched(false);
932
+ onDraftSaved?.();
434
933
  }
435
934
  catch (e) {
436
- setError(e instanceof Error ? e.message : String(e));
935
+ setError(formatMetadataError(e));
437
936
  }
438
937
  finally {
439
- setSaving(false);
938
+ setCreateBusy(false);
440
939
  }
441
- }, [client, current, objDraft]);
442
- const doPublish = React.useCallback(async () => {
940
+ }, [newLabel, newName, objects, client, packageId, onDraftSaved, readOnly]);
941
+ const doSave = React.useCallback(async () => {
443
942
  if (!current)
444
943
  return;
445
- setSaving('publish');
944
+ setSaving('draft');
446
945
  setError(null);
447
946
  try {
448
- await client.publish('object', current.name);
449
- // Bust the data-layer object-schema cache (separate from the metadata client)
450
- // so the remounted grid re-fetches the new/edited columns without a reload.
451
- adapter?.clearCache?.();
452
- setHasDraft(false);
947
+ await client.save('object', current.name, objDraft, { mode: 'draft', packageId });
948
+ setHasDraft(true);
453
949
  setDirty(false);
454
- setGridVer((v) => v + 1);
950
+ setSavedAt(new Date());
951
+ toast.success(tFormat('engine.studio.data.savedDraft', locale, { label: current.label }));
952
+ onDraftSaved?.();
455
953
  }
456
954
  catch (e) {
457
- setError(e instanceof Error ? e.message : String(e));
955
+ setError(formatMetadataError(e));
458
956
  }
459
957
  finally {
460
958
  setSaving(false);
461
959
  }
462
- }, [adapter, client, current]);
960
+ }, [client, current, objDraft, onDraftSaved, packageId, locale]);
463
961
  // Drag-reorder columns → reorder the object's `fields` metadata (field display
464
- // order follows metadata order), then publish so the new order persists.
962
+ // order follows metadata order), saved as a DRAFT. Published later via the
963
+ // package release — NOT auto-published per reorder as it used to be.
465
964
  const doReorderFields = React.useCallback(async (orderedNames) => {
466
965
  if (!current)
467
966
  return;
@@ -476,49 +975,124 @@ function DataPillar({ packageId }) {
476
975
  const entries = view.entries.map((e) => (visible.has(e.name) ? visibleInOrder[vi++] : e));
477
976
  const body = { ...objDraft, fields: writeFields({ ...view, entries }) };
478
977
  setObjDraft(body);
479
- setSaving('publish');
978
+ setSaving('draft');
480
979
  setError(null);
481
980
  try {
482
- await client.save('object', current.name, body, { mode: 'draft' });
483
- await client.publish('object', current.name);
484
- adapter?.clearCache?.();
485
- setHasDraft(false);
981
+ await client.save('object', current.name, body, { mode: 'draft', packageId });
982
+ setHasDraft(true);
486
983
  setDirty(false);
487
- setGridVer((v) => v + 1); // remount so the grid reflects the persisted order
984
+ onDraftSaved?.();
985
+ setGridVer((v) => v + 1); // remount so the grid reflects the new (draft) order
488
986
  }
489
987
  catch (e) {
490
- setError(e instanceof Error ? e.message : String(e));
988
+ setError(formatMetadataError(e));
491
989
  }
492
990
  finally {
493
991
  setSaving(false);
494
992
  }
495
- }, [client, current, objDraft, adapter]);
993
+ }, [client, current, objDraft, onDraftSaved]);
496
994
  const inspector = getMetadataInspector('object');
497
- return (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center gap-2 border-b px-3 py-1.5", children: [current ? (_jsxs("span", { className: "flex items-center gap-1.5 text-[11px] text-muted-foreground", children: [_jsx("span", { className: "text-[13px] font-medium text-foreground", children: current.label }), _jsxs("span", { className: "rounded bg-muted px-1.5 py-0.5", children: ["object \u00B7 ", current.name] }), _jsxs("span", { children: [fieldCount, " \u5B57\u6BB5"] })] })) : (_jsx("span", { className: "text-[11px] text-muted-foreground", children: "\u9009\u62E9\u4E00\u4E2A\u5BF9\u8C61" })), hasDraft && (_jsx("span", { className: "rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: "\u672A\u53D1\u5E03\u8349\u7A3F" })), _jsxs("button", { onClick: doSave, disabled: !current || !dirty || !!saving, className: "ml-auto inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-muted disabled:opacity-50", children: [saving === 'draft' ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : _jsx(Save, { className: "h-3.5 w-3.5" }), "\u4FDD\u5B58\u8349\u7A3F"] }), _jsxs("button", { onClick: doPublish, disabled: !current || !hasDraft || !!saving, className: "inline-flex items-center gap-1 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground disabled:opacity-50", children: [saving === 'publish' ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : null, "\u53D1\u5E03"] })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsxs("nav", { className: "w-52 shrink-0 overflow-auto border-r p-2", children: [_jsx("p", { className: "px-2 pb-1 pt-1 text-[11px] font-medium text-muted-foreground", children: "\u5BF9\u8C61" }), objects.length === 0 && (_jsx("p", { className: "px-2 py-3 text-[11px] text-muted-foreground", children: error ? '加载失败' : '加载中…' })), objects.map((o) => (_jsxs("button", { onClick: () => setCurrent(o), className: 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs ' +
498
- (current?.name === o.name ? 'bg-muted font-medium' : 'text-foreground/90 hover:bg-muted/60'), children: [_jsx(Database, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 truncate", children: o.label })] }, o.name)))] }), _jsx("main", { className: "flex min-w-0 flex-1 flex-col overflow-hidden p-4", children: !current ? (_jsx("div", { className: "py-16 text-center text-sm text-muted-foreground", children: "\u9009\u62E9\u4E00\u4E2A\u5BF9\u8C61" })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "mb-3 flex shrink-0 items-center gap-2", children: [_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary", children: [_jsx(Eye, { className: "h-3 w-3" }), " \u8FD0\u884C\u6001\u5217\u8868 \u00B7 \u540C\u4E00\u6E32\u67D3\u5668"] }), _jsxs("button", { type: "button", onClick: addField, title: "\u6DFB\u52A0\u4E00\u4E2A\u5B57\u6BB5(\u968F\u540E\u5728\u53F3\u4FA7\u8BBE\u7F6E\u7C7B\u578B\u4E0E\u5C5E\u6027)", className: "ml-auto inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), " \u6DFB\u52A0\u5B57\u6BB5"] })] }), error && (_jsx("div", { className: "mb-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-1.5 text-[11px] text-destructive", children: error })), _jsx("div", { className: "min-h-0 flex-1 overflow-auto rounded-lg border bg-background", children: _jsx(GridFieldAuthoringProvider, { value: {
499
- onAddColumn: addField,
500
- addColumnLabel: '添加字段',
501
- onEditColumn: (fieldName) => {
502
- // ignore non-field columns (e.g. the row-actions column)
503
- if (readFields(objDraft.fields).entries.some((e) => e.name === fieldName)) {
504
- setFieldSel({ kind: 'field', id: fieldName });
505
- }
506
- },
507
- editColumnLabel: '编辑字段属性',
508
- onReorderFields: doReorderFields,
509
- }, children: _jsx(SchemaRendererProvider, { dataSource: adapter, children: _jsx(PluginObjectView, { schema: {
510
- type: 'object-view',
511
- objectName: current.name,
512
- // No saved view exists in design mode, so show the object's
513
- // own fields as columns (in metadata order), dropping
514
- // framework-managed/audit fields so the grid opens on the
515
- // meaningful columns first — the way Airtable does.
516
- table: {
517
- fields: readFields(objDraft.fields)
518
- .entries.map((e) => e.name)
519
- .filter((n) => !STUDIO_SYSTEM_FIELD_NAMES.has(n)),
995
+ return (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center gap-2 border-b px-3 py-1.5", children: [current ? (_jsxs("span", { className: "flex items-center gap-1.5 text-[11px] text-muted-foreground", children: [_jsx("span", { className: "text-[13px] font-medium text-foreground", children: current.label }), _jsxs("span", { className: "rounded bg-muted px-1.5 py-0.5", children: ["object \u00B7 ", current.name] }), _jsx("span", { children: tFormat('engine.studio.data.fieldCount', locale, { count: fieldCount }) })] })) : (_jsx("span", { className: "text-[11px] text-muted-foreground", children: t('engine.studio.data.pickObject', locale) })), hasDraft && (_jsx("span", { className: "rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: t('engine.studio.unpublishedDraft', locale) })), savedAt && !dirty && (_jsx("span", { className: "ml-auto text-[11px] text-muted-foreground", "data-testid": "data-saved-at", children: tFormat('engine.studio.data.lastSaved', locale, {
996
+ time: savedAt.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
997
+ }) })), _jsxs("button", { onClick: doSave, disabled: !current || !dirty || !!saving || readOnly, title: readOnly ? t('engine.studio.pkg.readonlyHint', locale) : undefined, className: (savedAt && !dirty ? '' : 'ml-auto ') +
998
+ 'inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-muted disabled:opacity-50', children: [saving === 'draft' ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : _jsx(Save, { className: "h-3.5 w-3.5" }), t('engine.studio.saveDraft', locale)] })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsxs("nav", { className: "flex w-52 shrink-0 flex-col border-r", children: [_jsxs("div", { className: "shrink-0 p-2 pb-1", children: [_jsx("p", { className: "px-2 pb-1 pt-1 text-[11px] font-medium text-muted-foreground", children: t('engine.studio.data.objects', locale) }), _jsx("input", { value: query, onChange: (e) => setQuery(e.target.value), placeholder: t('engine.studio.data.searchObjects', locale), className: "h-7 w-full rounded-md border bg-background px-2 text-[11px] outline-none placeholder:text-muted-foreground/70 focus:ring-1 focus:ring-primary" })] }), _jsxs("div", { className: "min-h-0 flex-1 overflow-auto p-2 pt-1", children: [objects.length === 0 && (_jsx("p", { className: "px-2 py-3 text-[11px] text-muted-foreground", children: error ? t('engine.studio.loadFailed', locale) : objectsLoaded ? t('engine.studio.data.noObjects', locale) : t('engine.studio.loading', locale) })), objects
999
+ .filter((o) => !query.trim() ||
1000
+ o.label.toLowerCase().includes(query.trim().toLowerCase()) ||
1001
+ o.name.toLowerCase().includes(query.trim().toLowerCase()))
1002
+ .map((o) => (_jsxs("button", { onClick: () => setCurrent(o), className: 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs ' +
1003
+ (current?.name === o.name ? 'bg-muted font-medium' : 'text-foreground/90 hover:bg-muted/60'), children: [_jsx(Database, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 truncate", children: o.label })] }, o.name)))] }), _jsx("div", { className: "shrink-0 border-t p-2", children: readOnly ? (_jsxs("p", { title: t('engine.studio.pkg.readonlyHint', locale), className: "flex items-center gap-1.5 px-2 py-1.5 text-[11px] text-muted-foreground", children: [_jsx(Lock, { className: "h-3 w-3" }), " ", t('engine.studio.pkg.readonly', locale)] })) : creating ? (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx("input", { autoFocus: true, value: newLabel, onChange: (e) => {
1004
+ setNewLabel(e.target.value);
1005
+ if (!nameTouched)
1006
+ setNewName(toFieldNameLoose(e.target.value));
1007
+ }, onKeyDown: (e) => {
1008
+ if (e.key === 'Enter')
1009
+ void doCreateObject();
1010
+ if (e.key === 'Escape')
1011
+ setCreating(false);
1012
+ }, placeholder: t('engine.studio.data.labelPlaceholder', locale), className: "h-7 w-full rounded-md border bg-background px-2 text-[11px] outline-none focus:ring-1 focus:ring-primary" }), _jsx("input", { value: newName, onChange: (e) => {
1013
+ setNameTouched(true);
1014
+ setNewName(toFieldNameLoose(e.target.value));
1015
+ }, onKeyDown: (e) => {
1016
+ if (e.key === 'Enter')
1017
+ void doCreateObject();
1018
+ if (e.key === 'Escape')
1019
+ setCreating(false);
1020
+ }, placeholder: t('engine.studio.data.idPlaceholder', locale), className: "h-7 w-full rounded-md border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-primary" }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsxs("button", { type: "button", onClick: () => void doCreateObject(), disabled: createBusy || !newLabel.trim() || !toFieldName(newName.trim() || newLabel) || toFieldName(newName.trim() || newLabel) === 'field', className: "inline-flex flex-1 items-center justify-center gap-1 rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground disabled:opacity-50", children: [createBusy ? _jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : _jsx(Plus, { className: "h-3 w-3" }), t('engine.studio.createDraft', locale)] }), _jsx("button", { type: "button", onClick: () => setCreating(false), className: "rounded-md border px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted", children: t('engine.studio.cancel', locale) })] })] })) : (_jsxs("button", { type: "button", onClick: () => setCreating(true), className: "inline-flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), " ", t('engine.studio.data.newObject', locale)] })) })] }), _jsx("main", { className: "flex min-w-0 flex-1 flex-col overflow-hidden p-4", children: !current ? (objectsLoaded && objects.length === 0 ? (_jsxs("div", { className: "flex flex-col items-center gap-2 py-16 text-center", children: [_jsx("p", { className: "text-sm font-medium", children: t('engine.studio.data.firstObjectTitle', locale) }), _jsx("p", { className: "max-w-sm text-[11px] leading-5 text-muted-foreground", children: t('engine.studio.data.firstObjectHint', locale) })] })) : (_jsx("div", { className: "py-16 text-center text-sm text-muted-foreground", children: t('engine.studio.data.pickObject', locale) }))) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "mb-3 flex shrink-0 items-center gap-2", children: [_jsxs("div", { className: "inline-flex rounded-md border p-0.5 text-[11px]", children: [_jsx("button", { type: "button", onClick: () => setViewMode('grid'), className: 'rounded px-2.5 py-0.5 ' +
1021
+ (viewMode === 'grid' ? 'bg-muted font-medium' : 'text-muted-foreground hover:text-foreground'), children: t('engine.studio.data.tab.records', locale) }), _jsx("button", { type: "button", onClick: () => setViewMode('form'), className: 'rounded px-2.5 py-0.5 ' +
1022
+ (viewMode === 'form' ? 'bg-muted font-medium' : 'text-muted-foreground hover:text-foreground'), children: t('engine.studio.data.tab.form', locale) }), _jsx("button", { type: "button", onClick: () => setViewMode('rules'), className: 'rounded px-2.5 py-0.5 ' +
1023
+ (viewMode === 'rules' ? 'bg-muted font-medium' : 'text-muted-foreground hover:text-foreground'), children: t('engine.studio.data.tab.rules', locale) }), _jsx("button", { type: "button", onClick: () => setViewMode('settings'), className: 'rounded px-2.5 py-0.5 ' +
1024
+ (viewMode === 'settings' ? 'bg-muted font-medium' : 'text-muted-foreground hover:text-foreground'), children: t('engine.studio.data.tab.settings', locale) })] }), _jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary", children: [_jsx(Eye, { className: "h-3 w-3" }), ' ', viewMode === 'grid'
1025
+ ? t('engine.studio.data.badge.grid', locale)
1026
+ : viewMode === 'rules'
1027
+ ? t('engine.studio.data.badge.rules', locale)
1028
+ : viewMode === 'settings'
1029
+ ? t('engine.studio.data.badge.settings', locale)
1030
+ : formMode === 'layout'
1031
+ ? t('engine.studio.data.badge.formLayout', locale)
1032
+ : t('engine.studio.data.badge.formPreview', locale)] }), (viewMode === 'grid' || viewMode === 'form') && !readOnly && (_jsxs("button", { type: "button", onClick: addField, title: t('engine.studio.data.addFieldTitle', locale), className: "ml-auto inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), " ", t('engine.studio.data.addField', locale)] }))] }), error && (_jsx("div", { className: "mb-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-1.5 text-[11px] text-destructive whitespace-pre-line", children: error })), viewMode === 'rules' ? (_jsx(ObjectValidationsPanel, { draft: objDraft, onPatch: onPatch, disabled: readOnly })) : viewMode === 'settings' ? (_jsx(ObjectSettingsPanel, { name: current.name, draft: objDraft, onPatch: onPatch, locale: locale, disabled: readOnly })) : viewMode === 'grid' && !hasBaseline ? (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col items-center justify-center gap-2 rounded-lg border border-dashed bg-muted/20 text-center", children: [_jsx("p", { className: "text-sm font-medium", children: t('engine.studio.data.draftObjectTitle', locale) }), _jsx("p", { className: "max-w-md text-[11px] leading-5 text-muted-foreground", children: t('engine.studio.data.draftObjectHint', locale) }), _jsx("button", { type: "button", onClick: () => {
1033
+ setViewMode('form');
1034
+ setFormMode('layout');
1035
+ }, className: "mt-1 inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-muted", children: t('engine.studio.data.goDesignFields', locale) })] })) : viewMode === 'grid' ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "min-h-0 flex-1 overflow-auto rounded-lg border bg-background", children: _jsx(GridFieldAuthoringProvider, { value: {
1036
+ // Read-only package: keep the per-column inspector (view props)
1037
+ // but drop add-column and drag-reorder — both are doomed writes.
1038
+ ...(readOnly
1039
+ ? {}
1040
+ : {
1041
+ onAddColumn: addField,
1042
+ addColumnLabel: t('engine.studio.data.addField', locale),
1043
+ onReorderFields: doReorderFields,
1044
+ }),
1045
+ onEditColumn: (fieldName) => {
1046
+ // ignore non-field columns (e.g. the row-actions column)
1047
+ if (readFields(objDraft.fields).entries.some((e) => e.name === fieldName)) {
1048
+ setFieldSel({ kind: 'field', id: fieldName });
1049
+ }
520
1050
  },
521
- }, dataSource: adapter, renderListView: renderStudioGridList }, `${current.name}:${gridVer}`) }) }) }), _jsxs("p", { className: "mt-2 flex items-center gap-1 text-[11px] text-muted-foreground", children: [_jsx(MousePointer2, { className: "h-3 w-3" }), " \u5217\u5934\u300C+\u300D\u52A0\u5B57\u6BB5 \u00B7 \u7B14\u5F62\u6539\u5C5E\u6027 \u00B7 \u62D6\u5217\u5934\u91CD\u6392 \u00B7 \u6539\u5B8C\u300C\u4FDD\u5B58\u8349\u7A3F\u300D\u2192\u300C\u53D1\u5E03\u300D"] })] })) }), current && fieldSel && inspector && (_jsxs("aside", { className: "flex w-80 shrink-0 flex-col border-l", children: [_jsxs("header", { className: "sticky top-0 z-10 flex items-center gap-2 border-b bg-background/95 px-3 py-2 backdrop-blur", children: [_jsx(SlidersHorizontal, { className: "h-3.5 w-3.5" }), _jsx("span", { className: "text-[13px] font-medium", children: "\u5B57\u6BB5\u5C5E\u6027" }), _jsx("button", { type: "button", onClick: () => setFieldSel(null), className: "ml-auto rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground", "aria-label": "\u5173\u95ED", children: _jsx(X, { className: "h-3.5 w-3.5" }) })] }), _jsx("div", { className: "min-h-0 flex-1 overflow-auto p-3", children: React.createElement(inspector, {
1051
+ editColumnLabel: t('engine.studio.data.editFieldProps', locale),
1052
+ }, children: _jsx(SchemaRendererProvider, { dataSource: adapter, children: _jsx(PluginObjectView, { schema: {
1053
+ type: 'object-view',
1054
+ objectName: current.name,
1055
+ // No saved view exists in design mode, so show the object's
1056
+ // own fields as columns (in metadata order), dropping
1057
+ // framework-managed/audit fields so the grid opens on the
1058
+ // meaningful columns first — the way Airtable does.
1059
+ table: {
1060
+ fields: readFields(objDraft.fields)
1061
+ .entries.map((e) => e.name)
1062
+ // Also drop a field named `actions`: the grid always pins
1063
+ // its own row-actions column headed "Actions", so a data
1064
+ // column of the same name reads as a duplicated column.
1065
+ // The field stays editable in the form designer.
1066
+ .filter((n) => !STUDIO_SYSTEM_FIELD_NAMES.has(n) && n !== 'actions'),
1067
+ },
1068
+ }, dataSource: adapter, renderListView: renderStudioGridList }, `${current.name}:${gridVer}`) }) }) }), _jsxs("p", { className: "mt-2 flex items-center gap-1 text-[11px] text-muted-foreground", children: [_jsx(MousePointer2, { className: "h-3 w-3" }), " ", t('engine.studio.data.gridHint', locale)] })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "mb-3 flex items-center gap-2", children: [_jsxs("div", { className: "inline-flex rounded-md border p-0.5 text-[11px]", children: [_jsx("button", { type: "button", onClick: () => setFormMode('layout'), className: 'rounded px-2.5 py-0.5 ' +
1069
+ (formMode === 'layout' ? 'bg-muted font-medium' : 'text-muted-foreground hover:text-foreground'), children: t('engine.studio.data.form.layout', locale) }), _jsx("button", { type: "button", onClick: () => setFormMode('preview'), className: 'rounded px-2.5 py-0.5 ' +
1070
+ (formMode === 'preview' ? 'bg-muted font-medium' : 'text-muted-foreground hover:text-foreground'), children: t('engine.studio.data.form.preview', locale) })] }), _jsx("span", { className: "text-[11px] text-muted-foreground", children: formMode === 'layout' ? t('engine.studio.data.form.layoutBadge', locale) : t('engine.studio.data.form.previewBadge', locale) }), formMode === 'preview' && (dirty || hasDraft) && (_jsx("span", { className: "inline-flex items-center gap-1 rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: t('engine.studio.data.form.previewWarn', locale) }))] }), formMode === 'layout' ? (_jsx(ObjectFormDesigner, { draft: objDraft, systemFieldNames: STUDIO_SYSTEM_FIELD_NAMES, onChange: onPatch, selectedField: fieldSel?.kind === 'field' ? fieldSel.id : null, onSelectField: (name) => setFieldSel({ kind: 'field', id: name }), onAddField: addField, readOnly: readOnly })) : !hasBaseline ? (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col items-center justify-center gap-2 rounded-lg border border-dashed bg-muted/20 text-center", children: [_jsx("p", { className: "text-sm font-medium", children: t('engine.studio.data.form.noPublishedTitle', locale) }), _jsx("p", { className: "max-w-md text-[11px] leading-5 text-muted-foreground", children: t('engine.studio.data.form.noPublishedHint', locale) })] })) : (_jsxs(_Fragment, { children: [_jsx("style", { children: `
1071
+ /* Design preview: the form is a click-to-select canvas, not a data-entry
1072
+ * form. Disable interaction on field contents so a click anywhere on a
1073
+ * field routes to its [data-field] wrapper (→ select), and neutralize the
1074
+ * create/cancel footer so nothing is submittable here. */
1075
+ .os-form-authoring [data-field]{border-radius:8px;cursor:pointer;transition:box-shadow .12s;padding:8px;margin:-8px 0;}
1076
+ .os-form-authoring [data-field] *{pointer-events:none;}
1077
+ .os-form-authoring [data-field]:hover{box-shadow:0 0 0 1px hsl(var(--border));}
1078
+ .os-form-authoring form > div:last-child:has(button){display:none;}
1079
+ ${fieldSel?.kind === 'field'
1080
+ ? `.os-form-authoring [data-field="${String(fieldSel.id).replace(/[^\w-]/g, '')}"]{box-shadow:0 0 0 2px hsl(var(--primary));}`
1081
+ : ''}
1082
+ ` }), _jsx("div", { className: "min-h-0 flex-1 overflow-auto rounded-lg border bg-background p-6", children: _jsx("div", { className: "os-form-authoring mx-auto max-w-2xl", onClick: (e) => {
1083
+ const el = e.target.closest('[data-field]');
1084
+ const name = el?.getAttribute('data-field');
1085
+ if (name && readFields(objDraft.fields).entries.some((f) => f.name === name)) {
1086
+ setFieldSel({ kind: 'field', id: name });
1087
+ }
1088
+ }, children: _jsx(SchemaRendererProvider, { dataSource: adapter, children: _jsx(ObjectForm, { schema: {
1089
+ type: 'object-form',
1090
+ objectName: current.name,
1091
+ mode: 'create',
1092
+ fields: readFields(objDraft.fields)
1093
+ .entries.map((e) => e.name)
1094
+ .filter((n) => !STUDIO_SYSTEM_FIELD_NAMES.has(n)),
1095
+ }, dataSource: adapter }, `${current.name}:${gridVer}:form`) }) }) }), _jsxs("p", { className: "mt-2 flex items-center gap-1 text-[11px] text-muted-foreground", children: [_jsx(MousePointer2, { className: "h-3 w-3" }), " ", t('engine.studio.data.formHint', locale)] })] }))] }))] })) }), current && fieldSel && inspector && (_jsxs("aside", { className: "flex w-80 shrink-0 flex-col border-l", children: [_jsxs("header", { className: "sticky top-0 z-10 flex items-center gap-2 border-b bg-background/95 px-3 py-2 backdrop-blur", children: [_jsx(SlidersHorizontal, { className: "h-3.5 w-3.5" }), _jsx("span", { className: "text-[13px] font-medium", children: t('engine.studio.data.fieldProps', locale) }), _jsx("button", { type: "button", onClick: () => setFieldSel(null), className: "ml-auto rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground", "aria-label": t('engine.studio.close', locale), children: _jsx(X, { className: "h-3.5 w-3.5" }) })] }), _jsx("div", { className: "min-h-0 flex-1 overflow-auto p-3", children: React.createElement(inspector, {
522
1096
  type: 'object',
523
1097
  name: current.name,
524
1098
  draft: objDraft,
@@ -526,14 +1100,30 @@ function DataPillar({ packageId }) {
526
1100
  onPatch,
527
1101
  onClearSelection: () => setFieldSel(null),
528
1102
  onSelectionChange: setFieldSel,
529
- readOnly: false,
1103
+ readOnly,
530
1104
  locale,
531
1105
  }) })] }))] })] }));
532
1106
  }
533
- /** Automations pillar — flows: list → FlowPreview (default OFF / review-then-enable). */
534
- function AutomationsPillar({ packageId }) {
1107
+ /**
1108
+ * A flow's live status in the Automations rail: a colored dot + On/Off, from the
1109
+ * engine's runtime state (persisted `status` is intent; this is what's actually
1110
+ * live). Renders nothing for a flow the engine doesn't know yet (never published)
1111
+ * — the amber "unpublished draft" chip already covers that case.
1112
+ */
1113
+ export function FlowStatusDot({ state, locale }) {
1114
+ if (!state)
1115
+ return null;
1116
+ const { enabled, bound } = state;
1117
+ const title = enabled
1118
+ ? bound
1119
+ ? t('engine.studio.auto.onBound', locale)
1120
+ : t('engine.studio.auto.onUnbound', locale)
1121
+ : t('engine.studio.auto.offTitle', locale);
1122
+ return (_jsxs("span", { title: title, className: "inline-flex shrink-0 items-center gap-1", children: [_jsx("span", { className: 'h-1.5 w-1.5 rounded-full ' + (enabled ? 'bg-emerald-500' : 'bg-muted-foreground/40') }), _jsx("span", { className: 'text-[10px] ' + (enabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground'), children: enabled ? t('engine.studio.auto.on', locale) : t('engine.studio.auto.off', locale) })] }));
1123
+ }
1124
+ function AutomationsPillar({ packageId, publishNonce = 0, onDraftSaved, readOnly = false, }) {
535
1125
  const client = useMetadataClient();
536
- const locale = 'zh-CN';
1126
+ const locale = useMetadataLocale();
537
1127
  const [flows, setFlows] = React.useState([]);
538
1128
  const [current, setCurrent] = React.useState(null);
539
1129
  const [draft, setDraft] = React.useState({});
@@ -542,31 +1132,107 @@ function AutomationsPillar({ packageId }) {
542
1132
  const [saving, setSaving] = React.useState(false);
543
1133
  const [hasDraft, setHasDraft] = React.useState(false);
544
1134
  const [error, setError] = React.useState(null);
1135
+ // Tells "still fetching the list" apart from "fetched, package has no flows"
1136
+ // — without it the empty rail showed an endless "加载中…" for a fresh package.
1137
+ const [listed, setListed] = React.useState(false);
1138
+ // Inline create — a fresh package starts with zero flows, so the pillar must
1139
+ // offer a way to author the first one (mirrors the object/app creators).
1140
+ const [creating, setCreating] = React.useState(false);
1141
+ const [newLabel, setNewLabel] = React.useState('');
1142
+ const [newName, setNewName] = React.useState('');
1143
+ const [createBusy, setCreateBusy] = React.useState(false);
545
1144
  const Preview = getMetadataPreview(current?.type ?? '');
546
1145
  const inspector = getMetadataInspector('flow');
547
1146
  const isEditable = !!Preview;
1147
+ // Runtime enable/bound state per flow (GET /automation/_status). Persisted
1148
+ // `status` is intent; this is what's actually live in the engine — the truth
1149
+ // behind the rail's status dots. Refetched after a publish (publishNonce);
1150
+ // degrades silently on an older backend / offline (dots just don't render).
1151
+ const [flowStatus, setFlowStatus] = React.useState({});
548
1152
  React.useEffect(() => {
549
1153
  let cancelled = false;
550
1154
  (async () => {
551
1155
  try {
552
- const list = (await client.list('flow', { packageId }));
1156
+ const res = await fetch('/api/v1/automation/_status', { credentials: 'include', headers: { Accept: 'application/json' } });
1157
+ if (!res.ok)
1158
+ return;
1159
+ const payload = (await res.json().catch(() => null));
1160
+ const list = payload?.data?.flows ?? payload?.flows ?? [];
1161
+ if (cancelled || !Array.isArray(list))
1162
+ return;
1163
+ const map = {};
1164
+ for (const s of list)
1165
+ if (s?.name)
1166
+ map[s.name] = { enabled: s.enabled !== false, bound: !!s.bound };
1167
+ setFlowStatus(map);
1168
+ }
1169
+ catch {
1170
+ /* offline / older backend → no dots */
1171
+ }
1172
+ })();
1173
+ return () => {
1174
+ cancelled = true;
1175
+ };
1176
+ }, [publishNonce]);
1177
+ React.useEffect(() => {
1178
+ let cancelled = false;
1179
+ (async () => {
1180
+ try {
1181
+ // Published flows ∪ pending DRAFT flows — `list()` only sees
1182
+ // published/active metadata, so a just-authored flow that hasn't been
1183
+ // published yet (or a fresh writable-base package whose flows are all
1184
+ // drafts) would render an empty rail even though "Changes · N" shows the
1185
+ // draft exists. Mirrors the Data / Interfaces / Access pillars, which all
1186
+ // merge their drafts. Keyed on `publishNonce` too so drafts that go live
1187
+ // collapse back into the published rail after a package publish.
1188
+ const items = await loadPackageSurfaces(client, 'flow', packageId);
553
1189
  if (cancelled)
554
1190
  return;
555
- const items = (list || [])
556
- .map((f) => ({ type: 'flow', name: String(f.name ?? ''), label: String(f.label ?? f.name ?? '') }))
557
- .filter((f) => f.name);
558
1191
  setFlows(items);
559
1192
  setCurrent((c) => c ?? items[0] ?? null);
560
1193
  }
561
1194
  catch (e) {
562
1195
  if (!cancelled)
563
- setError(e instanceof Error ? e.message : String(e));
1196
+ setError(formatMetadataError(e));
1197
+ }
1198
+ finally {
1199
+ if (!cancelled)
1200
+ setListed(true);
564
1201
  }
565
1202
  })();
566
1203
  return () => {
567
1204
  cancelled = true;
568
1205
  };
569
- }, [client, packageId]);
1206
+ }, [client, packageId, publishNonce]);
1207
+ const doCreateFlow = React.useCallback(async () => {
1208
+ const label = newLabel.trim();
1209
+ const name = toFieldName(newName.trim() || label);
1210
+ if (!label || !name || name === 'field')
1211
+ return;
1212
+ setCreateBusy(true);
1213
+ setError(null);
1214
+ try {
1215
+ // Minimal valid, autolaunched skeleton: start → end. The designer fills in
1216
+ // the trigger + nodes; publishing it is a separate, user-initiated step.
1217
+ const skeleton = buildFlowSkeleton(name, label, t('engine.studio.auto.nodeStart', locale), t('engine.studio.auto.nodeEnd', locale));
1218
+ await client.save('flow', name, skeleton, { mode: 'draft', packageId });
1219
+ const item = { type: 'flow', name, label };
1220
+ setFlows((fs) => [...fs.filter((f) => f.name !== name), item]);
1221
+ setCurrent(item);
1222
+ setHasDraft(true);
1223
+ setCreating(false);
1224
+ setNewLabel('');
1225
+ setNewName('');
1226
+ onDraftSaved?.();
1227
+ toast.success(tFormat('engine.studio.auto.savedDraft', locale, { label }));
1228
+ }
1229
+ catch (e) {
1230
+ setError(formatMetadataError(e));
1231
+ }
1232
+ finally {
1233
+ setCreateBusy(false);
1234
+ }
1235
+ }, [newLabel, newName, client, packageId, onDraftSaved]);
570
1236
  React.useEffect(() => {
571
1237
  if (!current)
572
1238
  return;
@@ -590,7 +1256,7 @@ function AutomationsPillar({ packageId }) {
590
1256
  }
591
1257
  catch (e) {
592
1258
  if (!cancelled)
593
- setError(e instanceof Error ? e.message : String(e));
1259
+ setError(formatMetadataError(e));
594
1260
  }
595
1261
  finally {
596
1262
  if (!cancelled)
@@ -600,7 +1266,7 @@ function AutomationsPillar({ packageId }) {
600
1266
  return () => {
601
1267
  cancelled = true;
602
1268
  };
603
- }, [client, current]);
1269
+ }, [client, current, publishNonce]);
604
1270
  const onPatch = React.useCallback((patch) => setDraft((d) => ({ ...d, ...patch })), []);
605
1271
  const doSave = React.useCallback(async () => {
606
1272
  if (!current)
@@ -608,34 +1274,56 @@ function AutomationsPillar({ packageId }) {
608
1274
  setSaving('draft');
609
1275
  setError(null);
610
1276
  try {
611
- await client.save('flow', current.name, draft, { mode: 'draft' });
1277
+ await client.save('flow', current.name, draft, { mode: 'draft', packageId });
612
1278
  setHasDraft(true);
1279
+ onDraftSaved?.();
613
1280
  }
614
1281
  catch (e) {
615
- setError(e instanceof Error ? e.message : String(e));
1282
+ setError(formatMetadataError(e));
616
1283
  }
617
1284
  finally {
618
1285
  setSaving(false);
619
1286
  }
620
- }, [client, current, draft]);
621
- const doPublish = React.useCallback(async () => {
1287
+ }, [client, current, draft, onDraftSaved]);
1288
+ // Enable/disable persists via the flow's deployment `status` (active = on,
1289
+ // obsolete = off) — the engine honors it on the next publish. The switch flips
1290
+ // it and saves the draft immediately; the change goes live when the package is
1291
+ // published (so "review before enabling" is preserved).
1292
+ const flowEnabled = draft.status !== 'obsolete' && draft.status !== 'invalid';
1293
+ const toggleEnabled = React.useCallback(async () => {
622
1294
  if (!current)
623
1295
  return;
624
- setSaving('publish');
1296
+ const next = !(draft.status !== 'obsolete' && draft.status !== 'invalid');
1297
+ const nextDraft = { ...draft, status: next ? 'active' : 'obsolete' };
1298
+ setDraft(nextDraft);
1299
+ setSaving('draft');
625
1300
  setError(null);
626
1301
  try {
627
- await client.publish('flow', current.name);
628
- setHasDraft(false);
1302
+ await client.save('flow', current.name, nextDraft, { mode: 'draft', packageId });
1303
+ setHasDraft(true);
1304
+ onDraftSaved?.();
1305
+ toast.success(next ? t('engine.studio.auto.enabledToast', locale) : t('engine.studio.auto.disabledToast', locale));
629
1306
  }
630
1307
  catch (e) {
631
- setError(e instanceof Error ? e.message : String(e));
1308
+ setError(formatMetadataError(e));
632
1309
  }
633
1310
  finally {
634
1311
  setSaving(false);
635
1312
  }
636
- }, [client, current]);
637
- return (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center gap-2 border-b px-3 py-1.5", children: [_jsx("span", { className: "text-[11px] text-muted-foreground", children: "\u9ED8\u8BA4 OFF \u00B7 \u5BA1\u9605\u540E\u518D\u542F\u7528" }), hasDraft && (_jsx("span", { className: "rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: "\u672A\u53D1\u5E03\u8349\u7A3F" })), _jsxs("button", { onClick: doSave, disabled: !current || !isEditable || !!saving, className: "ml-auto inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-muted disabled:opacity-50", children: [saving === 'draft' ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : _jsx(Save, { className: "h-3.5 w-3.5" }), "\u4FDD\u5B58\u8349\u7A3F"] }), _jsxs("button", { onClick: doPublish, disabled: !current || !isEditable || !hasDraft || !!saving, className: "inline-flex items-center gap-1 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground disabled:opacity-50", children: [saving === 'publish' ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : null, "\u53D1\u5E03"] })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsxs("nav", { className: "w-52 shrink-0 overflow-auto border-r p-2", children: [_jsx("p", { className: "px-2 pb-1 pt-1 text-[11px] font-medium text-muted-foreground", children: "\u81EA\u52A8\u5316 \u00B7 flow" }), flows.length === 0 ? (_jsx("p", { className: "px-2 py-3 text-[11px] text-muted-foreground", children: error ? '加载失败' : '加载中…' })) : (flows.map((f) => (_jsxs("button", { onClick: () => setCurrent(f), className: 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs ' +
638
- (current?.name === f.name ? 'bg-muted font-medium' : 'text-foreground/90 hover:bg-muted/60'), children: [_jsx(Workflow, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 truncate", children: f.label })] }, f.name))))] }), _jsxs("main", { className: "min-w-0 flex-1 overflow-auto bg-muted/30 p-4", children: [_jsxs("div", { className: "mb-3 flex items-center gap-2", children: [_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary", children: [_jsx(Workflow, { className: "h-3 w-3" }), " \u53EF\u89C6\u5316\u7F16\u6392 \u00B7 \u70B9\u9009\u8282\u70B9\u914D\u7F6E"] }), current && _jsxs("span", { className: "text-[11px] text-muted-foreground", children: ["flow \u00B7 ", current.name] })] }), error && (_jsx("div", { className: "mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive", children: error })), _jsx("div", { className: "rounded-lg border bg-background p-4", children: !current ? (_jsx("div", { className: "py-16 text-center text-sm text-muted-foreground", children: "\u9009\u62E9\u4E00\u4E2A\u81EA\u52A8\u5316" })) : loading ? (_jsxs("div", { className: "flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin" }), " \u52A0\u8F7D\u4E2D\u2026"] })) : Preview ? (React.createElement(Preview, {
1313
+ }, [client, current, draft, packageId, onDraftSaved, locale]);
1314
+ return (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center gap-2 border-b px-3 py-1.5", children: [_jsx("span", { className: "text-[11px] text-muted-foreground", children: t('engine.studio.auto.defaultOff', locale) }), hasDraft && (_jsx("span", { className: "rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: t('engine.studio.unpublishedDraft', locale) })), current && (_jsxs("button", { type: "button", role: "switch", "aria-checked": flowEnabled, onClick: toggleEnabled, disabled: !isEditable || !!saving, title: flowEnabled ? t('engine.studio.auto.disableTitle', locale) : t('engine.studio.auto.enableTitle', locale), className: "inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] hover:bg-muted disabled:opacity-50", children: [_jsx("span", { className: 'relative inline-flex h-3.5 w-6 shrink-0 items-center rounded-full transition-colors ' + (flowEnabled ? 'bg-emerald-500' : 'bg-muted-foreground/40'), children: _jsx("span", { className: 'inline-block h-2.5 w-2.5 rounded-full bg-white transition-transform ' + (flowEnabled ? 'translate-x-3' : 'translate-x-0.5') }) }), flowEnabled ? t('engine.studio.auto.enabled', locale) : t('engine.studio.auto.disabled', locale)] })), _jsxs("button", { onClick: doSave, disabled: !current || !isEditable || !!saving || readOnly, title: readOnly ? t('engine.studio.pkg.readonlyHint', locale) : undefined, className: "ml-auto inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-muted disabled:opacity-50", children: [saving === 'draft' ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : _jsx(Save, { className: "h-3.5 w-3.5" }), t('engine.studio.saveDraft', locale)] })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsxs("nav", { className: "flex w-52 shrink-0 flex-col overflow-auto border-r p-2", children: [_jsxs("div", { className: "flex items-center gap-1 px-2 pb-1 pt-1", children: [_jsx("p", { className: "flex-1 text-[11px] font-medium text-muted-foreground", children: t('engine.studio.auto.heading', locale) }), !readOnly && (_jsxs("button", { type: "button", onClick: () => setCreating((v) => !v), title: t('engine.studio.auto.newTitle', locale), className: "inline-flex items-center gap-0.5 rounded border px-1.5 py-0.5 text-[11px] hover:bg-muted", children: [_jsx(Plus, { className: "h-3 w-3" }), " ", t('engine.studio.new', locale)] }))] }), flows.length > 0 &&
1315
+ flows.map((f) => (_jsxs("button", { onClick: () => setCurrent(f), className: 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs ' +
1316
+ (current?.name === f.name ? 'bg-muted font-medium' : 'text-foreground/90 hover:bg-muted/60'), children: [_jsx(Workflow, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 truncate", children: f.label }), _jsx(FlowStatusDot, { state: flowStatus[f.name], locale: locale })] }, f.name))), flows.length === 0 && !creating && (_jsx("p", { className: "px-2 py-3 text-[11px] text-muted-foreground", children: error ? t('engine.studio.loadFailed', locale) : !listed ? t('engine.studio.loading', locale) : t('engine.studio.auto.none', locale) })), creating && (_jsxs("div", { className: "mt-1 space-y-1.5 rounded-md border bg-muted/30 p-2", children: [_jsx("input", { autoFocus: true, value: newLabel, onChange: (e) => setNewLabel(e.target.value), onKeyDown: (e) => {
1317
+ if (e.key === 'Enter')
1318
+ void doCreateFlow();
1319
+ if (e.key === 'Escape')
1320
+ setCreating(false);
1321
+ }, placeholder: t('engine.studio.auto.namePlaceholder', locale), className: "w-full rounded border bg-background px-2 py-1 text-xs" }), _jsx("input", { value: newName, onChange: (e) => setNewName(e.target.value), onKeyDown: (e) => {
1322
+ if (e.key === 'Enter')
1323
+ void doCreateFlow();
1324
+ if (e.key === 'Escape')
1325
+ setCreating(false);
1326
+ }, placeholder: t('engine.studio.auto.idPlaceholder', locale), className: "w-full rounded border bg-background px-2 py-1 font-mono text-[11px]" }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsxs("button", { type: "button", onClick: () => void doCreateFlow(), disabled: !newLabel.trim() || createBusy, className: "inline-flex flex-1 items-center justify-center gap-1 rounded bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground disabled:opacity-50", children: [createBusy ? _jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : _jsx(Plus, { className: "h-3 w-3" }), t('engine.studio.createDraft', locale)] }), _jsx("button", { type: "button", onClick: () => setCreating(false), className: "rounded border px-2 py-1 text-[11px] hover:bg-muted", children: t('engine.studio.cancel', locale) })] })] }))] }), _jsxs("main", { className: "min-w-0 flex-1 overflow-auto bg-muted/30 p-4", children: [_jsxs("div", { className: "mb-3 flex items-center gap-2", children: [_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary", children: [_jsx(Workflow, { className: "h-3 w-3" }), " ", t('engine.studio.auto.canvasHint', locale)] }), current && _jsxs("span", { className: "text-[11px] text-muted-foreground", children: ["flow \u00B7 ", current.name] })] }), error && (_jsx("div", { className: "mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive whitespace-pre-line", children: error })), _jsx("div", { className: "rounded-lg border bg-background p-4", children: !current ? (_jsx("div", { className: "py-16 text-center text-sm text-muted-foreground", children: t('engine.studio.auto.pick', locale) })) : loading ? (_jsxs("div", { className: "flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin" }), " ", t('engine.studio.loading', locale)] })) : Preview ? (React.createElement(Preview, {
639
1327
  type: current.type,
640
1328
  name: current.name,
641
1329
  draft,
@@ -644,7 +1332,7 @@ function AutomationsPillar({ packageId }) {
644
1332
  onSelectionChange: setSelection,
645
1333
  onPatch,
646
1334
  locale,
647
- })) : (_jsx("pre", { className: "overflow-auto text-[11px] text-muted-foreground", children: JSON.stringify(draft, null, 2) })) }), isEditable && (_jsxs("p", { className: "mt-2 flex items-center gap-1 text-[11px] text-muted-foreground", children: [_jsx(MousePointer2, { className: "h-3 w-3" }), " \u70B9\u9009\u8282\u70B9 \u2192 \u53F3\u4FA7\u914D\u7F6E \u00B7 \u6539\u5B8C\u300C\u4FDD\u5B58\u8349\u7A3F\u300D\u2192\u300C\u53D1\u5E03\u300D"] }))] }), _jsxs("aside", { className: "w-72 shrink-0 overflow-auto border-l", children: [_jsxs("header", { className: "sticky top-0 z-10 flex items-center gap-2 border-b bg-background/95 px-3 py-2 backdrop-blur", children: [_jsx(SlidersHorizontal, { className: "h-3.5 w-3.5" }), _jsx("span", { className: "text-[13px] font-medium", children: "\u914D\u7F6E" }), selection && (_jsx("button", { type: "button", onClick: () => setSelection(null), className: "ml-auto rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground", "aria-label": "\u53D6\u6D88\u9009\u62E9", children: _jsx(X, { className: "h-3.5 w-3.5" }) }))] }), _jsx("div", { className: "p-3", children: selection && inspector && current ? (React.createElement(inspector, {
1335
+ })) : (_jsx("pre", { className: "overflow-auto text-[11px] text-muted-foreground", children: JSON.stringify(draft, null, 2) })) }), isEditable && (_jsxs("p", { className: "mt-2 flex items-center gap-1 text-[11px] text-muted-foreground", children: [_jsx(MousePointer2, { className: "h-3 w-3" }), " ", t('engine.studio.auto.editHint', locale)] }))] }), _jsxs("aside", { className: "w-72 shrink-0 overflow-auto border-l", children: [_jsxs("header", { className: "sticky top-0 z-10 flex items-center gap-2 border-b bg-background/95 px-3 py-2 backdrop-blur", children: [_jsx(SlidersHorizontal, { className: "h-3.5 w-3.5" }), _jsx("span", { className: "text-[13px] font-medium", children: t('engine.studio.auto.config', locale) }), selection && (_jsx("button", { type: "button", onClick: () => setSelection(null), className: "ml-auto rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground", "aria-label": t('engine.studio.deselect', locale), children: _jsx(X, { className: "h-3.5 w-3.5" }) }))] }), _jsx("div", { className: "p-3", children: selection && inspector && current ? (React.createElement(inspector, {
648
1336
  type: 'flow',
649
1337
  name: current.name,
650
1338
  draft,
@@ -654,6 +1342,135 @@ function AutomationsPillar({ packageId }) {
654
1342
  onSelectionChange: setSelection,
655
1343
  readOnly: false,
656
1344
  locale,
657
- })) : (_jsxs("div", { className: "flex flex-col items-center gap-2 px-2 py-10 text-center text-xs text-muted-foreground", children: [_jsx(MousePointer2, { className: "h-5 w-5" }), "\u5728\u753B\u5E03\u91CC\u70B9\u9009\u4E00\u4E2A\u8282\u70B9,", _jsx("br", {}), "\u5B83\u7684\u914D\u7F6E\u4F1A\u5728\u8FD9\u91CC\u663E\u793A\u3002"] })) })] })] })] }));
1345
+ })) : (_jsxs("div", { className: "flex flex-col items-center gap-2 px-2 py-10 text-center text-xs text-muted-foreground", children: [_jsx(MousePointer2, { className: "h-5 w-5" }), t('engine.studio.auto.emptyLine1', locale), _jsx("br", {}), t('engine.studio.auto.emptyLine2', locale)] })) })] })] })] }));
1346
+ }
1347
+ /**
1348
+ * Access pillar — the permission workbench (builder-ui §7, ADR-0084's fourth
1349
+ * content pillar). Left rail: the environment's permission sets / profiles;
1350
+ * main: the Salesforce-style PermissionMatrixEditPage (objects × CRUD/VAMA +
1351
+ * field-level R/W).
1352
+ *
1353
+ * Scope note (ADR-0086 P0/P1/P2): the pillar is scoped to the current package.
1354
+ * The left rail lists only permission sets this package owns — the metadata API
1355
+ * filters `permission` by the record-level `package_id` provenance server-side
1356
+ * (P1), so environment-owned platform defaults (`admin_full_access`,
1357
+ * `member_default`, …) are excluded by the backend. The object MATRIX lists only
1358
+ * the objects this package declares, and Save merges just that slice back,
1359
+ * leaving other packages' contributed rows untouched (P0). Save writes a package
1360
+ * DRAFT and publishes with the whole package via the top-bar Publish (P2, D6).
1361
+ */
1362
+ function AccessPillar({ packageId, publishNonce, onDraftSaved, readOnly = false, }) {
1363
+ const client = useMetadataClient();
1364
+ const locale = useMetadataLocale();
1365
+ const [perms, setPerms] = React.useState([]);
1366
+ const [loaded, setLoaded] = React.useState(false);
1367
+ const [error, setError] = React.useState(null);
1368
+ const [current, setCurrent] = React.useState(null);
1369
+ const [query, setQuery] = React.useState('');
1370
+ // inline creator (same rail pattern as the Data pillar's object creator)
1371
+ const [creating, setCreating] = React.useState(false);
1372
+ const [newLabel, setNewLabel] = React.useState('');
1373
+ const [newName, setNewName] = React.useState('');
1374
+ const [nameTouched, setNameTouched] = React.useState(false);
1375
+ const [busy, setBusy] = React.useState(false);
1376
+ const load = React.useCallback(async () => {
1377
+ try {
1378
+ // Scope the rail to this package server-side (ADR-0086 P1): the metadata
1379
+ // API filters `permission` by the record-level `package_id` provenance, so
1380
+ // it returns only the sets this package owns — environment-owned platform
1381
+ // defaults (`admin_full_access`, `member_default`, …) are excluded by the
1382
+ // backend, not the client. (The `?package=` list rows don't echo the
1383
+ // provenance columns, so a client-side filter can't do this.)
1384
+ //
1385
+ // ADR-0086 P2 (D6): a package permission set is draft/published metadata,
1386
+ // so the rail shows published ∪ pending-draft sets — a set created (or
1387
+ // renamed) as a draft but not yet published must still appear, just like
1388
+ // the Data/Interfaces pillars merge their drafts. Draft headers are
1389
+ // already package-scoped by `listDrafts({ packageId })`.
1390
+ const [list, drafts] = await Promise.all([
1391
+ client.list('permission', { packageId }),
1392
+ client.listDrafts({ packageId, type: 'permission' }).catch(() => []),
1393
+ ]);
1394
+ const byName = new Map();
1395
+ for (const p of list || []) {
1396
+ const name = String(p.name ?? p.id ?? '');
1397
+ if (!name)
1398
+ continue;
1399
+ byName.set(name, {
1400
+ name,
1401
+ label: String(p.label ?? p.name ?? ''),
1402
+ isProfile: !!p.isProfile,
1403
+ });
1404
+ }
1405
+ for (const d of drafts || []) {
1406
+ const name = String(d?.name ?? '');
1407
+ if (!name || byName.has(name))
1408
+ continue;
1409
+ byName.set(name, { name, label: name });
1410
+ }
1411
+ const scoped = [...byName.values()];
1412
+ setPerms(scoped);
1413
+ setCurrent((c) => c ?? scoped[0]?.name ?? null);
1414
+ }
1415
+ catch (e) {
1416
+ setError(formatMetadataError(e));
1417
+ }
1418
+ finally {
1419
+ setLoaded(true);
1420
+ }
1421
+ }, [client, packageId]);
1422
+ React.useEffect(() => {
1423
+ void load();
1424
+ // Re-read after a package publish so drafts that went live collapse into
1425
+ // the published rail (ADR-0086 P2).
1426
+ }, [load, publishNonce]);
1427
+ const doCreate = React.useCallback(async () => {
1428
+ const label = newLabel.trim();
1429
+ const name = toFieldName(newName.trim() || label);
1430
+ if (!label || !name || name === 'field')
1431
+ return;
1432
+ setBusy(true);
1433
+ try {
1434
+ // Package door → create as a DRAFT stamped with this package (D6/D7),
1435
+ // published atomically with the rest of the package.
1436
+ await client.save('permission', name, buildPermissionSkeleton(name, label), { mode: 'draft', packageId });
1437
+ toast.success(tFormat('engine.studio.access.created', locale, { label }));
1438
+ setCreating(false);
1439
+ setNewLabel('');
1440
+ setNewName('');
1441
+ setNameTouched(false);
1442
+ onDraftSaved?.();
1443
+ await load();
1444
+ setCurrent(name);
1445
+ }
1446
+ catch (e) {
1447
+ toast.error(formatMetadataError(e));
1448
+ }
1449
+ finally {
1450
+ setBusy(false);
1451
+ }
1452
+ }, [client, newLabel, newName, load, packageId, onDraftSaved, locale]);
1453
+ const filtered = perms.filter((p) => !query.trim() ||
1454
+ p.label.toLowerCase().includes(query.trim().toLowerCase()) ||
1455
+ p.name.toLowerCase().includes(query.trim().toLowerCase()));
1456
+ return (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center gap-2 border-b px-3 py-1.5", children: [_jsxs("span", { className: "flex items-center gap-1.5 text-[11px] text-muted-foreground", children: [_jsx(Shield, { className: "h-3.5 w-3.5" }), _jsx("span", { className: "text-[13px] font-medium text-foreground", children: t('engine.studio.access.title', locale) }), _jsx("span", { className: "rounded bg-muted px-1.5 py-0.5", children: t('engine.studio.access.subtitle', locale) })] }), _jsx("span", { title: t('engine.studio.access.bannerTitle', locale), className: "ml-auto rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: t('engine.studio.access.banner', locale) })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsxs("nav", { className: "flex w-52 shrink-0 flex-col border-r", children: [_jsxs("div", { className: "p-2 pb-0", children: [_jsx("p", { className: "px-2 pb-1 pt-1 text-[11px] font-medium text-muted-foreground", children: t('engine.studio.access.heading', locale) }), _jsx("input", { value: query, onChange: (e) => setQuery(e.target.value), placeholder: t('engine.studio.access.search', locale), className: "mb-1 h-7 w-full rounded-md border bg-background px-2 text-[11px] outline-none focus:ring-1 focus:ring-primary" })] }), _jsxs("div", { className: "min-h-0 flex-1 overflow-auto p-2 pt-1", children: [perms.length === 0 && (_jsx("p", { className: "px-2 py-3 text-[11px] text-muted-foreground", children: error ? t('engine.studio.loadFailed', locale) : loaded ? t('engine.studio.access.none', locale) : t('engine.studio.loading', locale) })), filtered.map((p) => (_jsxs("button", { onClick: () => setCurrent(p.name), className: 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs ' +
1457
+ (current === p.name ? 'bg-muted font-medium' : 'text-foreground/90 hover:bg-muted/60'), children: [_jsx(Shield, { className: "h-3.5 w-3.5 shrink-0 text-muted-foreground" }), _jsx("span", { className: "flex-1 truncate", children: p.label }), p.isProfile && (_jsx("span", { className: "rounded bg-muted px-1 py-px text-[9px] uppercase text-muted-foreground", children: "profile" }))] }, p.name)))] }), _jsx("div", { className: "shrink-0 border-t p-2", children: creating ? (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx("input", { autoFocus: true, value: newLabel, onChange: (e) => {
1458
+ setNewLabel(e.target.value);
1459
+ if (!nameTouched)
1460
+ setNewName(toFieldNameLoose(e.target.value));
1461
+ }, onKeyDown: (e) => {
1462
+ if (e.key === 'Enter')
1463
+ void doCreate();
1464
+ if (e.key === 'Escape')
1465
+ setCreating(false);
1466
+ }, placeholder: t('engine.studio.access.labelPlaceholder', locale), className: "h-7 w-full rounded-md border bg-background px-2 text-[11px] outline-none focus:ring-1 focus:ring-primary" }), _jsx("input", { value: newName, onChange: (e) => {
1467
+ setNameTouched(true);
1468
+ setNewName(toFieldNameLoose(e.target.value));
1469
+ }, onKeyDown: (e) => {
1470
+ if (e.key === 'Enter')
1471
+ void doCreate();
1472
+ if (e.key === 'Escape')
1473
+ setCreating(false);
1474
+ }, placeholder: t('engine.studio.access.idPlaceholder', locale), className: "h-7 w-full rounded-md border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-primary" }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsxs("button", { type: "button", onClick: () => void doCreate(), disabled: busy || !newLabel.trim() || !toFieldName(newName.trim() || newLabel) || toFieldName(newName.trim() || newLabel) === 'field', className: "inline-flex flex-1 items-center justify-center gap-1 rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground disabled:opacity-50", children: [busy ? _jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : _jsx(Plus, { className: "h-3 w-3" }), t('engine.studio.create', locale)] }), _jsx("button", { type: "button", onClick: () => setCreating(false), className: "rounded-md border px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted", children: t('engine.studio.cancel', locale) })] })] })) : readOnly ? (_jsxs("p", { title: t('engine.studio.pkg.readonlyHint', locale), className: "flex items-center gap-1.5 px-2 py-1.5 text-[11px] text-muted-foreground", children: [_jsx(Lock, { className: "h-3 w-3" }), " ", t('engine.studio.pkg.readonly', locale)] })) : (_jsxs("button", { type: "button", onClick: () => setCreating(true), className: "flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), " ", t('engine.studio.access.new', locale)] })) })] }), _jsx("main", { className: "min-w-0 flex-1 overflow-auto", children: current ? (_jsx(PermissionMatrixEditPage, { type: "permission", name: current, packageId: packageId, publishNonce: publishNonce, onDraftSaved: onDraftSaved }, current)) : (_jsx("div", { className: "py-16 text-center text-sm text-muted-foreground", children: loaded && perms.length === 0 ? t('engine.studio.access.emptyMain', locale) : t('engine.studio.access.pick', locale) })) })] })] }));
658
1475
  }
659
1476
  export default StudioDesignSurface;