feat(studio/robot-authors): category filter + display_name & category columns

- searchable CategoryPicker (Popover+Command) reusable across studio pages,
  plus useCategoryOptions hook (shared tree query + id→name resolver)
- filter bar with category + status filters and a reset action
- new columns: account (avatar + display_name + uid), category name; persona
  demoted to a secondary column; display_name/category surfaced in detail sheet
This commit is contained in:
2026-07-08 22:01:57 +08:00
parent 27fb6bbcd0
commit a809e8bb6b
4 changed files with 183 additions and 18 deletions

View File

@ -145,6 +145,9 @@ export interface StudioRobotAuthor {
kind: 'studio_robot_author'
id: string
user_uid: string
/** Bound robot account's public name (index listing only). */
display_name?: string | null
avatar?: string | null
category_id: string
subtopic_id: string | null
persona: string | null

View File

@ -0,0 +1,87 @@
import { useState } from 'react'
import { Check, ChevronsUpDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useCategoryOptions } from './use-categories'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
interface CategoryPickerProps {
/** selected category id, or null for "all" */
value: string | null
onChange: (id: string | null) => void
placeholder?: string
className?: string
}
/** Searchable category combobox (name + slug matchable), null = all. */
export function CategoryPicker({
value,
onChange,
placeholder = '全部分类',
className,
}: CategoryPickerProps) {
const [open, setOpen] = useState(false)
const { options, nameOf } = useCategoryOptions()
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
role="combobox"
aria-expanded={open}
className={cn('w-56 justify-between font-normal', className)}
>
<span className="truncate">{value ? nameOf(value) : placeholder}</span>
<ChevronsUpDown className="size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-0" align="start">
<Command>
<CommandInput placeholder="搜索分类…" />
<CommandList>
<CommandEmpty></CommandEmpty>
<CommandGroup>
<CommandItem
value="__all__"
onSelect={() => {
onChange(null)
setOpen(false)
}}
>
<Check className={cn('size-4', value === null ? 'opacity-100' : 'opacity-0')} />
</CommandItem>
{options.map((category) => (
<CommandItem
key={category.id}
value={`${category.name ?? category.slug} ${category.slug}`}
onSelect={() => {
onChange(category.id)
setOpen(false)
}}
>
<Check
className={cn('size-4', value === category.id ? 'opacity-100' : 'opacity-0')}
/>
<span className="truncate" style={{ paddingLeft: category.depth * 12 }}>
{category.name ?? category.slug}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { fetchCategoryTree, flattenTree } from '@/api/modules/categories'
/**
* Flattened category tree for pickers, plus a resolver for showing category
* names in tables. The underlying tree query is shared (React Query dedupes),
* so calling this in both a picker and a table costs one request.
*/
export function useCategoryOptions() {
const query = useQuery({
queryKey: ['categories', 'tree'],
queryFn: () => fetchCategoryTree(true),
})
const options = useMemo(() => flattenTree(query.data?.data ?? []), [query.data])
const nameOf = useMemo(() => {
const map = new Map<string, string>()
for (const category of options) map.set(category.id, category.name ?? category.slug)
return (id?: string | null) => (id ? (map.get(id) ?? id) : '—')
}, [options])
return { options, nameOf, isLoading: query.isPending }
}

View File

@ -1,18 +1,23 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { X } from 'lucide-react'
import type { ColumnDef } from '@tanstack/react-table'
import { toast } from 'sonner'
import type { StudioRobotAuthor } from '@/api/types'
import type { ListParams, StudioRobotAuthor } from '@/api/types'
import { fetchRobotAuthors, updateRobotAuthor } from '@/api/modules/studio'
import { handleApiError } from '@/lib/errors'
import { useListPage } from '@/lib/list-page'
import { CategoryPicker } from '@/features/categories/category-picker'
import { useCategoryOptions } from '@/features/categories/use-categories'
import { DetailSheet } from '@/features/studio/detail-sheet'
import { DataTable } from '@/components/data-table/data-table'
import { PageHeader } from '@/components/page-header'
import { StatusPill } from '@/components/status-pill'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
@ -30,12 +35,21 @@ const STATUSES = ['pending', 'profiling', 'ready', 'paused'] as const
function RobotAuthorsPage() {
const queryClient = useQueryClient()
const [status, setStatus] = useState('all')
const [categoryId, setCategoryId] = useState<string | null>(null)
const [detail, setDetail] = useState<StudioRobotAuthor | null>(null)
const list = useListPage(
'studio-robot-authors',
fetchRobotAuthors,
status === 'all' ? {} : { status },
)
const { nameOf } = useCategoryOptions()
const filters: ListParams = {}
if (status !== 'all') filters.status = status
if (categoryId) filters.category_id = categoryId
const list = useListPage('studio-robot-authors', fetchRobotAuthors, filters)
const hasFilters = status !== 'all' || categoryId !== null
const resetFilters = () => {
setStatus('all')
setCategoryId(null)
}
const setAuthorStatus = async (row: StudioRobotAuthor, next: 'ready' | 'paused') => {
try {
@ -50,13 +64,33 @@ function RobotAuthorsPage() {
const columns: ColumnDef<StudioRobotAuthor>[] = [
{
header: '作者',
header: '账号',
cell: ({ row }) => (
<div>
<div className="max-w-72 truncate font-medium">
{row.original.persona?.slice(0, 40) || '(人设待补全)'}
<div className="flex items-center gap-2.5">
<Avatar className="size-8">
<AvatarImage src={row.original.avatar ?? undefined} alt="" />
<AvatarFallback>
{(row.original.display_name ?? 'R').slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="max-w-44 truncate font-medium">
{row.original.display_name || '(未命名)'}
</div>
<code className="font-mono text-xs text-muted-foreground">{row.original.user_uid}</code>
</div>
<code className="font-mono text-xs text-muted-foreground">{row.original.user_uid}</code>
</div>
),
},
{
header: '分类',
cell: ({ row }) => <span className="text-sm">{nameOf(row.original.category_id)}</span>,
},
{
header: '人设',
cell: ({ row }) => (
<div className="max-w-64 truncate text-sm text-muted-foreground">
{row.original.persona?.slice(0, 40) || '(待补全)'}
</div>
),
},
@ -94,10 +128,15 @@ function RobotAuthorsPage() {
return (
<div>
<PageHeader
title="机器人作者"
description="由 cron 播种、Agent 补全人设;可在此暂停/恢复创作"
actions={
<PageHeader title="机器人作者" description="由 cron 播种、Agent 补全人设;可在此暂停/恢复创作" />
<div className="mb-4 flex flex-wrap items-end gap-4 rounded-lg border bg-card p-3">
<div className="grid gap-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<CategoryPicker value={categoryId} onChange={setCategoryId} />
</div>
<div className="grid gap-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger size="sm" className="w-32">
<SelectValue />
@ -111,8 +150,15 @@ function RobotAuthorsPage() {
))}
</SelectContent>
</Select>
}
/>
</div>
{hasFilters && (
<Button variant="ghost" size="sm" onClick={resetFilters}>
<X className="size-4" />
</Button>
)}
</div>
<DataTable
columns={columns}
data={list.rows}
@ -121,13 +167,17 @@ function RobotAuthorsPage() {
onPageChange={list.setPage}
onPageSizeChange={list.setPageSize}
/>
<DetailSheet
open={detail !== null}
onOpenChange={(open) => !open && setDetail(null)}
title="机器人作者详情"
title={detail?.display_name || '机器人作者详情'}
description={detail ? nameOf(detail.category_id) : undefined}
fields={[
{ label: 'ID', value: <code className="font-mono text-xs">{detail?.id}</code> },
{ label: '账号昵称', value: detail?.display_name },
{ label: '用户 UID', value: <code className="font-mono text-xs">{detail?.user_uid}</code> },
{ label: '分类', value: detail && nameOf(detail.category_id) },
{ label: '状态', value: detail && <StatusPill status={detail.status} /> },
{ label: '定位', value: detail?.positioning },
{ label: '目标读者', value: detail?.target_readers },