@@ -37,7 +37,7 @@ import {
type SettingsTone ,
type SiteSettings ,
} from '../../settings'
import { createItem , deleteItem } from '../../items'
import { createItem , deleteItem , fetchItem , saveItem } from '../../items'
/ * *
* 站 点 设 置 页 ( / admin ) : 页 头 滚 动 信 息 + 侧 栏 顺 序 / 显 隐 。
@@ -538,6 +538,12 @@ function SidebarForm({
/ >
< / div >
) ) }
{ category . kind === 'apps' && (
< div className = "admin-nav-group-row" >
< NewGroupRow sectionId = { category . id } onReload = { onReload } / >
< / div >
) }
< / div >
)
} ) }
@@ -1051,7 +1057,13 @@ function NewGroupRow({ sectionId, onReload }: { sectionId: string; onReload: ()
)
}
// ---------- 内容(新增 条目) ----------
// ---------- 内容(条目列表 + 新增/编辑弹窗 ) ----------
type EntryRow = EntrySummary & {
groupName? : string
sectionId : string
groupId? : string
}
function ContentManager ( {
categories ,
@@ -1061,23 +1073,16 @@ function ContentManager({
onReload : ( ) = > void
} ) {
const [ sectionId , setSectionId ] = useState ( categories [ 0 ] ? . id ? ? '' )
const [ groupId , setGroupId ] = useState ( '' )
const [ key , setKey ] = useState ( '' )
const [ name , setName ] = useState ( '' )
const [ href , setHref ] = useState ( '' )
const [ description , setDescription ] = useState ( '' )
const [ icon , setIcon ] = useState ( '' )
const [ cover , setCover ] = useState ( '' )
const [ tags , setTags ] = useState ( '' )
const [ comment , setComment ] = useState ( '' )
const [ body , setBody ] = useState ( '' )
const [ busy , setBusy ] = useState ( false )
const [ pendingEntry , setPendingEntry ] = useState < EntrySummary | null > ( null )
const [ dialog , setDialog ] = useState < {
open : boolean
key : string | null
location : { sectionId : string ; groupId? : string } | null
} > ( { open : false , key : null , location : null } )
const [ pendingEntry , setPendingEntry ] = useState < EntryRow | null > ( null )
const [ deleting , setDeleting ] = useState ( false )
const section =
categories . find ( ( category ) = > category . id === sectionId ) ? ? categories [ 0 ]
const groups = section ? . kind === 'apps' ? ( section . groups ? ? [ ] ) : [ ]
useEffect ( ( ) = > {
if ( categories . length === 0 ) return
@@ -1086,17 +1091,7 @@ function ContentManager({
}
} , [ categories , sectionId ] )
useEffect ( ( ) = > {
if ( section ? . kind !== 'apps' ) return
if ( ! groups . some ( ( group ) = > group . id === groupId ) ) {
setGroupId ( groups [ 0 ] ? . id ? ? '' )
}
} , [ section , groups , groupId ] )
const nodeKey =
section === undefined ? '' : section . kind === 'apps' ? groupId : section.id
const entries =
const entries : EntryRow [ ] =
section === undefined
? [ ]
: section . kind === 'apps'
@@ -1104,56 +1099,15 @@ function ContentManager({
( group . items ? ? [ ] ) . map ( ( item ) = > ( {
. . . item ,
groupName : group.name ,
sectionId : section.id ,
groupId : group.id ,
} ) ) ,
)
: ( section . items ? ? [ ] ) . map ( ( item ) = > ( { . . . item , groupName : undefined } ) )
const submit = async ( ) = > {
if ( section === undefined || nodeKey === '' ) {
toast . error ( '请先选择分区与分组' )
return
}
setBusy ( true )
const result = await createItem ( {
key : key.trim ( ) ,
nodeKey ,
item : {
name : name.trim ( ) ,
description ,
icon ,
tags : tags
. split ( /[, ,]/ )
. map ( ( tag ) = > tag . trim ( ) )
. filter ( Boolean ) ,
badge : '' ,
comment ,
summary : '' ,
body ,
cover ,
banner : '' ,
href ,
} ,
} )
setBusy ( false )
if ( ! result . ok ) {
toast . error ( result . error )
return
}
toast . success ( ` 已添加「 ${ name . trim ( ) } 」 ` )
setKey ( '' )
setName ( '' )
setHref ( '' )
setDescription ( '' )
setIcon ( '' )
setCover ( '' )
setTags ( '' )
setComment ( '' )
setBody ( '' )
onReload ( )
}
: ( section . items ? ? [ ] ) . map ( ( item ) = > ( {
. . . item ,
groupName : undefined ,
sectionId : section.id ,
} ) )
const removeEntry = async ( ) = > {
if ( pendingEntry === null ) return
@@ -1172,8 +1126,8 @@ function ContentManager({
return (
< Space direction = "column" gap = "lg" align = "stretch" className = "admin-form" >
< Text size = "sm" tone = "muted" >
在 下 面 新 增 网 址 / 海 报 / 图 文 条 目 , 并 选 择 它 挂 在 哪 个 分 区 、 哪 个 分 组 ( 应 用 类 必 须 选 分 组 ) 。
新 增 后 , 分 组 出 现 在 侧 栏 与 首 页 卡 片 的 条 件 是 它 被 设 为 显 示 。
新 增 网 址 / 海 报 / 图 文 条 目 时 , 选 择 它 挂 在 哪 个 分 区 、 哪 个 分 组 ( 应 用 类 必 须 选 分 组 ) ;
列 表 里 的 条 目 可 以 点 铅 笔 修 改 , 包 括 换 分 区 / 分 组 。
< / Text >
< Space gap = "sm" wrap >
@@ -1187,95 +1141,15 @@ function ContentManager({
onChange = { setSectionId }
/ >
< / div >
{ section ? . kind === 'apps' && (
< div style = { { width : 220 } } >
< Select
options = { groups . map ( ( group ) = > ( { value : group.id , label : group.name } ) ) }
value = { groupId }
onChange = { setGroupId }
/ >
< / div >
) }
{ section ? . kind === 'apps' && groups . length === 0 && (
< Text size = "sm" tone = "muted" >
这 个 分 区 还 没 有 分 组 , 请 先 到 「 分 区 」 Tab 新 建 分 组 。
< / Text >
) }
< / Space >
< div className = "admin-content-form" >
< Input
value = { key }
placeholder = "标识,如 my-site"
aria - label = "条目标识"
onChange = { ( event ) = > setKey ( event . target . value ) }
/ >
< Input
value = { name }
placeholder = "标题,如 我的站点"
aria - label = "条目标题"
onChange = { ( event ) = > setName ( event . target . value ) }
/ >
< Input
value = { href }
placeholder = "跳转地址,如 https://example.com 或 /project/"
aria - label = "跳转地址"
onChange = { ( event ) = > setHref ( event . target . value ) }
/ >
< Input
value = { icon }
placeholder = "字符图标,如 G"
aria - label = "图标"
onChange = { ( event ) = > setIcon ( event . target . value ) }
/ >
< Input
value = { description }
placeholder = "一句话简介"
aria - label = "简介"
onChange = { ( event ) = > setDescription ( event . target . value ) }
/ >
< Input
value = { cover }
placeholder = "封面 / 海报图片地址"
aria - label = "封面地址"
onChange = { ( event ) = > setCover ( event . target . value ) }
/ >
< Input
value = { tags }
placeholder = "标签,用逗号分隔"
aria - label = "标签"
onChange = { ( event ) = > setTags ( event . target . value ) }
/ >
< Input
value = { comment }
placeholder = "一句话评价"
aria - label = "一句话评价"
onChange = { ( event ) = > setComment ( event . target . value ) }
/ >
< div className = "admin-col-span" >
< Textarea
rows = { 4 }
value = { body }
placeholder = "正文(图文 / Markdown),可留空"
aria - label = "正文"
onChange = { ( event ) = > setBody ( event . target . value ) }
/ >
< / div >
< / div >
< Space gap = "sm" >
< Button
loading = { busy }
icon = { < Icon name = "add_line" / > }
disabled = {
section === undefined ||
nodeKey === '' ||
key . trim ( ) === '' ||
name . trim ( ) === ''
onClick = { ( ) = >
setDialog ( {
open : true ,
key : null ,
location : section === undefined ? null : { sectionId : section.id } ,
} )
}
onClick = { submit }
>
新 增 条 目
< / Button >
@@ -1291,6 +1165,19 @@ function ContentManager({
< Text size = "sm" tone = "muted" >
{ entry . groupName ? ? '直属分区' }
< / Text >
< Button
variant = "ghost"
size = "sm"
icon = { < Icon name = "pencil_line" / > }
aria - label = "编辑条目"
onClick = { ( ) = >
setDialog ( {
open : true ,
key : entry.id ,
location : { sectionId : entry.sectionId , groupId : entry.groupId } ,
} )
}
/ >
< Button
variant = "ghost"
size = "sm"
@@ -1303,6 +1190,18 @@ function ContentManager({
) ) }
< / Space >
< EntryDialog
open = { dialog . open }
entryKey = { dialog . key }
categories = { categories }
defaultLocation = { dialog . location }
onClose = { ( ) = > setDialog ( { open : false , key : null , location : null } ) }
onSaved = { ( ) = > {
setDialog ( { open : false , key : null , location : null } )
onReload ( )
} }
/ >
< ConfirmModal
open = { pendingEntry !== null }
title = {
@@ -1319,6 +1218,300 @@ function ContentManager({
)
}
type EntryForm = {
key : string
name : string
href : string
icon : string
description : string
cover : string
tags : string
comment : string
body : string
badge : string
summary : string
banner : string
}
const EMPTY_ENTRY_FORM : EntryForm = {
key : '' ,
name : '' ,
href : '' ,
icon : '' ,
description : '' ,
cover : '' ,
tags : '' ,
comment : '' ,
body : '' ,
badge : '' ,
summary : '' ,
banner : '' ,
}
function EntryDialog ( {
open ,
entryKey ,
categories ,
defaultLocation ,
onClose ,
onSaved ,
} : {
open : boolean
entryKey : string | null
categories : CategorySummary [ ]
defaultLocation : { sectionId : string ; groupId? : string } | null
onClose : ( ) = > void
onSaved : ( ) = > void
} ) {
const editing = entryKey !== null
const [ loading , setLoading ] = useState ( false )
const [ updatedAt , setUpdatedAt ] = useState ( 0 )
const [ sectionId , setSectionId ] = useState ( '' )
const [ groupId , setGroupId ] = useState ( '' )
const [ form , setForm ] = useState < EntryForm > ( EMPTY_ENTRY_FORM )
const [ busy , setBusy ] = useState ( false )
const [ error , setError ] = useState ( '' )
const section =
categories . find ( ( category ) = > category . id === sectionId ) ? ? categories [ 0 ]
const groups = section ? . kind === 'apps' ? ( section . groups ? ? [ ] ) : [ ]
// 打开时重置:新建用当前选择,编辑先拉完整条目
useEffect ( ( ) = > {
if ( ! open ) return
setError ( '' )
const startSection =
defaultLocation ? . sectionId ? ? categories [ 0 ] ? . id ? ? ''
setSectionId ( startSection )
setGroupId ( defaultLocation ? . groupId ? ? '' )
if ( entryKey === null ) {
setLoading ( false )
setUpdatedAt ( 0 )
setForm ( EMPTY_ENTRY_FORM )
return
}
setLoading ( true )
fetchItem ( entryKey ) . then ( ( result ) = > {
setLoading ( false )
if ( ! result . ok ) {
setError ( result . error )
return
}
const entry = result . entry
setUpdatedAt ( entry . updated_at )
setForm ( {
key : entry.key ,
name : entry.name ,
href : entry.href ,
icon : entry.icon ,
description : entry.description ,
cover : entry.cover ,
tags : entry.tags.join ( ', ' ) ,
comment : entry.comment ,
body : entry.body ,
badge : entry.badge ,
summary : entry.summary ,
banner : entry.banner ,
} )
} )
// 只在打开/切换条目时重置,表单输入不应触发重新拉取
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ open , entryKey ] )
// 分区切换后保证分组仍然合法
useEffect ( ( ) = > {
if ( section ? . kind !== 'apps' ) {
setGroupId ( '' )
return
}
if ( ! groups . some ( ( group ) = > group . id === groupId ) ) {
setGroupId ( groups [ 0 ] ? . id ? ? '' )
}
} , [ section , groups , groupId ] )
const nodeKey =
section === undefined ? '' : section . kind === 'apps' ? groupId : section.id
const update = ( patch : Partial < EntryForm > ) = >
setForm ( ( previous ) = > ( { . . . previous , . . . patch } ) )
const submit = async ( ) = > {
if ( section === undefined || nodeKey === '' ) {
setError ( '请先选择分区与分组' )
return
}
if ( ! editing && form . key . trim ( ) === '' ) {
setError ( '标识不能为空' )
return
}
if ( form . name . trim ( ) === '' ) {
setError ( '标题不能为空' )
return
}
setBusy ( true )
setError ( '' )
const item = {
name : form.name.trim ( ) ,
description : form.description ,
icon : form.icon ,
tags : form.tags
. split ( /[, ,]/ )
. map ( ( tag ) = > tag . trim ( ) )
. filter ( Boolean ) ,
badge : form.badge ,
comment : form.comment ,
summary : form.summary ,
body : form.body ,
cover : form.cover ,
banner : form.banner ,
href : form.href ,
nodeKey ,
}
const result =
editing && entryKey !== null
? await saveItem ( entryKey , updatedAt , item )
: await createItem ( { key : form.key.trim ( ) , nodeKey , item } )
setBusy ( false )
if ( ! result . ok ) {
setError ( result . error )
return
}
toast . success ( editing ? '条目已保存' : ` 已添加「 ${ form . name . trim ( ) } 」 ` )
onSaved ( )
}
return (
< Modal
open = { open }
onClose = { busy ? ( ) = > undefined : onClose }
title = { editing ? '编辑条目' : '新增条目' }
size = "lg"
footer = {
< Space gap = "sm" justify = "end" >
< Button variant = "secondary" onClick = { onClose } >
取 消
< / Button >
< Button
loading = { busy }
disabled = { loading }
icon = { < Icon name = "check_line" / > }
onClick = { submit }
>
保 存
< / Button >
< / Space >
}
>
{ loading ? (
< Text > 正 在 读 取 … < / Text >
) : (
< Space direction = "column" gap = "md" align = "stretch" >
{ error !== '' && (
< Notice tone = "error" title = "操作失败" >
{ error }
< / Notice >
) }
< Space gap = "sm" wrap >
< div style = { { width : 200 } } >
< Select
options = { categories . map ( ( category ) = > ( {
value : category.id ,
label : category.title ,
} ) ) }
value = { section ? . id ? ? '' }
onChange = { setSectionId }
/ >
< / div >
{ section ? . kind === 'apps' && (
< div style = { { width : 220 } } >
< Select
options = { groups . map ( ( group ) = > ( {
value : group.id ,
label : group.name ,
} ) ) }
value = { groupId }
onChange = { setGroupId }
/ >
< / div >
) }
< / Space >
< div className = "admin-content-form" >
{ ! editing && (
< Input
value = { form . key }
placeholder = "标识,如 my-site"
aria - label = "条目标识"
onChange = { ( event ) = > update ( { key : event.target.value } ) }
/ >
) }
< Input
value = { form . name }
placeholder = "标题,如 我的站点"
aria - label = "条目标题"
onChange = { ( event ) = > update ( { name : event.target.value } ) }
/ >
< Input
value = { form . href }
placeholder = "跳转地址,如 https://example.com 或 /project/"
aria - label = "跳转地址"
onChange = { ( event ) = > update ( { href : event.target.value } ) }
/ >
< Input
value = { form . icon }
placeholder = "字符图标,如 G"
aria - label = "图标"
onChange = { ( event ) = > update ( { icon : event.target.value } ) }
/ >
< Input
value = { form . description }
placeholder = "一句话简介"
aria - label = "简介"
onChange = { ( event ) = > update ( { description : event.target.value } ) }
/ >
< Input
value = { form . cover }
placeholder = "封面 / 海报图片地址"
aria - label = "封面地址"
onChange = { ( event ) = > update ( { cover : event.target.value } ) }
/ >
< Input
value = { form . tags }
placeholder = "标签,用逗号分隔"
aria - label = "标签"
onChange = { ( event ) = > update ( { tags : event.target.value } ) }
/ >
< Input
value = { form . comment }
placeholder = "一句话评价"
aria - label = "一句话评价"
onChange = { ( event ) = > update ( { comment : event.target.value } ) }
/ >
< div className = "admin-col-span" >
< Textarea
rows = { 4 }
value = { form . body }
placeholder = "正文(图文 / Markdown),可留空"
aria - label = "正文"
onChange = { ( event ) = > update ( { body : event.target.value } ) }
/ >
< / div >
< / div >
< / Space >
) }
< / Modal >
)
}
// ---------- 通用删除确认(页面级) ----------
function ConfirmModal ( {