@@ -48,7 +48,13 @@ import { getMetadataPreview, type MetadataSelection } from '../metadata-admin/pr
4848import { getMetadataInspector } from '../metadata-admin/inspector-registry' ;
4949import { useMetadataClient } from '../metadata-admin/useMetadata' ;
5050import { AppNavCanvas } from '../metadata-admin/previews/AppNavCanvas' ;
51- import { readFields , writeFields , newField } from '../metadata-admin/previews/object-fields-io' ;
51+ import {
52+ readFields ,
53+ writeFields ,
54+ newField ,
55+ toFieldName ,
56+ toFieldNameLoose ,
57+ } from '../metadata-admin/previews/object-fields-io' ;
5258import { ObjectFormDesigner } from './ObjectFormDesigner' ;
5359import { DraftChangesPanel } from '../../preview/DraftChangesPanel' ;
5460import { toast } from 'sonner' ;
@@ -772,6 +778,17 @@ function DataPillar({
772778 // Tracks which object's baseline is currently loaded — so we (re)load exactly
773779 // once per selected object and never clobber an in-progress draft.
774780 const loadedNameRef = React . useRef < string | null > ( null ) ;
781+ // Left-rail search + inline "new object" creator (design §4: rail = search + New).
782+ const [ query , setQuery ] = React . useState ( '' ) ;
783+ const [ creating , setCreating ] = React . useState ( false ) ;
784+ const [ newLabel , setNewLabel ] = React . useState ( '' ) ;
785+ const [ newName , setNewName ] = React . useState ( '' ) ;
786+ const [ nameTouched , setNameTouched ] = React . useState ( false ) ;
787+ const [ createBusy , setCreateBusy ] = React . useState ( false ) ;
788+ // Whether the selected object exists beyond the draft (published/code baseline).
789+ // A draft-only object has NO physical table yet (DDL lands at publish), so the
790+ // Records grid must not fire data SQL against it.
791+ const [ hasBaseline , setHasBaseline ] = React . useState ( true ) ;
775792
776793 React . useEffect ( ( ) => {
777794 let cancelled = false ;
@@ -820,6 +837,7 @@ function DataPillar({
820837 const draftBody = extractDraftBody ( draftResp ) ;
821838 setObjDraft ( draftBody ? { ...baseline , ...draftBody } : baseline ) ;
822839 setHasDraft ( ! ! draftBody ) ;
840+ setHasBaseline ( ! ! ( lay . effective ?? lay . code ) ) ;
823841 } catch ( e ) {
824842 if ( ! cancelled ) setError ( e instanceof Error ? e . message : String ( e ) ) ;
825843 } finally {
@@ -848,6 +866,45 @@ function DataPillar({
848866 setFieldSel ( { kind : 'field' , id : name } ) ;
849867 } , [ objDraft ] ) ;
850868
869+ // "+ new object": create a fresh object as a DRAFT in this package (runtime
870+ // create — same path the classic Studio editor uses), seeded with one text
871+ // field so the form/grid isn't empty. It stays draft-only (no physical table)
872+ // until the package publish, so we land on 表单·布局 — the metadata-level
873+ // surface that never fires data SQL.
874+ const doCreateObject = React . useCallback ( async ( ) => {
875+ const label = newLabel . trim ( ) ;
876+ const name = toFieldName ( newName . trim ( ) || label ) ;
877+ if ( ! label || ! name || name === 'field' ) return ; // CJK label → identifier must be typed
878+ if ( objects . some ( ( o ) => o . name === name ) ) {
879+ setError ( `标识符 "${ name } " 已存在` ) ;
880+ return ;
881+ }
882+ setCreateBusy ( true ) ;
883+ setError ( null ) ;
884+ try {
885+ const body : Record < string , unknown > = {
886+ name,
887+ label,
888+ fields : { name : { type : 'text' , label : '名称' } } ,
889+ } ;
890+ await client . save ( 'object' , name , body , { mode : 'draft' , packageId } ) ;
891+ const surface : Surface = { type : 'object' , name, label } ;
892+ setObjects ( ( prev ) => [ ...prev , surface ] ) ;
893+ setCurrent ( surface ) ;
894+ setViewMode ( 'form' ) ;
895+ setFormMode ( 'layout' ) ;
896+ setCreating ( false ) ;
897+ setNewLabel ( '' ) ;
898+ setNewName ( '' ) ;
899+ setNameTouched ( false ) ;
900+ onDraftSaved ?.( ) ;
901+ } catch ( e ) {
902+ setError ( e instanceof Error ? e . message : String ( e ) ) ;
903+ } finally {
904+ setCreateBusy ( false ) ;
905+ }
906+ } , [ newLabel , newName , objects , client , packageId , onDraftSaved ] ) ;
907+
851908 const doSave = React . useCallback ( async ( ) => {
852909 if ( ! current ) return ;
853910 setSaving ( 'draft' ) ;
@@ -928,24 +985,100 @@ function DataPillar({
928985 </ div >
929986
930987 < div className = "flex min-h-0 flex-1" >
931- < nav className = "w-52 shrink-0 overflow-auto border-r p-2" >
932- < p className = "px-2 pb-1 pt-1 text-[11px] font-medium text-muted-foreground" > 对象</ p >
933- { objects . length === 0 && (
934- < p className = "px-2 py-3 text-[11px] text-muted-foreground" > { error ? '加载失败' : '加载中…' } </ p >
935- ) }
936- { objects . map ( ( o ) => (
937- < button
938- key = { o . name }
939- onClick = { ( ) => setCurrent ( o ) }
940- className = {
941- 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs ' +
942- ( current ?. name === o . name ? 'bg-muted font-medium' : 'text-foreground/90 hover:bg-muted/60' )
943- }
944- >
945- < Database className = "h-3.5 w-3.5 shrink-0" />
946- < span className = "flex-1 truncate" > { o . label } </ span >
947- </ button >
948- ) ) }
988+ < nav className = "flex w-52 shrink-0 flex-col border-r" >
989+ < div className = "shrink-0 p-2 pb-1" >
990+ < p className = "px-2 pb-1 pt-1 text-[11px] font-medium text-muted-foreground" > 对象</ p >
991+ < input
992+ value = { query }
993+ onChange = { ( e ) => setQuery ( e . target . value ) }
994+ placeholder = "搜索对象…"
995+ 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"
996+ />
997+ </ div >
998+ < div className = "min-h-0 flex-1 overflow-auto p-2 pt-1" >
999+ { objects . length === 0 && (
1000+ < p className = "px-2 py-3 text-[11px] text-muted-foreground" > { error ? '加载失败' : '加载中…' } </ p >
1001+ ) }
1002+ { objects
1003+ . filter (
1004+ ( o ) =>
1005+ ! query . trim ( ) ||
1006+ o . label . toLowerCase ( ) . includes ( query . trim ( ) . toLowerCase ( ) ) ||
1007+ o . name . toLowerCase ( ) . includes ( query . trim ( ) . toLowerCase ( ) ) ,
1008+ )
1009+ . map ( ( o ) => (
1010+ < button
1011+ key = { o . name }
1012+ onClick = { ( ) => setCurrent ( o ) }
1013+ className = {
1014+ 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs ' +
1015+ ( current ?. name === o . name ? 'bg-muted font-medium' : 'text-foreground/90 hover:bg-muted/60' )
1016+ }
1017+ >
1018+ < Database className = "h-3.5 w-3.5 shrink-0" />
1019+ < span className = "flex-1 truncate" > { o . label } </ span >
1020+ </ button >
1021+ ) ) }
1022+ </ div >
1023+ < div className = "shrink-0 border-t p-2" >
1024+ { creating ? (
1025+ < div className = "flex flex-col gap-1.5" >
1026+ < input
1027+ autoFocus
1028+ value = { newLabel }
1029+ onChange = { ( e ) => {
1030+ setNewLabel ( e . target . value ) ;
1031+ if ( ! nameTouched ) setNewName ( toFieldNameLoose ( e . target . value ) ) ;
1032+ } }
1033+ onKeyDown = { ( e ) => {
1034+ if ( e . key === 'Enter' ) void doCreateObject ( ) ;
1035+ if ( e . key === 'Escape' ) setCreating ( false ) ;
1036+ } }
1037+ placeholder = "显示名(如:报修工单)"
1038+ className = "h-7 w-full rounded-md border bg-background px-2 text-[11px] outline-none focus:ring-1 focus:ring-primary"
1039+ />
1040+ < input
1041+ value = { newName }
1042+ onChange = { ( e ) => {
1043+ setNameTouched ( true ) ;
1044+ setNewName ( toFieldNameLoose ( e . target . value ) ) ;
1045+ } }
1046+ onKeyDown = { ( e ) => {
1047+ if ( e . key === 'Enter' ) void doCreateObject ( ) ;
1048+ if ( e . key === 'Escape' ) setCreating ( false ) ;
1049+ } }
1050+ placeholder = "标识符(如:repair_ticket)"
1051+ className = "h-7 w-full rounded-md border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-primary"
1052+ />
1053+ < div className = "flex items-center gap-1.5" >
1054+ < button
1055+ type = "button"
1056+ onClick = { ( ) => void doCreateObject ( ) }
1057+ disabled = { createBusy || ! newLabel . trim ( ) || ! toFieldName ( newName . trim ( ) || newLabel ) || toFieldName ( newName . trim ( ) || newLabel ) === 'field' }
1058+ 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"
1059+ >
1060+ { createBusy ? < Loader2 className = "h-3 w-3 animate-spin" /> : < Plus className = "h-3 w-3" /> }
1061+ 创建(存为草稿)
1062+ </ button >
1063+ < button
1064+ type = "button"
1065+ onClick = { ( ) => setCreating ( false ) }
1066+ className = "rounded-md border px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted"
1067+ >
1068+ 取消
1069+ </ button >
1070+ </ div >
1071+ </ div >
1072+ ) : (
1073+ < button
1074+ type = "button"
1075+ onClick = { ( ) => setCreating ( true ) }
1076+ 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"
1077+ >
1078+ < Plus className = "h-3.5 w-3.5" /> 新建对象
1079+ </ button >
1080+ ) }
1081+ </ div >
9491082 </ nav >
9501083
9511084 < main className = "flex min-w-0 flex-1 flex-col overflow-hidden p-4" >
@@ -999,7 +1132,28 @@ function DataPillar({
9991132 { error }
10001133 </ div >
10011134 ) }
1002- { viewMode === 'grid' ? (
1135+ { viewMode === 'grid' && ! hasBaseline ? (
1136+ /* Draft-only object: no physical table until the package publish —
1137+ * rendering the runtime grid would fire data SQL against a table
1138+ * that doesn't exist. Say so instead of erroring. */
1139+ < 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" >
1140+ < p className = "text-sm font-medium" > 未发布的新对象</ p >
1141+ < p className = "max-w-md text-[11px] leading-5 text-muted-foreground" >
1142+ 「记录」网格查询真实数据,而这个对象发布前还没有数据表。请先在「表单 · 布局」里设计字段与分组,
1143+ 然后点顶栏「发布」— 发布后这里就是它的实时数据网格。
1144+ </ p >
1145+ < button
1146+ type = "button"
1147+ onClick = { ( ) => {
1148+ setViewMode ( 'form' ) ;
1149+ setFormMode ( 'layout' ) ;
1150+ } }
1151+ className = "mt-1 inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-muted"
1152+ >
1153+ 去「表单 · 布局」设计字段
1154+ </ button >
1155+ </ div >
1156+ ) : viewMode === 'grid' ? (
10031157 < >
10041158 { /* Records grid — fields are the columns. Header "+" adds a field, the
10051159 * per-column edit affordance opens the field editor, and dragging a
@@ -1098,6 +1252,14 @@ function DataPillar({
10981252 onSelectField = { ( name ) => setFieldSel ( { kind : 'field' , id : name } ) }
10991253 onAddField = { addField }
11001254 />
1255+ ) : ! hasBaseline ? (
1256+ /* Draft-only object: there is no published definition to preview yet. */
1257+ < 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" >
1258+ < p className = "text-sm font-medium" > 尚无已发布定义</ p >
1259+ < p className = "max-w-md text-[11px] leading-5 text-muted-foreground" >
1260+ 「预览」渲染已发布的运行态表单,而这个对象还未发布。在「布局」里确认草稿,点顶栏「发布」后即可预览。
1261+ </ p >
1262+ </ div >
11011263 ) : (
11021264 < >
11031265 { /* Form — the real runtime ObjectForm ("same renderer"): the object's
0 commit comments