feat(studio): manage content subtopics

This commit is contained in:
2026-09-10 13:49:04 +08:00
parent 0599d004cf
commit 6d34238e34
4 changed files with 136 additions and 49 deletions
+55 -43
View File
@@ -1,56 +1,68 @@
import { useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { PERM, requirePermission } from '@/auth/permissions'
import type { ColumnDef } from '@tanstack/react-table'
import type { StudioSubtopic } from '@/api/types'
import { fetchSubtopics } from '@/api/modules/studio'
import { useListPage } from '@/lib/list-page'
import { X } from 'lucide-react'
import { useState } from 'react'
import { deleteSubtopic, fetchSubtopics } from '@/api/modules/studio'
import type { ListParams, StudioSubtopic } from '@/api/types'
import { PERM, requirePermission } from '@/auth/permissions'
import { useCan } from '@/auth/store'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataTable } from '@/components/data-table/data-table'
import { PageHeader } from '@/components/page-header'
import { StatusPill } from '@/components/status-pill'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { CategoryPicker } from '@/features/categories/category-picker'
import { useCategoryOptions } from '@/features/categories/use-categories'
import { DetailSheet } from '@/features/studio/detail-sheet'
import { SubtopicEditSheet } from '@/features/studio/subtopic-edit-sheet'
import { handleApiError } from '@/lib/errors'
import { useListPage } from '@/lib/list-page'
export const Route = createFileRoute('/_authed/studio/subtopics')({
beforeLoad: () => requirePermission(PERM.STUDIO_READ),
component: SubtopicsPage,
beforeLoad: () => requirePermission(PERM.STUDIO_READ), component: SubtopicsPage,
})
const columns: ColumnDef<StudioSubtopic>[] = [
{
header: '子话题',
cell: ({ row }) => (
<div>
<div className="font-medium">{row.original.title}</div>
<div className="max-w-96 truncate text-xs text-muted-foreground">
{row.original.target_audience ?? ''}
</div>
</div>
),
},
{
header: '可扩展性',
cell: ({ row }) =>
row.original.expandability_score != null ? `${row.original.expandability_score}/10` : '—',
},
{ header: '状态', cell: ({ row }) => <StatusPill status={row.original.status} /> },
{
header: '更新时间',
cell: ({ row }) => new Date(row.original.updated_at).toLocaleString('zh-CN'),
},
]
function SubtopicsPage() {
const list = useListPage('studio-subtopics', fetchSubtopics)
const canManage = useCan(PERM.STUDIO_MANAGE)
const queryClient = useQueryClient()
const [categoryId, setCategoryId] = useState<string | null>(null)
const [status, setStatus] = useState('all')
const [detail, setDetail] = useState<StudioSubtopic | null>(null)
const [editing, setEditing] = useState<StudioSubtopic | null>(null)
const [creating, setCreating] = useState(false)
const { nameOf } = useCategoryOptions()
const filters: ListParams = {}
if (categoryId) filters.category_id = categoryId
if (status !== 'all') filters.status = status
const list = useListPage('studio-subtopics', fetchSubtopics, filters)
const refresh = () => { void queryClient.invalidateQueries({ queryKey: ['studio-subtopics'] }); void list.query.refetch() }
const remove = async (row: StudioSubtopic) => { try { await deleteSubtopic(row.id); refresh() } catch (error) { handleApiError(error) } }
return (
<div>
<PageHeader title="子话题" description="分类下的细分内容专题(category_id + title 幂等)" />
<DataTable
columns={columns}
data={list.rows}
pagination={list.pagination}
loading={list.loading}
onPageChange={list.setPage}
onPageSizeChange={list.setPageSize}
/>
const columns: ColumnDef<StudioSubtopic>[] = [
{ header: '分类', cell: ({ row }) => nameOf(row.original.category_id) },
{ header: '子话题', cell: ({ row }) => <div><div className="font-medium">{row.original.title}</div><div className="max-w-96 truncate text-xs text-muted-foreground">{row.original.target_audience ?? ''}</div></div> },
{ header: '可扩展性', cell: ({ row }) => row.original.expandability_score != null ? `${row.original.expandability_score}/10` : '—' },
{ header: '状态', cell: ({ row }) => <StatusPill status={row.original.status} /> },
{ header: '更新时间', cell: ({ row }) => new Date(row.original.updated_at).toLocaleString('zh-CN') },
{ header: '操作', cell: ({ row }) => <div className="flex gap-1.5">
<Button size="sm" variant="ghost" onClick={() => setDetail(row.original)}></Button>
{canManage && <Button size="sm" variant="outline" onClick={() => setEditing(row.original)}></Button>}
{canManage && <ConfirmDialog trigger={<Button size="sm" variant="destructive"></Button>} title="删除子话题?" description="关联内容不会一并删除;如存在数据库约束,服务端会拒绝操作。" confirmLabel="删除" destructive onConfirm={() => remove(row.original)} />}
</div> },
]
return <div>
<PageHeader title="子话题" description="分类下的细分内容专题(category_id + title 幂等)" actions={canManage ? <Button onClick={() => setCreating(true)}></Button> : undefined} />
<div className="mb-4 flex flex-wrap items-end gap-4 rounded-lg border bg-card p-3">
<div className="grid gap-1.5"><Label className="text-xs text-muted-foreground"></Label><CategoryPicker value={categoryId} onChange={setCategoryId} /></div>
<div className="grid gap-1.5"><Label className="text-xs text-muted-foreground"></Label><Select value={status} onValueChange={setStatus}><SelectTrigger size="sm" className="w-32"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="all"></SelectItem><SelectItem value="active"></SelectItem><SelectItem value="archived"></SelectItem></SelectContent></Select></div>
{(categoryId || status !== 'all') && <Button variant="ghost" size="sm" onClick={() => { setCategoryId(null); setStatus('all') }}><X className="size-4" /></Button>}
</div>
)
<DataTable columns={columns} data={list.rows} pagination={list.pagination} loading={list.loading} onPageChange={list.setPage} onPageSizeChange={list.setPageSize} />
<DetailSheet open={detail !== null} onOpenChange={(open) => !open && setDetail(null)} title={detail?.title ?? '子话题'} description={detail ? nameOf(detail.category_id) : undefined} fields={[{ label: '目标受众', value: detail?.target_audience }, { label: '读者痛点', value: detail?.pain_points }, { label: '内容价值', value: detail?.content_value }, { label: '可扩展性', value: detail?.expandability_score != null ? `${detail.expandability_score}/10` : undefined }, { label: '状态', value: detail && <StatusPill status={detail.status} /> }]} />
<SubtopicEditSheet subtopic={editing} creating={creating} onOpenChange={(open) => { if (!open) { setEditing(null); setCreating(false) } }} onSaved={refresh} />
</div>
}