diff --git a/src/api/modules/studio.ts b/src/api/modules/studio.ts index 90c84d4..70b6a4b 100644 --- a/src/api/modules/studio.ts +++ b/src/api/modules/studio.ts @@ -41,6 +41,27 @@ export const deleteSeries = (id: string) => api.delete>(`${BAS export const fetchArticles = (params: ListParams) => api.get>(`${BASE}/articles`, params) +export interface ArticlePayload { + series_id?: string + article_number?: number + plan?: Record | null + outline?: Record | 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 | null + publish_schedule?: Record | null +} +export const createArticle = (payload: ArticlePayload) => api.post>(`${BASE}/articles`, payload) +export const updateArticle = (id: string, payload: ArticlePayload) => api.put>(`${BASE}/articles/${id}`, payload) +export const deleteArticle = (id: string) => api.delete>(`${BASE}/articles/${id}`) + export const fetchSubtopics = (params: ListParams) => api.get>(`${BASE}/subtopics`, params) diff --git a/src/api/types.gen.ts b/src/api/types.gen.ts index 1d3740f..970da9b 100644 --- a/src/api/types.gen.ts +++ b/src/api/types.gen.ts @@ -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 [] diff --git a/src/features/studio/article-edit-sheet.tsx b/src/features/studio/article-edit-sheet.tsx new file mode 100644 index 0000000..93b1a62 --- /dev/null +++ b/src/features/studio/article-edit-sheet.tsx @@ -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 +const lines = (value: string) => value.split('\n').map((item) => item.trim()).filter(Boolean) +const object = (value: string) => value.trim() ? JSON.parse(value) as Record : 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({ 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 {article ? '编辑文章计划' : '新建文章计划'}
+ {creating && <> } />} +