From a809e8bb6b9b7e89defc1dacc05589b082a97eb1 Mon Sep 17 00:00:00 2001 From: kongkx Date: Wed, 8 Jul 2026 22:01:57 +0800 Subject: [PATCH] feat(studio/robot-authors): category filter + display_name & category columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/api/types.ts | 3 + src/features/categories/category-picker.tsx | 87 +++++++++++++++++++++ src/features/categories/use-categories.ts | 25 ++++++ src/routes/_authed/studio/robot-authors.tsx | 86 +++++++++++++++----- 4 files changed, 183 insertions(+), 18 deletions(-) create mode 100644 src/features/categories/category-picker.tsx create mode 100644 src/features/categories/use-categories.ts diff --git a/src/api/types.ts b/src/api/types.ts index 282caaf..be08dd5 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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 diff --git a/src/features/categories/category-picker.tsx b/src/features/categories/category-picker.tsx new file mode 100644 index 0000000..82607cf --- /dev/null +++ b/src/features/categories/category-picker.tsx @@ -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 ( + + + + + + + + + 无匹配分类 + + { + onChange(null) + setOpen(false) + }} + > + + 全部分类 + + {options.map((category) => ( + { + onChange(category.id) + setOpen(false) + }} + > + + + {category.name ?? category.slug} + + + ))} + + + + + + ) +} diff --git a/src/features/categories/use-categories.ts b/src/features/categories/use-categories.ts new file mode 100644 index 0000000..cb05650 --- /dev/null +++ b/src/features/categories/use-categories.ts @@ -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() + 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 } +} diff --git a/src/routes/_authed/studio/robot-authors.tsx b/src/routes/_authed/studio/robot-authors.tsx index 54f6780..f493267 100644 --- a/src/routes/_authed/studio/robot-authors.tsx +++ b/src/routes/_authed/studio/robot-authors.tsx @@ -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(null) const [detail, setDetail] = useState(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[] = [ { - header: '作者', + header: '账号', cell: ({ row }) => ( -
-
- {row.original.persona?.slice(0, 40) || '(人设待补全)'} +
+ + + + {(row.original.display_name ?? 'R').slice(0, 1).toUpperCase()} + + +
+
+ {row.original.display_name || '(未命名)'} +
+ {row.original.user_uid}
- {row.original.user_uid} +
+ ), + }, + { + header: '分类', + cell: ({ row }) => {nameOf(row.original.category_id)}, + }, + { + header: '人设', + cell: ({ row }) => ( +
+ {row.original.persona?.slice(0, 40) || '(待补全)'}
), }, @@ -94,10 +128,15 @@ function RobotAuthorsPage() { return (
- + +
+
+ + +
+
+ - } - /> +
+ {hasFilters && ( + + )} +
+ + !open && setDetail(null)} - title="机器人作者详情" + title={detail?.display_name || '机器人作者详情'} + description={detail ? nameOf(detail.category_id) : undefined} fields={[ { label: 'ID', value: {detail?.id} }, + { label: '账号昵称', value: detail?.display_name }, { label: '用户 UID', value: {detail?.user_uid} }, + { label: '分类', value: detail && nameOf(detail.category_id) }, { label: '状态', value: detail && }, { label: '定位', value: detail?.positioning }, { label: '目标读者', value: detail?.target_readers },