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:
@ -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
|
||||
|
||||
87
src/features/categories/category-picker.tsx
Normal file
87
src/features/categories/category-picker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
src/features/categories/use-categories.ts
Normal file
25
src/features/categories/use-categories.ts
Normal 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 }
|
||||
}
|
||||
@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user