@@ -36,12 +36,14 @@ import {
3636 Pencil ,
3737 Check ,
3838 Plus ,
39+ X ,
3940 type LucideIcon ,
4041} from 'lucide-react' ;
4142import { getMetadataPreview , type MetadataSelection } from '../metadata-admin/preview-registry' ;
4243import { getMetadataInspector } from '../metadata-admin/inspector-registry' ;
4344import { useMetadataClient } from '../metadata-admin/useMetadata' ;
4445import { AppNavCanvas } from '../metadata-admin/previews/AppNavCanvas' ;
46+ import { readFields , writeFields , newField } from '../metadata-admin/previews/object-fields-io' ;
4547
4648const PILLARS = [
4749 { key : 'data' , label : 'Data' } ,
@@ -598,53 +600,34 @@ function InterfacesPillar({ packageId }: { packageId: string }): React.ReactElem
598600 ) ;
599601}
600602
601- const NEW_FIELD_TYPES : Array < { value : string ; label : string } > = [
602- { value : 'text' , label : '文本' } ,
603- { value : 'textarea' , label : '长文本' } ,
604- { value : 'number' , label : '数字' } ,
605- { value : 'boolean' , label : '勾选' } ,
606- { value : 'date' , label : '日期' } ,
607- { value : 'datetime' , label : '日期时间' } ,
608- ] ;
609-
610- /** Slugify a label into a safe field name, unique against existing names. */
611- function toFieldName ( label : string , existing : string [ ] ) : string {
612- const base =
613- label
614- . trim ( )
615- . toLowerCase ( )
616- . replace ( / [ ^ a - z 0 - 9 ] + / g, '_' )
617- . replace ( / ^ _ + | _ + $ / g, '' ) || `field_${ existing . length + 1 } ` ;
618- let name = base ;
619- let i = 2 ;
620- while ( existing . includes ( name ) ) name = `${ base } _${ i ++ } ` ;
603+ /** Next unused `field_N` name for a freshly-added field. */
604+ function nextFieldName ( existing : string [ ] ) : string {
605+ let i = existing . length + 1 ;
606+ let name = `field_${ i } ` ;
607+ while ( existing . includes ( name ) ) name = `field_${ ++ i } ` ;
621608 return name ;
622609}
623610
624- /** Append a field to an object body, honoring keyed-map or array `fields`. */
625- function appendField (
626- body : Record < string , unknown > ,
627- field : { name : string ; type : string ; label : string } ,
628- ) : Record < string , unknown > {
629- const raw = body . fields ;
630- if ( Array . isArray ( raw ) ) return { ...body , fields : [ ...raw , field ] } ;
631- return { ...body , fields : { ...( ( raw as object ) ?? { } ) , [ field . name ] : field } } ;
632- }
633-
634- /** Data pillar — the package's objects: list → fields + record grid. */
611+ /**
612+ * Data pillar — the package's objects: a records grid (Airtable parity) plus
613+ * table-based field management. Add a field, or click a column header's edit
614+ * affordance, to open ObjectFieldInspector (full type list + per-type config)
615+ * in the right panel; changes persist via the object draft → publish overlay.
616+ */
635617function DataPillar ( { packageId } : { packageId : string } ) : React . ReactElement {
636618 const client = useMetadataClient ( ) ;
637619 const adapter = useAdapter ( ) ;
620+ const locale = 'zh-CN' ;
638621 const [ objects , setObjects ] = React . useState < Surface [ ] > ( [ ] ) ;
639622 const [ current , setCurrent ] = React . useState < Surface | null > ( null ) ;
640- const [ obj , setObj ] = React . useState < Record < string , unknown > | null > ( null ) ;
623+ const [ objDraft , setObjDraft ] = React . useState < Record < string , unknown > > ( { } ) ;
641624 const [ loading , setLoading ] = React . useState ( false ) ;
642625 const [ error , setError ] = React . useState < string | null > ( null ) ;
643- // add- field (Airtable-style "+" on the grid column header )
644- const [ addOpen , setAddOpen ] = React . useState ( false ) ;
645- const [ fLabel , setFLabel ] = React . useState ( '' ) ;
646- const [ fType , setFType ] = React . useState ( 'text' ) ;
647- const [ saving , setSaving ] = React . useState ( false ) ;
626+ // field management — a selected field opens ObjectFieldInspector (full type + config )
627+ const [ fieldSel , setFieldSel ] = React . useState < MetadataSelection | null > ( null ) ;
628+ const [ dirty , setDirty ] = React . useState ( false ) ;
629+ const [ hasDraft , setHasDraft ] = React . useState ( false ) ;
630+ const [ saving , setSaving ] = React . useState < false | 'draft' | 'publish' > ( false ) ;
648631 const [ gridVer , setGridVer ] = React . useState ( 0 ) ;
649632
650633 React . useEffect ( ( ) => {
@@ -665,19 +648,27 @@ function DataPillar({ packageId }: { packageId: string }): React.ReactElement {
665648 return ( ) => {
666649 cancelled = true ;
667650 } ;
668- } , [ client ] ) ;
651+ } , [ client , packageId ] ) ;
669652
670653 React . useEffect ( ( ) => {
671654 if ( ! current ) return ;
672655 let cancelled = false ;
673656 setLoading ( true ) ;
657+ setError ( null ) ;
658+ setFieldSel ( null ) ;
659+ setDirty ( false ) ;
674660 ( async ( ) => {
675661 try {
676- const lay = ( await client . layered < Record < string , unknown > > ( 'object' , current . name ) ) as {
677- effective ?: Record < string , unknown > ;
678- code ?: Record < string , unknown > ;
679- } ;
680- if ( ! cancelled ) setObj ( lay . effective ?? lay . code ?? null ) ;
662+ const [ layRaw , draftResp ] = await Promise . all ( [
663+ client . layered < Record < string , unknown > > ( 'object' , current . name ) ,
664+ client . getDraft < Record < string , unknown > > ( 'object' , current . name ) . catch ( ( ) => null ) ,
665+ ] ) ;
666+ if ( cancelled ) return ;
667+ const lay = layRaw as { effective ?: Record < string , unknown > ; code ?: Record < string , unknown > } ;
668+ const baseline = ( lay . effective ?? lay . code ?? { } ) as Record < string , unknown > ;
669+ const draftBody = extractDraftBody ( draftResp ) ;
670+ setObjDraft ( draftBody ? { ...baseline , ...draftBody } : baseline ) ;
671+ setHasDraft ( ! ! draftBody ) ;
681672 } catch ( e ) {
682673 if ( ! cancelled ) setError ( e instanceof Error ? e . message : String ( e ) ) ;
683674 } finally {
@@ -689,41 +680,58 @@ function DataPillar({ packageId }: { packageId: string }): React.ReactElement {
689680 } ;
690681 } , [ client , current ] ) ;
691682
692- const fields : Array < { name : string ; label ?: string ; type ?: string } > = React . useMemo ( ( ) => {
693- const raw = obj ?. fields ;
694- if ( Array . isArray ( raw ) ) return raw as never ;
695- if ( raw && typeof raw === 'object' )
696- return Object . entries ( raw ) . map ( ( [ name , f ] ) => ( { name, ...( f as object ) } ) ) as never ;
697- return [ ] ;
698- } , [ obj ] ) ;
699-
700- const doAddField = React . useCallback ( async ( ) => {
701- if ( ! current || ! obj || ! fLabel . trim ( ) ) return ;
702- const name = toFieldName (
703- fLabel ,
704- fields . map ( ( f ) => f . name ) ,
705- ) ;
706- const body = appendField ( obj , { name, type : fType , label : fLabel . trim ( ) } ) ;
707- setSaving ( true ) ;
683+ const fieldCount = React . useMemo ( ( ) => readFields ( objDraft . fields ) . entries . length , [ objDraft ] ) ;
684+
685+ const onPatch = React . useCallback ( ( patch : Record < string , unknown > ) => {
686+ setObjDraft ( ( d ) => ( { ...d , ...patch } ) ) ;
687+ setDirty ( true ) ;
688+ } , [ ] ) ;
689+
690+ // "+ add field": append a fresh text field and select it for editing in the panel.
691+ const addField = React . useCallback ( ( ) => {
692+ const view = readFields ( objDraft . fields ) ;
693+ const name = nextFieldName ( view . entries . map ( ( e ) => e . name ) ) ;
694+ view . entries . push ( newField ( name , 'text' , '新字段' ) ) ;
695+ setObjDraft ( ( d ) => ( { ...d , fields : writeFields ( view ) } ) ) ;
696+ setDirty ( true ) ;
697+ setFieldSel ( { kind : 'field' , id : name } ) ;
698+ } , [ objDraft ] ) ;
699+
700+ const doSave = React . useCallback ( async ( ) => {
701+ if ( ! current ) return ;
702+ setSaving ( 'draft' ) ;
703+ setError ( null ) ;
704+ try {
705+ await client . save ( 'object' , current . name , objDraft , { mode : 'draft' } ) ;
706+ setHasDraft ( true ) ;
707+ setDirty ( false ) ;
708+ } catch ( e ) {
709+ setError ( e instanceof Error ? e . message : String ( e ) ) ;
710+ } finally {
711+ setSaving ( false ) ;
712+ }
713+ } , [ client , current , objDraft ] ) ;
714+
715+ const doPublish = React . useCallback ( async ( ) => {
716+ if ( ! current ) return ;
717+ setSaving ( 'publish' ) ;
708718 setError ( null ) ;
709719 try {
710- await client . save ( 'object' , current . name , body , { mode : 'draft' } ) ;
711720 await client . publish ( 'object' , current . name ) ;
712- // Bust the data-layer object-schema cache so the remounted grid re-fetches
713- // the new column without a full page reload (the grid reads its columns from
714- // dataSource.getObjectSchema, a separate cache from the metadata client).
721+ // Bust the data-layer object-schema cache (separate from the metadata client)
722+ // so the remounted grid re-fetches the new/edited columns without a reload.
715723 ( adapter as { clearCache ?: ( ) => void } | null ) ?. clearCache ?.( ) ;
716- setObj ( body ) ; // optimistic
724+ setHasDraft ( false ) ;
725+ setDirty ( false ) ;
717726 setGridVer ( ( v ) => v + 1 ) ;
718- setAddOpen ( false ) ;
719- setFLabel ( '' ) ;
720- setFType ( 'text' ) ;
721727 } catch ( e ) {
722728 setError ( e instanceof Error ? e . message : String ( e ) ) ;
723729 } finally {
724730 setSaving ( false ) ;
725731 }
726- } , [ adapter , client , current , obj , fLabel , fType , fields ] ) ;
732+ } , [ adapter , client , current ] ) ;
733+
734+ const inspector = getMetadataInspector ( 'object' ) ;
727735
728736 return (
729737 < div className = "flex h-full" >
@@ -756,22 +764,42 @@ function DataPillar({ packageId }: { packageId: string }): React.ReactElement {
756764 < span className = "rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground" >
757765 object · { current . name }
758766 </ span >
759- < span className = "text-[11px] text-muted-foreground" > { fields . length } 字段</ span >
767+ < span className = "text-[11px] text-muted-foreground" > { fieldCount } 字段</ span >
768+ { hasDraft && (
769+ < span className = "rounded bg-amber-400/15 px-1.5 py-0.5 text-[10px] text-amber-600 dark:text-amber-300" >
770+ 未发布草稿
771+ </ span >
772+ ) }
760773 < button
761774 type = "button"
762- onClick = { ( ) => setAddOpen ( true ) }
763- title = "给该对象添加一个字段 "
775+ onClick = { addField }
776+ title = "添加一个字段(随后在右侧设置类型与属性) "
764777 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"
765778 >
766779 < Plus className = "h-3.5 w-3.5" /> 添加字段
767780 </ button >
768781 </ div >
769- { /* Data mode = the records themselves, as a directly-viewable grid
770- * (Airtable parity). Fields are the columns; the trailing "+" column
771- * header (via GridFieldAuthoringProvider) adds a new field. */ }
782+ { error && (
783+ < div className = "mb-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-1.5 text-[11px] text-destructive" >
784+ { error }
785+ </ div >
786+ ) }
787+ { /* Records grid — fields are the columns. The trailing "+" header adds a
788+ * field; each column header's edit affordance opens the field editor
789+ * (both via the GridFieldAuthoringProvider context the data-table reads). */ }
772790 < div className = "min-h-0 flex-1 overflow-auto rounded-lg border bg-background" >
773791 < GridFieldAuthoringProvider
774- value = { { onAddColumn : ( ) => setAddOpen ( true ) , addColumnLabel : '添加字段' } }
792+ value = { {
793+ onAddColumn : addField ,
794+ addColumnLabel : '添加字段' ,
795+ onEditColumn : ( fieldName ) => {
796+ // ignore non-field columns (e.g. the row-actions column)
797+ if ( readFields ( objDraft . fields ) . entries . some ( ( e ) => e . name === fieldName ) ) {
798+ setFieldSel ( { kind : 'field' , id : fieldName } ) ;
799+ }
800+ } ,
801+ editColumnLabel : '编辑字段属性' ,
802+ } }
775803 >
776804 < SchemaRenderer
777805 key = { `${ current . name } :${ gridVer } ` }
@@ -782,64 +810,56 @@ function DataPillar({ packageId }: { packageId: string }): React.ReactElement {
782810 </ >
783811 ) }
784812 </ main >
785- { addOpen && (
786- < div
787- className = "fixed inset-0 z-50 flex items-center justify-center bg-black/30"
788- onClick = { ( ) => ! saving && setAddOpen ( false ) }
789- >
790- < div
791- className = "w-80 rounded-lg border bg-background p-4 shadow-lg"
792- onClick = { ( e ) => e . stopPropagation ( ) }
793- >
794- < p className = "mb-3 text-sm font-medium" > 添加字段 · { current ?. label } </ p >
795- { error && (
796- < div className = "mb-2 rounded border border-destructive/40 bg-destructive/10 px-2 py-1 text-[11px] text-destructive" >
797- { error }
798- </ div >
799- ) }
800- < label className = "mb-1 block text-[11px] text-muted-foreground" > 字段名称</ label >
801- < input
802- autoFocus
803- value = { fLabel }
804- onChange = { ( e ) => setFLabel ( e . target . value ) }
805- onKeyDown = { ( e ) => {
806- if ( e . key === 'Enter' && fLabel . trim ( ) && ! saving ) doAddField ( ) ;
807- if ( e . key === 'Escape' ) setAddOpen ( false ) ;
808- } }
809- placeholder = "例如 备注"
810- className = "mb-3 w-full rounded border bg-background px-2 py-1 text-sm outline-none focus:border-primary"
811- />
812- < label className = "mb-1 block text-[11px] text-muted-foreground" > 类型</ label >
813- < select
814- value = { fType }
815- onChange = { ( e ) => setFType ( e . target . value ) }
816- className = "mb-4 w-full rounded border bg-background px-2 py-1 text-sm outline-none focus:border-primary"
813+ { /* field inspector — full type list + per-type config (reuses ObjectFieldInspector) */ }
814+ { current && fieldSel && inspector && (
815+ < aside className = "flex w-80 shrink-0 flex-col border-l" >
816+ < header className = "flex items-center gap-2 border-b px-3 py-2" >
817+ < SlidersHorizontal className = "h-3.5 w-3.5" />
818+ < span className = "text-[13px] font-medium" > 字段属性</ span >
819+ < span className = "truncate rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground" >
820+ { fieldSel . id }
821+ </ span >
822+ < button
823+ type = "button"
824+ onClick = { ( ) => setFieldSel ( null ) }
825+ className = "ml-auto rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
826+ aria-label = "关闭"
817827 >
818- { NEW_FIELD_TYPES . map ( ( ft ) => (
819- < option key = { ft . value } value = { ft . value } >
820- { ft . label }
821- </ option >
822- ) ) }
823- </ select >
824- < div className = "flex justify-end gap-2" >
825- < button
826- onClick = { ( ) => setAddOpen ( false ) }
827- disabled = { saving }
828- className = "rounded-md border px-3 py-1 text-xs hover:bg-muted disabled:opacity-50"
829- >
830- 取消
831- </ button >
832- < button
833- onClick = { doAddField }
834- disabled = { ! fLabel . trim ( ) || saving }
835- className = "inline-flex items-center gap-1 rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground disabled:opacity-50"
836- >
837- { saving ? < Loader2 className = "h-3.5 w-3.5 animate-spin" /> : null }
838- 添加并发布
839- </ button >
840- </ div >
828+ < X className = "h-3.5 w-3.5" />
829+ </ button >
830+ </ header >
831+ < div className = "flex items-center gap-1.5 border-b px-3 py-1.5" >
832+ < button
833+ onClick = { doSave }
834+ disabled = { ! dirty || ! ! saving }
835+ className = "inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] hover:bg-muted disabled:opacity-50"
836+ >
837+ { saving === 'draft' ? < Loader2 className = "h-3 w-3 animate-spin" /> : < Save className = "h-3 w-3" /> }
838+ 保存草稿
839+ </ button >
840+ < button
841+ onClick = { doPublish }
842+ disabled = { ! hasDraft || ! ! saving }
843+ 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"
844+ >
845+ { saving === 'publish' ? < Loader2 className = "h-3 w-3 animate-spin" /> : null }
846+ 发布
847+ </ button >
841848 </ div >
842- </ div >
849+ < div className = "min-h-0 flex-1 overflow-auto p-3" >
850+ { React . createElement ( inspector , {
851+ type : 'object' ,
852+ name : current . name ,
853+ draft : objDraft ,
854+ selection : fieldSel ,
855+ onPatch,
856+ onClearSelection : ( ) => setFieldSel ( null ) ,
857+ onSelectionChange : setFieldSel ,
858+ readOnly : false ,
859+ locale,
860+ } ) }
861+ </ div >
862+ </ aside >
843863 ) }
844864 </ div >
845865 ) ;
0 commit comments