refactor(studio): validate editors with zod

This commit is contained in:
2026-09-10 14:10:03 +08:00
parent afe4a4b919
commit 1fbc6cab5b
2 changed files with 45 additions and 116 deletions
@@ -1,83 +1,31 @@
import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { Controller, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
import { createCategoryBrief, updateCategoryBrief, type CategoryBriefPayload } from '@/api/modules/studio'
import type { StudioCategoryBrief } from '@/api/types'
import {
createCategoryBrief,
updateCategoryBrief,
type CategoryBriefPayload,
} from '@/api/modules/studio'
import { handleApiError } from '@/lib/errors'
import { CategoryPicker } from '@/features/categories/category-picker'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Textarea } from '@/components/ui/textarea'
import { CategoryPicker } from '@/features/categories/category-picker'
import { handleApiError } from '@/lib/errors'
interface Props {
brief: StudioCategoryBrief | null
creating: boolean
onOpenChange: (open: boolean) => void
onSaved: () => void
}
const schema = z.object({ category_id: z.string().min(1, '请选择分类'), description: z.string(), target_audience: z.string(), content_angles: z.string(), risk_notes: z.string() })
type FormValues = z.infer<typeof schema>
interface Props { brief: StudioCategoryBrief | null; creating: boolean; onOpenChange: (open: boolean) => void; onSaved: () => void }
export function CategoryBriefEditSheet({ brief, creating, onOpenChange, onSaved }: Props) {
const open = creating || brief !== null
const [categoryId, setCategoryId] = useState<string | null>(null)
const [description, setDescription] = useState('')
const [targetAudience, setTargetAudience] = useState('')
const [contentAngles, setContentAngles] = useState('')
const [riskNotes, setRiskNotes] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
setCategoryId(brief?.category_id ?? null)
setDescription(brief?.description ?? '')
setTargetAudience(brief?.target_audience ?? '')
setContentAngles(brief?.content_angles?.join('\n') ?? '')
setRiskNotes(brief?.risk_notes ?? '')
}, [brief, creating])
const save = async () => {
if (!categoryId) {
toast.error('请选择分类')
return
}
const payload: CategoryBriefPayload = {
category_id: categoryId,
description: description.trim() || null,
target_audience: targetAudience.trim() || null,
content_angles: contentAngles.split('\n').map((value) => value.trim()).filter(Boolean),
risk_notes: riskNotes.trim() || null,
}
setSaving(true)
try {
if (brief) await updateCategoryBrief(brief.id, payload)
else await createCategoryBrief(payload)
toast.success(brief ? '分类简报已更新' : '分类简报已创建')
onSaved()
onOpenChange(false)
} catch (error) {
handleApiError(error)
} finally {
setSaving(false)
}
const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { category_id: '', description: '', target_audience: '', content_angles: '', risk_notes: '' } })
useEffect(() => { form.reset({ category_id: brief?.category_id ?? '', description: brief?.description ?? '', target_audience: brief?.target_audience ?? '', content_angles: brief?.content_angles?.join('\n') ?? '', risk_notes: brief?.risk_notes ?? '' }) }, [brief, creating, form])
const submit = async (values: FormValues) => {
const payload: CategoryBriefPayload = { category_id: values.category_id, description: values.description.trim() || null, target_audience: values.target_audience.trim() || null, content_angles: values.content_angles.split('\n').map((value) => value.trim()).filter(Boolean), risk_notes: values.risk_notes.trim() || null }
try { if (brief) await updateCategoryBrief(brief.id, payload); else await createCategoryBrief(payload); toast.success(brief ? '分类简报已更新' : '分类简报已创建'); onSaved(); onOpenChange(false) } catch (error) { handleApiError(error, form.setError) }
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full overflow-y-auto sm:max-w-xl">
<SheetHeader><SheetTitle>{brief ? '编辑分类简报' : '新建分类简报'}</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="brief-description"></Label><Textarea id="brief-description" value={description} onChange={(e) => setDescription(e.target.value)} /></div>
<div className="grid gap-2"><Label htmlFor="brief-audience"></Label><Textarea id="brief-audience" value={targetAudience} onChange={(e) => setTargetAudience(e.target.value)} /></div>
<div className="grid gap-2"><Label htmlFor="brief-angles"></Label><Textarea id="brief-angles" value={contentAngles} onChange={(e) => setContentAngles(e.target.value)} placeholder="每行一个角度" /></div>
<div className="grid gap-2"><Label htmlFor="brief-risks"></Label><Textarea id="brief-risks" value={riskNotes} onChange={(e) => setRiskNotes(e.target.value)} /></div>
<Button disabled={saving} onClick={save}>{saving ? '保存中…' : '保存'}</Button>
</div>
</SheetContent>
</Sheet>
)
const fieldError = (name: keyof FormValues) => form.formState.errors[name]?.message
return <Sheet open={creating || brief !== null} onOpenChange={onOpenChange}><SheetContent className="w-full overflow-y-auto sm:max-w-xl"><SheetHeader><SheetTitle>{brief ? '编辑分类简报' : '新建分类简报'}</SheetTitle></SheetHeader><form className="grid gap-5 px-4 pb-8" onSubmit={form.handleSubmit(submit)}>
<Field label="分类" error={fieldError('category_id')}><Controller control={form.control} name="category_id" render={({ field }) => <CategoryPicker value={field.value || null} onChange={(value) => field.onChange(value ?? '')} />} /></Field>
<Field label="定位描述" error={fieldError('description')}><Textarea {...form.register('description')} /></Field><Field label="目标受众" error={fieldError('target_audience')}><Textarea {...form.register('target_audience')} /></Field><Field label="内容切入角度" error={fieldError('content_angles')}><Textarea {...form.register('content_angles')} placeholder="每行一个角度" /></Field><Field label="风险提示" error={fieldError('risk_notes')}><Textarea {...form.register('risk_notes')} /></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> }
+24 -43
View File
@@ -1,56 +1,37 @@
import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { Controller, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import type { StudioSubtopic } from '@/api/types'
import { z } from 'zod'
import { createSubtopic, updateSubtopic, type SubtopicPayload } from '@/api/modules/studio'
import { handleApiError } from '@/lib/errors'
import { CategoryPicker } from '@/features/categories/category-picker'
import type { StudioSubtopic } 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 { CategoryPicker } from '@/features/categories/category-picker'
import { handleApiError } from '@/lib/errors'
const schema = z.object({
category_id: z.string().min(1, '请选择分类'), title: z.string().trim().min(1, '请填写标题').max(255, '不能超过 255 个字符'),
target_audience: z.string(), pain_points: z.string(), content_value: z.string(),
expandability_score: z.string().refine((value) => value === '' || (/^\d+$/.test(value) && Number(value) <= 10), '必须是 010 的整数'),
status: z.enum(['active', 'archived']),
})
type FormValues = z.infer<typeof schema>
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) }
const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { category_id: '', title: '', target_audience: '', pain_points: '', content_value: '', expandability_score: '', status: 'active' } })
useEffect(() => { form.reset({ category_id: subtopic?.category_id ?? '', title: subtopic?.title ?? '', target_audience: subtopic?.target_audience ?? '', pain_points: subtopic?.pain_points ?? '', content_value: subtopic?.content_value ?? '', expandability_score: subtopic?.expandability_score?.toString() ?? '', status: subtopic?.status ?? 'active' }) }, [subtopic, creating, form])
const submit = async (values: FormValues) => {
const payload: SubtopicPayload = { category_id: values.category_id, title: values.title.trim(), target_audience: values.target_audience.trim() || null, pain_points: values.pain_points.trim() || null, content_value: values.content_value.trim() || null, expandability_score: values.expandability_score === '' ? null : Number(values.expandability_score), status: values.status }
try { if (subtopic) await updateSubtopic(subtopic.id, payload); else await createSubtopic(payload); toast.success(subtopic ? '子话题已更新' : '子话题已创建'); onSaved(); onOpenChange(false) } catch (error) { handleApiError(error, form.setError) }
}
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">010</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>
const fieldError = (name: keyof FormValues) => form.formState.errors[name]?.message
return <Sheet open={creating || subtopic !== null} onOpenChange={onOpenChange}><SheetContent className="w-full overflow-y-auto sm:max-w-xl"><SheetHeader><SheetTitle>{subtopic ? '编辑子话题' : '新建子话题'}</SheetTitle></SheetHeader><form className="grid gap-5 px-4 pb-8" onSubmit={form.handleSubmit(submit)}>
<Field label="分类" error={fieldError('category_id')}><Controller control={form.control} name="category_id" render={({ field }) => <CategoryPicker value={field.value || null} onChange={(value) => field.onChange(value ?? '')} />} /></Field><Field label="标题" error={fieldError('title')}><Input maxLength={255} {...form.register('title')} /></Field><Field label="目标受众" error={fieldError('target_audience')}><Textarea {...form.register('target_audience')} /></Field><Field label="读者痛点" error={fieldError('pain_points')}><Textarea {...form.register('pain_points')} /></Field><Field label="内容价值" error={fieldError('content_value')}><Textarea {...form.register('content_value')} /></Field><Field label="可扩展性评分(010" error={fieldError('expandability_score')}><Input type="number" min={0} max={10} step={1} {...form.register('expandability_score')} /></Field><Field label="状态" error={fieldError('status')}><Controller control={form.control} name="status" render={({ field }) => <Select value={field.value} onValueChange={field.onChange}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="active"></SelectItem><SelectItem value="archived"></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> }