feat: 格式从分组下沉到条目(v21)
- entry 新增 format(apps/posters/articles),老数据按根分组 view_mode 回填;seed 按分区写入 - 分组管理移除格式字段/显示,根分组只作容器 - 内容弹窗新增逐条格式选择,列表增加格式列;ItemEditor 保存时保留原格式 - 前台按 entry.format 渲染:卡片/分组页支持同组混合格式,详情按条目格式选模板 - 迁移后 live 20 应用 / 13 海报;cargo test 59 passed;Playwright 验证混合展示
This commit is contained in:
+18
-1
@@ -348,6 +348,10 @@
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.mixed-format-block + .mixed-format-block {
|
||||
margin-top: var(--su-space-4);
|
||||
}
|
||||
|
||||
.group-crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1231,7 +1235,20 @@
|
||||
|
||||
/* 内容页:与分组管理一致的列表 */
|
||||
.entry-table .entry-col-href {
|
||||
width: 40%;
|
||||
width: 36%;
|
||||
}
|
||||
|
||||
.entry-table .entry-col-format {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.entry-format {
|
||||
display: inline-block;
|
||||
padding: 0 var(--su-space-2);
|
||||
border-radius: var(--su-radius-xs);
|
||||
background-color: var(--su-bg-subtle);
|
||||
color: var(--su-text-muted);
|
||||
font-size: var(--su-font-size-sm);
|
||||
}
|
||||
|
||||
.entry-table .entry-col-group {
|
||||
|
||||
+32
-14
@@ -144,8 +144,12 @@ function collectEntries(group: GroupNode): Record<string, unknown>[] {
|
||||
}
|
||||
|
||||
function defaultLimit(group: GroupNode): number {
|
||||
const fallback = group.format === 'apps' ? 4 : group.format === 'posters' ? 6 : 4
|
||||
return group.previewLimit ?? fallback
|
||||
return group.previewLimit ?? 6
|
||||
}
|
||||
|
||||
function entryFormat(entry: Record<string, unknown>): 'apps' | 'posters' | 'articles' {
|
||||
const value = entry.format
|
||||
return value === 'posters' || value === 'articles' ? value : 'apps'
|
||||
}
|
||||
|
||||
function matches(entry: Record<string, unknown>, keyword: string): boolean {
|
||||
@@ -273,13 +277,25 @@ export default function AppNext() {
|
||||
const items = filtered.slice(0, limit)
|
||||
const open = (itemId: string) => navigate(`/g/${encodeURIComponent(group.key)}/i/${encodeURIComponent(itemId)}`)
|
||||
|
||||
if (group.format === 'posters') {
|
||||
return <PosterGrid items={items as unknown as PosterItem[]} onOpen={open} />
|
||||
}
|
||||
if (group.format === 'articles') {
|
||||
return <CesCardGrid items={items as unknown as ArticleItem[]} onOpen={open} />
|
||||
}
|
||||
return <AppGrid items={items as unknown as AppItem[]} onOpen={open} />
|
||||
return (
|
||||
<>
|
||||
{(['apps', 'posters', 'articles'] as const).map((format) => {
|
||||
const subset = items.filter((entry) => entryFormat(entry) === format)
|
||||
if (subset.length === 0) return null
|
||||
return (
|
||||
<div className="mixed-format-block" key={format}>
|
||||
{format === 'posters' ? (
|
||||
<PosterGrid items={subset as unknown as PosterItem[]} onOpen={open} />
|
||||
) : format === 'articles' ? (
|
||||
<CesCardGrid items={subset as unknown as ArticleItem[]} onOpen={open} />
|
||||
) : (
|
||||
<AppGrid items={subset as unknown as AppItem[]} onOpen={open} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderGroupBlock(group: GroupNode, limit: number, showTitle: boolean) {
|
||||
@@ -423,7 +439,9 @@ export default function AppNext() {
|
||||
{ label: String(entry.name ?? entry.title ?? entry.id) },
|
||||
]
|
||||
|
||||
if (group.format === 'articles') {
|
||||
const format = entryFormat(entry)
|
||||
|
||||
if (format === 'articles') {
|
||||
const article = entry as unknown as ArticleItem
|
||||
return (
|
||||
<ArticleDetailView
|
||||
@@ -438,18 +456,18 @@ export default function AppNext() {
|
||||
}
|
||||
|
||||
const model: ItemDetailModel =
|
||||
group.format === 'posters'
|
||||
format === 'posters'
|
||||
? posterDetailModel(entry as unknown as PosterItem)
|
||||
: appDetailModel(entry as unknown as AppItem)
|
||||
|
||||
const related: RelatedItem[] =
|
||||
group.format === 'posters'
|
||||
format === 'posters'
|
||||
? group.entries
|
||||
.filter((item) => item.id !== entry.id)
|
||||
.filter((item) => item.id !== entry.id && entryFormat(item as unknown as Record<string, unknown>) === format)
|
||||
.slice(0, 4)
|
||||
.map((item) => posterToRelatedItem(item as unknown as PosterItem))
|
||||
: group.entries
|
||||
.filter((item) => item.id !== entry.id)
|
||||
.filter((item) => item.id !== entry.id && entryFormat(item as unknown as Record<string, unknown>) === format)
|
||||
.slice(0, 4)
|
||||
.map((item) => toRelatedItem(item as unknown as AppItem))
|
||||
|
||||
|
||||
@@ -499,7 +499,6 @@ function GroupsPanel({
|
||||
)}
|
||||
<GroupMark icon={node.icon} />
|
||||
<span className="group-name">{node.title}</span>
|
||||
<span className="group-format">{FORMAT_LABEL[node.format]}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="group-sort">{node.sort}</td>
|
||||
@@ -583,7 +582,6 @@ function GroupDialog({
|
||||
const [key, setKey] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
const [parentKey, setParentKey] = useState('')
|
||||
const [format, setFormat] = useState<GroupFormat>('apps')
|
||||
const [icon, setIcon] = useState('')
|
||||
const [sort, setSort] = useState('')
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
@@ -599,7 +597,6 @@ function GroupDialog({
|
||||
setKey(state.group.key)
|
||||
setTitle(state.group.title)
|
||||
setParentKey(state.group.parentKey ?? '')
|
||||
setFormat(state.group.format)
|
||||
setIcon(state.group.icon)
|
||||
setSort(String(state.group.sort))
|
||||
setEnabled(state.group.enabled)
|
||||
@@ -608,7 +605,6 @@ function GroupDialog({
|
||||
|
||||
setKey('')
|
||||
setTitle('')
|
||||
setFormat('apps')
|
||||
setIcon('')
|
||||
setSort('')
|
||||
setEnabled(true)
|
||||
@@ -625,7 +621,6 @@ function GroupDialog({
|
||||
|
||||
const rootMode = parentKey === '' || isRootEdit
|
||||
const showKey = editing === undefined
|
||||
|
||||
const submit = async () => {
|
||||
if (title.trim() === '') {
|
||||
setError('分组名称不能为空')
|
||||
@@ -645,9 +640,7 @@ function GroupDialog({
|
||||
icon,
|
||||
sort: sort.trim() === '' ? undefined : Number(sort),
|
||||
enabled,
|
||||
...(isRootEdit
|
||||
? { format }
|
||||
: { parentKey: parentKey }),
|
||||
...(isRootEdit ? {} : { parentKey }),
|
||||
})
|
||||
setBusy(false)
|
||||
if (!result.ok) {
|
||||
@@ -663,7 +656,6 @@ function GroupDialog({
|
||||
key: key.trim(),
|
||||
title: title.trim(),
|
||||
parentKey: rootMode ? undefined : parentKey,
|
||||
format: rootMode ? format : undefined,
|
||||
icon,
|
||||
sort: sort.trim() === '' ? undefined : Number(sort),
|
||||
enabled,
|
||||
@@ -750,16 +742,6 @@ function GroupDialog({
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{rootMode && (
|
||||
<FormField label="格式" htmlFor="group-format" hint="根分组决定这一棵树的渲染格式">
|
||||
<Select
|
||||
options={FORMAT_OPTIONS}
|
||||
value={format}
|
||||
onChange={(next) => setFormat(next as GroupFormat)}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField label="图标样式" htmlFor="group-icon" hint="留空则不显示图标;可填 iconfont 图标名或一个字符">
|
||||
<div className="group-icon-field">
|
||||
<span className="group-icon-preview">
|
||||
@@ -877,6 +859,7 @@ function ContentPanel({ groups, onReload }: { groups: GroupNode[]; onReload: ()
|
||||
<thead>
|
||||
<tr>
|
||||
<th>标题</th>
|
||||
<th className="entry-col-format">格式</th>
|
||||
<th className="entry-col-href">跳转链接</th>
|
||||
<th className="entry-col-group">所属分组</th>
|
||||
<th className="group-col-actions">操作</th>
|
||||
@@ -888,6 +871,11 @@ function ContentPanel({ groups, onReload }: { groups: GroupNode[]; onReload: ()
|
||||
<td>
|
||||
<span className="entry-title">{entry.name ?? entry.title ?? entry.id}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="entry-format">
|
||||
{FORMAT_LABEL[(entry.format ?? 'apps') as GroupFormat]}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<EntryLink href={entry.href} />
|
||||
</td>
|
||||
@@ -925,7 +913,7 @@ function ContentPanel({ groups, onReload }: { groups: GroupNode[]; onReload: ()
|
||||
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="group-empty">
|
||||
<td colSpan={5} className="group-empty">
|
||||
该分组下还没有内容
|
||||
</td>
|
||||
</tr>
|
||||
@@ -972,6 +960,7 @@ function EntryDialog({
|
||||
const [error, setError] = useState('')
|
||||
const [form, setForm] = useState({
|
||||
key: '',
|
||||
format: 'apps' as GroupFormat,
|
||||
name: '',
|
||||
href: '',
|
||||
icon: '',
|
||||
@@ -994,6 +983,7 @@ function EntryDialog({
|
||||
setUpdatedAt(0)
|
||||
setForm({
|
||||
key: '',
|
||||
format: 'apps',
|
||||
name: '',
|
||||
href: '',
|
||||
icon: '',
|
||||
@@ -1016,6 +1006,7 @@ function EntryDialog({
|
||||
setUpdatedAt(result.entry.updated_at)
|
||||
setForm({
|
||||
key: result.entry.key,
|
||||
format: result.entry.format,
|
||||
name: result.entry.name,
|
||||
href: result.entry.href,
|
||||
icon: result.entry.icon,
|
||||
@@ -1044,6 +1035,7 @@ function EntryDialog({
|
||||
setBusy(true)
|
||||
setError('')
|
||||
const item = {
|
||||
format: form.format,
|
||||
name: form.name.trim(),
|
||||
description: form.description,
|
||||
icon: form.icon,
|
||||
@@ -1111,6 +1103,14 @@ function EntryDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField label="格式" htmlFor="entry-format" hint="每条内容单独选择格式,同一个分组下可以混合">
|
||||
<Select
|
||||
options={FORMAT_OPTIONS}
|
||||
value={form.format}
|
||||
onChange={(next) => update({ format: next as GroupFormat })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="标识"
|
||||
htmlFor="entry-key"
|
||||
|
||||
@@ -91,6 +91,7 @@ export function ItemEditor({ open, itemKey, onClose, onSaved }: ItemEditorProps)
|
||||
|
||||
const result = await saveItem(entry.key, entry.updated_at, {
|
||||
...form,
|
||||
format: entry.format,
|
||||
icon: entry.icon,
|
||||
tags: form.tags
|
||||
.split(/[,,]/)
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
*
|
||||
* 读取是公开的(编辑表单要能预览),写入必须登录,并用 `updatedAt` 做乐观锁。
|
||||
*/
|
||||
export type EntryFormat = 'apps' | 'posters' | 'articles'
|
||||
|
||||
export type EntryRecord = {
|
||||
key: string
|
||||
format: EntryFormat
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
@@ -21,6 +24,8 @@ export type EntryRecord = {
|
||||
|
||||
export type EntryInput = {
|
||||
name: string
|
||||
/** 条目的展示格式;不传由后端按 apps 处理 */
|
||||
format?: EntryFormat
|
||||
description: string
|
||||
icon: string
|
||||
tags: string[]
|
||||
|
||||
@@ -7,6 +7,7 @@ export type GroupFormat = 'apps' | 'posters' | 'articles'
|
||||
|
||||
export type GroupEntry = {
|
||||
id: string
|
||||
format?: GroupFormat
|
||||
name?: string
|
||||
title?: string
|
||||
href?: string
|
||||
|
||||
Reference in New Issue
Block a user