feat(studio): manage category briefs

This commit is contained in:
2026-09-10 13:39:54 +08:00
parent e72490c498
commit 0599d004cf
3 changed files with 161 additions and 6 deletions
+17
View File
@@ -30,6 +30,23 @@ export const fetchSubtopics = (params: ListParams) =>
export const fetchCategoryBriefs = (params: ListParams) =>
api.get<PaginatedResponse<StudioCategoryBrief>>(`${BASE}/category-briefs`, params)
export interface CategoryBriefPayload {
category_id?: string
description?: string | null
target_audience?: string | null
content_angles?: string[] | null
risk_notes?: string | null
}
export const createCategoryBrief = (payload: CategoryBriefPayload) =>
api.post<ApiResponse<StudioCategoryBrief>>(`${BASE}/category-briefs`, payload)
export const updateCategoryBrief = (id: string, payload: CategoryBriefPayload) =>
api.put<ApiResponse<StudioCategoryBrief>>(`${BASE}/category-briefs/${id}`, payload)
export const deleteCategoryBrief = (id: string) =>
api.delete<ApiResponse<null>>(`${BASE}/category-briefs/${id}`)
export const fetchStylePresets = (params: ListParams) =>
api.get<PaginatedResponse<StudioStylePreset>>(`${BASE}/style-presets`, params)
@@ -0,0 +1,83 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
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'
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)
}
}
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>
)
}
+61 -6
View File
@@ -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 { StudioCategoryBrief } from '@/api/types'
import { fetchCategoryBriefs } from '@/api/modules/studio'
import { deleteCategoryBrief, fetchCategoryBriefs } from '@/api/modules/studio'
import { handleApiError } from '@/lib/errors'
import { CategoryBriefEditSheet } from '@/features/studio/category-brief-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'
@@ -16,10 +22,33 @@ export const Route = createFileRoute('/_authed/studio/category-briefs')({
})
function CategoryBriefsPage() {
const canManage = useCan(PERM.STUDIO_MANAGE)
const queryClient = useQueryClient()
const [detail, setDetail] = useState<StudioCategoryBrief | null>(null)
const [editing, setEditing] = useState<StudioCategoryBrief | null>(null)
const [creating, setCreating] = useState(false)
const list = useListPage('studio-category-briefs', fetchCategoryBriefs)
const { nameOf } = useCategoryOptions()
const refresh = () => {
void queryClient.invalidateQueries({ queryKey: ['studio-category-briefs'] })
void list.query.refetch()
}
const remove = async (brief: StudioCategoryBrief) => {
try {
await deleteCategoryBrief(brief.id)
refresh()
} catch (error) {
handleApiError(error)
}
}
const columns: ColumnDef<StudioCategoryBrief>[] = [
{
header: '分类',
cell: ({ row }) => <span className="font-medium">{nameOf(row.original.category_id)}</span>,
},
{
header: '分类简报',
cell: ({ row }) => (
@@ -36,16 +65,31 @@ function CategoryBriefsPage() {
{
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="Agent 对各分类的内容定位分析(含聚合计数)" />
<PageHeader
title="分类简报"
description="Agent 对各分类的内容定位分析(含聚合计数)"
actions={canManage ? <Button onClick={() => setCreating(true)}></Button> : undefined}
/>
<DataTable
columns={columns}
data={list.rows}
@@ -61,7 +105,7 @@ function CategoryBriefsPage() {
fields={[
{
label: '分类',
value: <code className="font-mono text-xs">{detail?.category_id}</code>,
value: detail ? nameOf(detail.category_id) : undefined,
},
{ label: '定位描述', value: detail?.description },
{ label: '目标受众', value: detail?.target_audience },
@@ -75,6 +119,17 @@ function CategoryBriefsPage() {
]}
jsonBlocks={[{ label: '内容切入角度', value: detail?.content_angles }]}
/>
<CategoryBriefEditSheet
brief={editing}
creating={creating}
onOpenChange={(open) => {
if (!open) {
setEditing(null)
setCreating(false)
}
}}
onSaved={refresh}
/>
</div>
)
}