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'
|
kind: 'studio_robot_author'
|
||||||
id: string
|
id: string
|
||||||
user_uid: string
|
user_uid: string
|
||||||
|
/** Bound robot account's public name (index listing only). */
|
||||||
|
display_name?: string | null
|
||||||
|
avatar?: string | null
|
||||||
category_id: string
|
category_id: string
|
||||||
subtopic_id: string | null
|
subtopic_id: string | null
|
||||||
persona: 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 { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import { toast } from 'sonner'
|
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 { fetchRobotAuthors, updateRobotAuthor } from '@/api/modules/studio'
|
||||||
import { handleApiError } from '@/lib/errors'
|
import { handleApiError } from '@/lib/errors'
|
||||||
import { useListPage } from '@/lib/list-page'
|
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 { DetailSheet } from '@/features/studio/detail-sheet'
|
||||||
import { DataTable } from '@/components/data-table/data-table'
|
import { DataTable } from '@/components/data-table/data-table'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { StatusPill } from '@/components/status-pill'
|
import { StatusPill } from '@/components/status-pill'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -30,12 +35,21 @@ const STATUSES = ['pending', 'profiling', 'ready', 'paused'] as const
|
|||||||
function RobotAuthorsPage() {
|
function RobotAuthorsPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [status, setStatus] = useState('all')
|
const [status, setStatus] = useState('all')
|
||||||
|
const [categoryId, setCategoryId] = useState<string | null>(null)
|
||||||
const [detail, setDetail] = useState<StudioRobotAuthor | null>(null)
|
const [detail, setDetail] = useState<StudioRobotAuthor | null>(null)
|
||||||
const list = useListPage(
|
const { nameOf } = useCategoryOptions()
|
||||||
'studio-robot-authors',
|
|
||||||
fetchRobotAuthors,
|
const filters: ListParams = {}
|
||||||
status === 'all' ? {} : { status },
|
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') => {
|
const setAuthorStatus = async (row: StudioRobotAuthor, next: 'ready' | 'paused') => {
|
||||||
try {
|
try {
|
||||||
@ -50,13 +64,33 @@ function RobotAuthorsPage() {
|
|||||||
|
|
||||||
const columns: ColumnDef<StudioRobotAuthor>[] = [
|
const columns: ColumnDef<StudioRobotAuthor>[] = [
|
||||||
{
|
{
|
||||||
header: '作者',
|
header: '账号',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div>
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="max-w-72 truncate font-medium">
|
<Avatar className="size-8">
|
||||||
{row.original.persona?.slice(0, 40) || '(人设待补全)'}
|
<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>
|
</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>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -94,10 +128,15 @@ function RobotAuthorsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<PageHeader title="机器人作者" description="由 cron 播种、Agent 补全人设;可在此暂停/恢复创作" />
|
||||||
title="机器人作者"
|
|
||||||
description="由 cron 播种、Agent 补全人设;可在此暂停/恢复创作"
|
<div className="mb-4 flex flex-wrap items-end gap-4 rounded-lg border bg-card p-3">
|
||||||
actions={
|
<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}>
|
<Select value={status} onValueChange={setStatus}>
|
||||||
<SelectTrigger size="sm" className="w-32">
|
<SelectTrigger size="sm" className="w-32">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@ -111,8 +150,15 @@ function RobotAuthorsPage() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
}
|
</div>
|
||||||
/>
|
{hasFilters && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={resetFilters}>
|
||||||
|
<X className="size-4" />
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={list.rows}
|
data={list.rows}
|
||||||
@ -121,13 +167,17 @@ function RobotAuthorsPage() {
|
|||||||
onPageChange={list.setPage}
|
onPageChange={list.setPage}
|
||||||
onPageSizeChange={list.setPageSize}
|
onPageSizeChange={list.setPageSize}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DetailSheet
|
<DetailSheet
|
||||||
open={detail !== null}
|
open={detail !== null}
|
||||||
onOpenChange={(open) => !open && setDetail(null)}
|
onOpenChange={(open) => !open && setDetail(null)}
|
||||||
title="机器人作者详情"
|
title={detail?.display_name || '机器人作者详情'}
|
||||||
|
description={detail ? nameOf(detail.category_id) : undefined}
|
||||||
fields={[
|
fields={[
|
||||||
{ label: 'ID', value: <code className="font-mono text-xs">{detail?.id}</code> },
|
{ 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: '用户 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 && <StatusPill status={detail.status} /> },
|
||||||
{ label: '定位', value: detail?.positioning },
|
{ label: '定位', value: detail?.positioning },
|
||||||
{ label: '目标读者', value: detail?.target_readers },
|
{ label: '目标读者', value: detail?.target_readers },
|
||||||
|
|||||||
Reference in New Issue
Block a user