feat(studio): manage article plans

This commit is contained in:
2026-09-10 14:17:30 +08:00
parent 1fbc6cab5b
commit ecdbf4d768
4 changed files with 81 additions and 21 deletions
+21
View File
@@ -41,6 +41,27 @@ export const deleteSeries = (id: string) => api.delete<ApiResponse<null>>(`${BAS
export const fetchArticles = (params: ListParams) =>
api.get<PaginatedResponse<StudioArticle>>(`${BASE}/articles`, params)
export interface ArticlePayload {
series_id?: string
article_number?: number
plan?: Record<string, unknown> | null
outline?: Record<string, unknown> | null
title?: string | null
summary?: string | null
key_takeaways?: string[] | null
cta?: string | null
tags?: string[] | null
seo_keywords?: string[] | null
cover_image_prompt?: string | null
inline_image_prompts?: string[] | null
status?: StudioArticle['status']
drafting_model?: Record<string, unknown> | null
publish_schedule?: Record<string, unknown> | null
}
export const createArticle = (payload: ArticlePayload) => api.post<ApiResponse<StudioArticle>>(`${BASE}/articles`, payload)
export const updateArticle = (id: string, payload: ArticlePayload) => api.put<ApiResponse<StudioArticle>>(`${BASE}/articles/${id}`, payload)
export const deleteArticle = (id: string) => api.delete<ApiResponse<null>>(`${BASE}/articles/${id}`)
export const fetchSubtopics = (params: ListParams) =>
api.get<PaginatedResponse<StudioSubtopic>>(`${BASE}/subtopics`, params)
+2 -16
View File
@@ -2428,28 +2428,14 @@ export interface paths {
/**
* @description UUID
* @example 9f1c2d3e-4b5a-6789-0abc-def012345678
*
*/
series_id: string;
/**
* @description UUID
* @example 3c4d5e6f-7081-9234-5abc-def012345678
*/
robot_author_id: string;
/**
* @description UUID
* @example 1a2b3c4d-5e6f-7081-9234-abcdef012345
*/
category_id: string;
/**
* @description UUID
* @example 2b3c4d5e-6f70-8912-3abc-def012345678
*/
subtopic_id?: string;
/**
* @description >=1
* @example 1
*/
article_number?: number;
article_number: number;
/**
* @description /
* @example []
@@ -0,0 +1,41 @@
import { useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { zodResolver } from '@hookform/resolvers/zod'
import { Controller, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
import { createArticle, fetchSeries, updateArticle, type ArticlePayload } from '@/api/modules/studio'
import type { StudioArticle } from '@/api/types'
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'
import { handleApiError } from '@/lib/errors'
const jsonObject = z.string().refine((value) => { if (!value.trim()) return true; try { const parsed = JSON.parse(value); return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) } catch { return false } }, '必须是 JSON 对象')
const schema = z.object({ series_id: z.string(), article_number: z.number().int('必须是整数').min(1, '不能小于 1'), title: z.string().max(255), summary: z.string(), cta: z.string(), cover_image_prompt: z.string(), key_takeaways: z.string(), tags: z.string(), seo_keywords: z.string(), inline_image_prompts: z.string(), plan: jsonObject, outline: jsonObject, drafting_model: jsonObject, publish_schedule: jsonObject, status: z.enum(['planned', 'drafting', 'drafted', 'published', 'scheduled']) }).superRefine((values, context) => { if (!values.series_id) context.addIssue({ code: 'custom', path: ['series_id'], message: '请选择系列' }) })
type FormValues = z.infer<typeof schema>
const lines = (value: string) => value.split('\n').map((item) => item.trim()).filter(Boolean)
const object = (value: string) => value.trim() ? JSON.parse(value) as Record<string, unknown> : null
const text = (value: unknown) => value ? JSON.stringify(value, null, 2) : ''
interface Props { article: StudioArticle | null; creating: boolean; onOpenChange: (open: boolean) => void; onSaved: () => void }
export function ArticleEditSheet({ article, creating, onOpenChange, onSaved }: Props) {
const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { series_id: '', article_number: 1, title: '', summary: '', cta: '', cover_image_prompt: '', key_takeaways: '', tags: '', seo_keywords: '', inline_image_prompts: '', plan: '', outline: '', drafting_model: '', publish_schedule: '', status: 'planned' } })
useEffect(() => { form.reset({ series_id: article?.series_id ?? '', article_number: article?.article_number ?? 1, title: article?.title ?? '', summary: article?.summary ?? '', cta: article?.cta ?? '', cover_image_prompt: article?.cover_image_prompt ?? '', key_takeaways: article?.key_takeaways?.join('\n') ?? '', tags: article?.tags?.join('\n') ?? '', seo_keywords: article?.seo_keywords?.join('\n') ?? '', inline_image_prompts: (article?.inline_image_prompts as string[] | null)?.join('\n') ?? '', plan: text(article?.plan), outline: text(article?.outline), drafting_model: text(article?.drafting_model), publish_schedule: text(article?.publish_schedule), status: article?.status ?? 'planned' }) }, [article, creating, form])
const series = useQuery({ queryKey: ['studio-series', 'article-editor'], queryFn: () => fetchSeries({ page_size: 100 }), enabled: creating })
const submit = async (values: FormValues) => {
const payload: ArticlePayload = { title: values.title.trim() || null, summary: values.summary.trim() || null, cta: values.cta.trim() || null, cover_image_prompt: values.cover_image_prompt.trim() || null, key_takeaways: lines(values.key_takeaways), tags: lines(values.tags), seo_keywords: lines(values.seo_keywords), inline_image_prompts: lines(values.inline_image_prompts), plan: object(values.plan), outline: object(values.outline), drafting_model: object(values.drafting_model), publish_schedule: object(values.publish_schedule), status: values.status }
if (creating) Object.assign(payload, { series_id: values.series_id, article_number: values.article_number })
try { if (article) await updateArticle(article.id, payload); else await createArticle(payload); toast.success(article ? '文章计划已更新' : '文章计划已创建'); onSaved(); onOpenChange(false) } catch (error) { handleApiError(error, form.setError) }
}
const error = (name: keyof FormValues) => form.formState.errors[name]?.message
return <Sheet open={creating || article !== null} onOpenChange={onOpenChange}><SheetContent className="w-full overflow-y-auto sm:max-w-2xl"><SheetHeader><SheetTitle>{article ? '编辑文章计划' : '新建文章计划'}</SheetTitle></SheetHeader><form className="grid gap-4 px-4 pb-8" onSubmit={form.handleSubmit(submit)}>
{creating && <><Field label="系列" error={error('series_id')}><Controller control={form.control} name="series_id" render={({ field }) => <Select value={field.value} onValueChange={field.onChange}><SelectTrigger><SelectValue placeholder="请选择系列" /></SelectTrigger><SelectContent>{series.data?.data.map((item) => <SelectItem key={item.id} value={item.id}>{item.goal || item.id}</SelectItem>)}</SelectContent></Select>} /></Field><Field label="系列内序号" error={error('article_number')}><Input type="number" min={1} {...form.register('article_number', { valueAsNumber: true })} /></Field></>}
<Field label="标题" error={error('title')}><Input maxLength={255} {...form.register('title')} /></Field><Field label="摘要" error={error('summary')}><Textarea {...form.register('summary')} /></Field><Field label="核心要点(每行一个)" error={error('key_takeaways')}><Textarea {...form.register('key_takeaways')} /></Field><Field label="标签(每行一个)" error={error('tags')}><Textarea {...form.register('tags')} /></Field><Field label="SEO 关键词(每行一个)" error={error('seo_keywords')}><Textarea {...form.register('seo_keywords')} /></Field><Field label="CTA" error={error('cta')}><Textarea {...form.register('cta')} /></Field><Field label="封面提示词" error={error('cover_image_prompt')}><Textarea {...form.register('cover_image_prompt')} /></Field><Field label="内文配图提示词(每行一个)" error={error('inline_image_prompts')}><Textarea {...form.register('inline_image_prompts')} /></Field>
{(['plan', 'outline', 'drafting_model', 'publish_schedule'] as const).map((name) => <Field key={name} label={`${name}JSON 对象)`} error={error(name)}><Textarea rows={5} className="font-mono text-xs" {...form.register(name)} /></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>{['planned', 'drafting', 'drafted', 'published', 'scheduled'].map((value) => <SelectItem key={value} value={value}>{value}</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> }
+17 -5
View File
@@ -1,9 +1,14 @@
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 { StudioArticle } from '@/api/types'
import { fetchArticles } from '@/api/modules/studio'
import { deleteArticle, fetchArticles } from '@/api/modules/studio'
import { handleApiError } from '@/lib/errors'
import { ArticleEditSheet } from '@/features/studio/article-edit-sheet'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { useListPage } from '@/lib/list-page'
import { ArticlePreviewSheet } from '@/features/studio/article-preview-sheet'
import { DetailSheet } from '@/features/studio/detail-sheet'
@@ -27,14 +32,20 @@ export const Route = createFileRoute('/_authed/studio/articles')({
const STATUSES = ['planned', 'drafting', 'drafted', 'published', 'scheduled'] as const
function ArticlesPage() {
const canManage = useCan(PERM.STUDIO_MANAGE)
const queryClient = useQueryClient()
const [status, setStatus] = useState('all')
const [detail, setDetail] = useState<StudioArticle | null>(null)
const [preview, setPreview] = useState<StudioArticle | null>(null)
const [editing, setEditing] = useState<StudioArticle | null>(null)
const [creating, setCreating] = useState(false)
const list = useListPage(
'studio-articles',
fetchArticles,
status === 'all' ? {} : { status },
)
const refresh = () => { void queryClient.invalidateQueries({ queryKey: ['studio-articles'] }); void list.query.refetch() }
const remove = async (row: StudioArticle) => { try { await deleteArticle(row.id); refresh() } catch (error) { handleApiError(error) } }
const columns: ColumnDef<StudioArticle>[] = [
{
@@ -83,6 +94,8 @@ function ArticlesPage() {
<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="已创建的 Dimzou 内容不会由此操作级联删除。" confirmLabel="删除" destructive onConfirm={() => remove(row.original)} />}
</div>
),
},
@@ -93,8 +106,7 @@ function ArticlesPage() {
<PageHeader
title="文章"
description="正文存于 Dimzou 草稿(块修订即编辑历史);此处为计划元数据"
actions={
<Select value={status} onValueChange={setStatus}>
actions={<div className="flex gap-2">{canManage && <Button onClick={() => setCreating(true)}></Button>}<Select value={status} onValueChange={setStatus}>
<SelectTrigger size="sm" className="w-32">
<SelectValue />
</SelectTrigger>
@@ -106,8 +118,7 @@ function ArticlesPage() {
</SelectItem>
))}
</SelectContent>
</Select>
}
</Select></div>}
/>
<DataTable
columns={columns}
@@ -157,6 +168,7 @@ function ArticlesPage() {
/>
<ArticlePreviewSheet article={preview} onOpenChange={(open) => !open && setPreview(null)} />
<ArticleEditSheet article={editing} creating={creating} onOpenChange={(open) => { if (!open) { setEditing(null); setCreating(false) } }} onSaved={refresh} />
</div>
)
}