feat(studio): manage content subtopics
This commit is contained in:
@@ -27,6 +27,25 @@ export const fetchArticles = (params: ListParams) =>
|
||||
export const fetchSubtopics = (params: ListParams) =>
|
||||
api.get<PaginatedResponse<StudioSubtopic>>(`${BASE}/subtopics`, params)
|
||||
|
||||
export interface SubtopicPayload {
|
||||
category_id?: string
|
||||
title?: string
|
||||
target_audience?: string | null
|
||||
pain_points?: string | null
|
||||
content_value?: string | null
|
||||
expandability_score?: number | null
|
||||
status?: StudioSubtopic['status']
|
||||
}
|
||||
|
||||
export const createSubtopic = (payload: SubtopicPayload) =>
|
||||
api.post<ApiResponse<StudioSubtopic>>(`${BASE}/subtopics`, payload)
|
||||
|
||||
export const updateSubtopic = (id: string, payload: SubtopicPayload) =>
|
||||
api.put<ApiResponse<StudioSubtopic>>(`${BASE}/subtopics/${id}`, payload)
|
||||
|
||||
export const deleteSubtopic = (id: string) =>
|
||||
api.delete<ApiResponse<null>>(`${BASE}/subtopics/${id}`)
|
||||
|
||||
export const fetchCategoryBriefs = (params: ListParams) =>
|
||||
api.get<PaginatedResponse<StudioCategoryBrief>>(`${BASE}/category-briefs`, params)
|
||||
|
||||
|
||||
@@ -1273,8 +1273,8 @@ export interface paths {
|
||||
*/
|
||||
content_value?: string;
|
||||
/**
|
||||
* @description 可扩展性评分,0-255。
|
||||
* @example 80
|
||||
* @description 可扩展性评分,0-10。
|
||||
* @example 8
|
||||
*/
|
||||
expandability_score?: number;
|
||||
/**
|
||||
@@ -3277,8 +3277,8 @@ export interface paths {
|
||||
*/
|
||||
content_value?: string;
|
||||
/**
|
||||
* @description 可扩展性评分,0-255。
|
||||
* @example 90
|
||||
* @description 可扩展性评分,0-10。
|
||||
* @example 9
|
||||
*/
|
||||
expandability_score?: number;
|
||||
/**
|
||||
@@ -7685,9 +7685,9 @@ export interface paths {
|
||||
* @example b
|
||||
*/
|
||||
q?: string | null;
|
||||
/** @example false */
|
||||
status?: boolean | null;
|
||||
/** @example true */
|
||||
status?: boolean | null;
|
||||
/** @example false */
|
||||
is_robot?: boolean | null;
|
||||
/**
|
||||
* @description Must not be greater than 100 characters.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { StudioSubtopic } from '@/api/types'
|
||||
import { createSubtopic, updateSubtopic, type SubtopicPayload } from '@/api/modules/studio'
|
||||
import { handleApiError } from '@/lib/errors'
|
||||
import { CategoryPicker } from '@/features/categories/category-picker'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
interface Props { subtopic: StudioSubtopic | null; creating: boolean; onOpenChange: (open: boolean) => void; onSaved: () => void }
|
||||
|
||||
export function SubtopicEditSheet({ subtopic, creating, onOpenChange, onSaved }: Props) {
|
||||
const open = creating || subtopic !== null
|
||||
const [categoryId, setCategoryId] = useState<string | null>(null)
|
||||
const [title, setTitle] = useState('')
|
||||
const [targetAudience, setTargetAudience] = useState('')
|
||||
const [painPoints, setPainPoints] = useState('')
|
||||
const [contentValue, setContentValue] = useState('')
|
||||
const [score, setScore] = useState('')
|
||||
const [status, setStatus] = useState<StudioSubtopic['status']>('active')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setCategoryId(subtopic?.category_id ?? null); setTitle(subtopic?.title ?? '')
|
||||
setTargetAudience(subtopic?.target_audience ?? ''); setPainPoints(subtopic?.pain_points ?? '')
|
||||
setContentValue(subtopic?.content_value ?? ''); setScore(subtopic?.expandability_score?.toString() ?? '')
|
||||
setStatus(subtopic?.status ?? 'active')
|
||||
}, [subtopic, creating])
|
||||
|
||||
const save = async () => {
|
||||
if (!categoryId || !title.trim()) { toast.error('请选择分类并填写标题'); return }
|
||||
const numericScore = score === '' ? null : Number(score)
|
||||
if (numericScore !== null && (!Number.isInteger(numericScore) || numericScore < 0 || numericScore > 10)) { toast.error('可扩展性评分必须是 0–10 的整数'); return }
|
||||
const payload: SubtopicPayload = { category_id: categoryId, title: title.trim(), target_audience: targetAudience.trim() || null, pain_points: painPoints.trim() || null, content_value: contentValue.trim() || null, expandability_score: numericScore, status }
|
||||
setSaving(true)
|
||||
try {
|
||||
if (subtopic) await updateSubtopic(subtopic.id, payload); else await createSubtopic(payload)
|
||||
toast.success(subtopic ? '子话题已更新' : '子话题已创建'); onSaved(); onOpenChange(false)
|
||||
} catch (error) { handleApiError(error) } finally { setSaving(false) }
|
||||
}
|
||||
|
||||
return <Sheet open={open} onOpenChange={onOpenChange}><SheetContent className="w-full overflow-y-auto sm:max-w-xl"><SheetHeader><SheetTitle>{subtopic ? '编辑子话题' : '新建子话题'}</SheetTitle></SheetHeader><div className="grid gap-5 px-4 pb-8">
|
||||
<div className="grid gap-2"><Label>分类</Label><CategoryPicker value={categoryId} onChange={setCategoryId} /></div>
|
||||
<div className="grid gap-2"><Label htmlFor="subtopic-title">标题</Label><Input id="subtopic-title" value={title} maxLength={255} onChange={(e) => setTitle(e.target.value)} /></div>
|
||||
<div className="grid gap-2"><Label htmlFor="subtopic-audience">目标受众</Label><Textarea id="subtopic-audience" value={targetAudience} onChange={(e) => setTargetAudience(e.target.value)} /></div>
|
||||
<div className="grid gap-2"><Label htmlFor="subtopic-pains">读者痛点</Label><Textarea id="subtopic-pains" value={painPoints} onChange={(e) => setPainPoints(e.target.value)} /></div>
|
||||
<div className="grid gap-2"><Label htmlFor="subtopic-value">内容价值</Label><Textarea id="subtopic-value" value={contentValue} onChange={(e) => setContentValue(e.target.value)} /></div>
|
||||
<div className="grid gap-2"><Label htmlFor="subtopic-score">可扩展性评分(0–10)</Label><Input id="subtopic-score" type="number" min={0} max={10} step={1} value={score} onChange={(e) => setScore(e.target.value)} /></div>
|
||||
<div className="grid gap-2"><Label>状态</Label><Select value={status} onValueChange={(value) => setStatus(value as StudioSubtopic['status'])}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="active">启用</SelectItem><SelectItem value="archived">归档</SelectItem></SelectContent></Select></div>
|
||||
<Button disabled={saving} onClick={save}>{saving ? '保存中…' : '保存'}</Button>
|
||||
</div></SheetContent></Sheet>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user