refactor(studio): validate editors with zod
This commit is contained in:
@@ -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> }
|
||||
|
||||
@@ -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), '必须是 0–10 的整数'),
|
||||
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">可扩展性评分(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>
|
||||
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="可扩展性评分(0–10)" 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> }
|
||||
|
||||
Reference in New Issue
Block a user