feat(studio): manage article series
This commit is contained in:
@@ -21,6 +21,23 @@ export const fetchRobotAuthors = (params: ListParams) =>
|
||||
export const fetchSeries = (params: ListParams) =>
|
||||
api.get<PaginatedResponse<StudioSeries>>(`${BASE}/series`, params)
|
||||
|
||||
export interface SeriesPayload {
|
||||
category_id?: string
|
||||
subtopic_id?: string | null
|
||||
robot_author_id?: string
|
||||
goal?: string | null
|
||||
positioning?: string | null
|
||||
target_readers?: string | null
|
||||
reader_journey?: string | null
|
||||
target_article_count?: number
|
||||
planned_articles?: unknown[] | null
|
||||
status?: StudioSeries['status']
|
||||
}
|
||||
|
||||
export const createSeries = (payload: SeriesPayload) => api.post<ApiResponse<StudioSeries>>(`${BASE}/series`, payload)
|
||||
export const updateSeries = (id: string, payload: SeriesPayload) => api.put<ApiResponse<StudioSeries>>(`${BASE}/series/${id}`, payload)
|
||||
export const deleteSeries = (id: string) => api.delete<ApiResponse<null>>(`${BASE}/series/${id}`)
|
||||
|
||||
export const fetchArticles = (params: ListParams) =>
|
||||
api.get<PaginatedResponse<StudioArticle>>(`${BASE}/articles`, params)
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { toast } from 'sonner'
|
||||
import type { StudioSeries } from '@/api/types'
|
||||
import { createSeries, fetchRobotAuthors, fetchSubtopics, updateSeries, type SeriesPayload } 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'
|
||||
|
||||
const statuses = ['planned', 'in_progress', 'complete'] as const
|
||||
const schema = z.object({
|
||||
category_id: z.string(), robot_author_id: z.string(), subtopic_id: z.string(),
|
||||
goal: z.string(), positioning: z.string(), target_readers: z.string(), reader_journey: z.string(),
|
||||
target_article_count: z.number().int('必须是整数').min(0, '不能小于 0'),
|
||||
planned_articles: z.string().refine((value) => { if (!value.trim()) return true; try { return Array.isArray(JSON.parse(value)) } catch { return false } }, '必须是 JSON 数组'),
|
||||
status: z.enum(statuses),
|
||||
}).superRefine((values, context) => {
|
||||
if (!values.category_id) context.addIssue({ code: 'custom', path: ['category_id'], message: '请选择分类' })
|
||||
if (!values.robot_author_id) context.addIssue({ code: 'custom', path: ['robot_author_id'], message: '请选择机器人作者' })
|
||||
})
|
||||
type FormValues = z.infer<typeof schema>
|
||||
interface Props { series: StudioSeries | null; creating: boolean; onOpenChange: (open: boolean) => void; onSaved: () => void }
|
||||
|
||||
export function SeriesEditSheet({ series, creating, onOpenChange, onSaved }: Props) {
|
||||
const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { category_id: '', robot_author_id: '', subtopic_id: '', goal: '', positioning: '', target_readers: '', reader_journey: '', target_article_count: 0, planned_articles: '', status: 'planned' } })
|
||||
useEffect(() => { form.reset({ category_id: series?.category_id ?? '', robot_author_id: series?.robot_author_id ?? '', subtopic_id: series?.subtopic_id ?? '', goal: series?.goal ?? '', positioning: series?.positioning ?? '', target_readers: series?.target_readers ?? '', reader_journey: series?.reader_journey ?? '', target_article_count: series?.target_article_count ?? 0, planned_articles: series?.planned_articles ? JSON.stringify(series.planned_articles, null, 2) : '', status: series?.status ?? 'planned' }) }, [series, creating, form])
|
||||
const categoryId = form.watch('category_id')
|
||||
const authors = useQuery({ queryKey: ['studio-robot-authors', 'series-editor', categoryId], queryFn: () => fetchRobotAuthors({ category_id: categoryId, page_size: 100 }), enabled: creating && categoryId !== '' })
|
||||
const subtopics = useQuery({ queryKey: ['studio-subtopics', 'series-editor', categoryId], queryFn: () => fetchSubtopics({ category_id: categoryId, page_size: 100 }), enabled: creating && categoryId !== '' })
|
||||
const submit = async (values: FormValues) => {
|
||||
const payload: SeriesPayload = { goal: values.goal.trim() || null, positioning: values.positioning.trim() || null, target_readers: values.target_readers.trim() || null, reader_journey: values.reader_journey.trim() || null, target_article_count: values.target_article_count, planned_articles: values.planned_articles.trim() ? JSON.parse(values.planned_articles) : null, status: values.status }
|
||||
if (creating) Object.assign(payload, { category_id: values.category_id, robot_author_id: values.robot_author_id, subtopic_id: values.subtopic_id || null })
|
||||
try { if (series) await updateSeries(series.id, payload); else await createSeries(payload); toast.success(series ? '系列已更新' : '系列已创建'); onSaved(); onOpenChange(false) } catch (error) { handleApiError(error, form.setError) }
|
||||
}
|
||||
const error = (name: keyof FormValues) => form.formState.errors[name]?.message
|
||||
return <Sheet open={creating || series !== null} onOpenChange={onOpenChange}><SheetContent className="w-full overflow-y-auto sm:max-w-xl"><SheetHeader><SheetTitle>{series ? '编辑文章系列' : '新建文章系列'}</SheetTitle></SheetHeader><form className="grid gap-4 px-4 pb-8" onSubmit={form.handleSubmit(submit)}>
|
||||
{creating && <><Field label="分类" error={error('category_id')}><Controller control={form.control} name="category_id" render={({ field }) => <CategoryPicker value={field.value || null} onChange={(value) => { field.onChange(value ?? ''); form.setValue('robot_author_id', ''); form.setValue('subtopic_id', '') }} />} /></Field><Field label="机器人作者" error={error('robot_author_id')}><Controller control={form.control} name="robot_author_id" render={({ field }) => <Select value={field.value} onValueChange={field.onChange}><SelectTrigger><SelectValue placeholder="请选择" /></SelectTrigger><SelectContent>{authors.data?.data.map((author) => <SelectItem key={author.id} value={author.id}>{author.display_name || author.user_uid}</SelectItem>)}</SelectContent></Select>} /></Field><Field label="子话题"><Controller control={form.control} name="subtopic_id" render={({ field }) => <Select value={field.value || '__none'} onValueChange={(value) => field.onChange(value === '__none' ? '' : value)}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="__none">未分配</SelectItem>{subtopics.data?.data.map((item) => <SelectItem key={item.id} value={item.id}>{item.title}</SelectItem>)}</SelectContent></Select>} /></Field></>}
|
||||
<Field label="系列目标" error={error('goal')}><Textarea {...form.register('goal')} /></Field><Field label="内容定位" error={error('positioning')}><Textarea {...form.register('positioning')} /></Field><Field label="目标读者" error={error('target_readers')}><Textarea {...form.register('target_readers')} /></Field><Field label="读者旅程" error={error('reader_journey')}><Textarea {...form.register('reader_journey')} /></Field><Field label="计划篇数" error={error('target_article_count')}><Input type="number" min={0} step={1} {...form.register('target_article_count', { valueAsNumber: true })} /></Field><Field label="计划文章(JSON 数组)" error={error('planned_articles')}><Textarea className="font-mono text-xs" rows={7} {...form.register('planned_articles')} /></Field><Field label="状态" error={error('status')}><Controller control={form.control} name="status" render={({ field }) => <Select value={field.value} onValueChange={field.onChange}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="planned">已规划</SelectItem><SelectItem value="in_progress">进行中</SelectItem><SelectItem value="complete">已完成</SelectItem></SelectContent></Select>} /></Field><Button type="submit" disabled={form.formState.isSubmitting}>{form.formState.isSubmitting ? '保存中…' : '保存'}</Button>
|
||||
</form></SheetContent></Sheet>
|
||||
}
|
||||
function Field({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) { return <div className="grid gap-2"><Label>{label}</Label>{children}{error && <p className="text-sm text-destructive">{error}</p>}</div> }
|
||||
@@ -1,9 +1,15 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PERM, requirePermission } from '@/auth/permissions'
|
||||
import { useCan } from '@/auth/store'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { StudioSeries } from '@/api/types'
|
||||
import { fetchSeries } from '@/api/modules/studio'
|
||||
import { deleteSeries, fetchSeries } from '@/api/modules/studio'
|
||||
import { handleApiError } from '@/lib/errors'
|
||||
import { SeriesEditSheet } from '@/features/studio/series-edit-sheet'
|
||||
import { useCategoryOptions } from '@/features/categories/use-categories'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { useListPage } from '@/lib/list-page'
|
||||
import { DetailSheet } from '@/features/studio/detail-sheet'
|
||||
import { DataTable } from '@/components/data-table/data-table'
|
||||
@@ -17,10 +23,18 @@ export const Route = createFileRoute('/_authed/studio/series')({
|
||||
})
|
||||
|
||||
function SeriesPage() {
|
||||
const canManage = useCan(PERM.STUDIO_MANAGE)
|
||||
const queryClient = useQueryClient()
|
||||
const [detail, setDetail] = useState<StudioSeries | null>(null)
|
||||
const [editing, setEditing] = useState<StudioSeries | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const list = useListPage('studio-series', fetchSeries)
|
||||
const { nameOf } = useCategoryOptions()
|
||||
const refresh = () => { void queryClient.invalidateQueries({ queryKey: ['studio-series'] }); void list.query.refetch() }
|
||||
const remove = async (row: StudioSeries) => { try { await deleteSeries(row.id); refresh() } catch (error) { handleApiError(error) } }
|
||||
|
||||
const columns: ColumnDef<StudioSeries>[] = [
|
||||
{ header: '分类', cell: ({ row }) => nameOf(row.original.category_id) },
|
||||
{
|
||||
header: '系列目标',
|
||||
cell: ({ row }) => (
|
||||
@@ -39,16 +53,14 @@ function SeriesPage() {
|
||||
{
|
||||
header: '操作',
|
||||
cell: ({ row }) => (
|
||||
<Button size="sm" variant="ghost" onClick={() => setDetail(row.original)}>
|
||||
详情
|
||||
</Button>
|
||||
<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="机器人作者的系列规划(目标 / 读者旅程 / 计划列表)" />
|
||||
<PageHeader title="文章系列" description="机器人作者的系列规划(目标 / 读者旅程 / 计划列表)" actions={canManage ? <Button onClick={() => setCreating(true)}>新建系列</Button> : undefined} />
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={list.rows}
|
||||
@@ -75,6 +87,7 @@ function SeriesPage() {
|
||||
]}
|
||||
jsonBlocks={[{ label: '计划文章列表', value: detail?.planned_articles }]}
|
||||
/>
|
||||
<SeriesEditSheet series={editing} creating={creating} onOpenChange={(open) => { if (!open) { setEditing(null); setCreating(false) } }} onSaved={refresh} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user