feat: add authenticated settings page.
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { useState, createContext, useContext, ReactNode } from 'react';
|
||||
import { Character } from '../data/schema';
|
||||
|
||||
interface CharacterDialogContextType {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
character: Character | null;
|
||||
setCharacter: (character: Character | null) => void;
|
||||
}
|
||||
|
||||
const CharacterDialogContext = createContext<CharacterDialogContextType | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
export function CharacterDialogProvider({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [character, setCharacter] = useState<Character | null>(null);
|
||||
|
||||
return (
|
||||
<CharacterDialogContext.Provider value={{ open, setOpen, character, setCharacter }}>
|
||||
{children}
|
||||
</CharacterDialogContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCharacterDialog() {
|
||||
const context = useContext(CharacterDialogContext);
|
||||
if (!context) {
|
||||
throw new Error('useCharacterDialog must be used within a CharacterDialogProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useCharacterDialog } from './character-dialog-context';
|
||||
import { Character, characterSchema } from '../data/schema';
|
||||
import { createCharacter, updateCharacter } from '../data/api';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
export function CharacterDialog() {
|
||||
const { open, setOpen, character } = useCharacterDialog();
|
||||
const queryClient = useQueryClient();
|
||||
const isEdit = !!character;
|
||||
|
||||
const form = useForm<any>({
|
||||
resolver: zodResolver(characterSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
status: 'online',
|
||||
compatibility: 0,
|
||||
is_active: true,
|
||||
is_locked: false,
|
||||
sort_order: 0,
|
||||
tagline: '',
|
||||
description: '',
|
||||
ai_system_prompt: '',
|
||||
ai_greeting: '',
|
||||
ai_personality: {},
|
||||
ai_voice_config: {},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (character) {
|
||||
form.reset(character);
|
||||
} else {
|
||||
form.reset({
|
||||
name: '',
|
||||
status: 'online',
|
||||
compatibility: 0,
|
||||
is_active: true,
|
||||
is_locked: false,
|
||||
sort_order: 0,
|
||||
tagline: '',
|
||||
description: '',
|
||||
ai_system_prompt: '',
|
||||
ai_greeting: '',
|
||||
});
|
||||
}
|
||||
}, [character, form, open]);
|
||||
|
||||
const onSubmit = async (values: Character) => {
|
||||
console.log('🚀 表单提交触发');
|
||||
console.log('📝 表单值:', values);
|
||||
console.log('✅ 表单验证状态:', form.formState.isValid);
|
||||
console.log('❌ 表单错误:', form.formState.errors);
|
||||
|
||||
try {
|
||||
// 确保 JSONB 字段有默认值
|
||||
const sanitizedValues = {
|
||||
...values,
|
||||
ai_personality: values.ai_personality || {},
|
||||
ai_voice_config: values.ai_voice_config || {},
|
||||
};
|
||||
|
||||
console.log('🔧 处理后的数据:', sanitizedValues);
|
||||
|
||||
if (isEdit) {
|
||||
console.log('🔄 更新角色,ID:', character.id);
|
||||
await updateCharacter({ ...sanitizedValues, id: character.id });
|
||||
toast.success('角色更新成功');
|
||||
} else {
|
||||
console.log('➕ 创建新角色');
|
||||
await createCharacter(sanitizedValues);
|
||||
toast.success('角色创建成功');
|
||||
}
|
||||
setOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['characters'] });
|
||||
} catch (error) {
|
||||
console.error('❌ 保存角色失败,详细错误:', error);
|
||||
|
||||
// 改进错误信息显示
|
||||
const errorMessage = error instanceof Error
|
||||
? error.message
|
||||
: JSON.stringify(error);
|
||||
|
||||
toast.error(`保存角色失败: ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className='max-h-[85vh] overflow-y-auto sm:max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? '编辑角色' : '创建角色'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? '在下方修改角色详情。'
|
||||
: '填写详情以创建新角色。'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
console.log('📋 Form onSubmit 事件触发', e);
|
||||
console.log('📊 当前表单状态:', form.formState);
|
||||
console.log('❌ 表单错误:', form.formState.errors);
|
||||
console.log('📝 当前表单值:', form.getValues());
|
||||
form.handleSubmit(
|
||||
onSubmit,
|
||||
(errors) => {
|
||||
console.error('🚫 表单验证失败:', errors);
|
||||
toast.error('请检查表单输入');
|
||||
}
|
||||
)(e);
|
||||
}}
|
||||
className='space-y-4'
|
||||
>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>名称</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='角色名称' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='status'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>状态</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='选择状态' />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value='online'>在线</SelectItem>
|
||||
<SelectItem value='busy'>忙碌</SelectItem>
|
||||
<SelectItem value='offline'>离线</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='tagline'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>简述</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='简短描述' {...field} value={field.value || ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>描述</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder='完整角色描述' {...field} value={field.value || ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='compatibility'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>契合度 (%)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
max={100}
|
||||
{...field}
|
||||
onChange={e => field.onChange(parseInt(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='sort_order'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>排序</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
{...field}
|
||||
onChange={e => field.onChange(parseInt(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='is_active'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4'>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='space-y-1 leading-none'>
|
||||
<FormLabel>启用</FormLabel>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='is_locked'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4'>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='space-y-1 leading-none'>
|
||||
<FormLabel>锁定 (会员)</FormLabel>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ai_system_prompt'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>系统提示词 (System Prompt)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder='你是一个乐于助人的助手...'
|
||||
className='min-h-[100px]'
|
||||
{...field}
|
||||
value={field.value || ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='ai_greeting'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>问候语</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='你好!' {...field} value={field.value || ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button type='button' variant='outline' onClick={() => setOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={form.formState.isSubmitting}
|
||||
onClick={() => console.log('💾 保存按钮被点击')}
|
||||
>
|
||||
{form.formState.isSubmitting ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCharacterDialog } from './character-dialog-context';
|
||||
|
||||
export function CharactersPrimaryButtons() {
|
||||
const { setOpen, setCharacter } = useCharacterDialog();
|
||||
|
||||
const handleCreate = () => {
|
||||
setCharacter(null); // Clear character for creation
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex gap-2'>
|
||||
<Button onClick={handleCreate}>创建角色</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getRouteApi } from '@tanstack/react-router';
|
||||
import {
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { DataTablePagination, DataTableToolbar } from '@/components/data-table';
|
||||
import { statuses } from '../data/data';
|
||||
import { type Character } from '../data/schema';
|
||||
import { columns } from './columns';
|
||||
|
||||
const route = getRouteApi('/_authenticated/characters');
|
||||
|
||||
type DataTableProps = {
|
||||
data: Character[];
|
||||
};
|
||||
|
||||
export function CharactersTable({ data }: DataTableProps) {
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
|
||||
const {
|
||||
globalFilter,
|
||||
onGlobalFilterChange,
|
||||
columnFilters,
|
||||
onColumnFiltersChange,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
ensurePageInRange,
|
||||
} = useTableUrlState({
|
||||
search: route.useSearch(),
|
||||
navigate: route.useNavigate(),
|
||||
pagination: { defaultPage: 1, defaultPageSize: 10 },
|
||||
globalFilter: { enabled: true, key: 'filter' },
|
||||
columnFilters: [
|
||||
{ columnId: 'status', searchKey: 'status', type: 'array' },
|
||||
],
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
const name = String(row.getValue('name')).toLowerCase();
|
||||
const searchValue = String(filterValue).toLowerCase();
|
||||
return name.includes(searchValue);
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
onPaginationChange,
|
||||
onGlobalFilterChange,
|
||||
onColumnFiltersChange,
|
||||
});
|
||||
|
||||
const pageCount = table.getPageCount();
|
||||
useEffect(() => {
|
||||
ensurePageInRange(pageCount);
|
||||
}, [pageCount, ensurePageInRange]);
|
||||
|
||||
return (
|
||||
<div className='flex flex-1 flex-col gap-4'>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchPlaceholder='Filter by name...'
|
||||
filters={[
|
||||
{
|
||||
columnId: 'status',
|
||||
title: 'Status',
|
||||
options: statuses,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className='overflow-hidden rounded-md border'>
|
||||
<Table className='min-w-xl'>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
className={cn(
|
||||
header.column.columnDef.meta?.className,
|
||||
header.column.columnDef.meta?.thClassName
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
cell.column.columnDef.meta?.className,
|
||||
cell.column.columnDef.meta?.tdClassName
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className='h-24 text-center'
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DataTablePagination table={table} className='mt-auto' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
shadcn-admin/src/features/characters/components/columns.tsx
Normal file
97
shadcn-admin/src/features/characters/components/columns.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Character } from '../data/schema';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { DataTableColumnHeader } from '@/components/data-table';
|
||||
import { DataTableRowActions } from './data-table-row-actions';
|
||||
|
||||
export const columns: ColumnDef<Character>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && 'indeterminate')
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label='全选'
|
||||
className='translate-y-[2px]'
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label='选择行'
|
||||
className='translate-y-[2px]'
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='名称' />
|
||||
),
|
||||
cell: ({ row }) => <div className='w-[150px] font-medium'>{row.getValue('name')}</div>,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='状态' />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status') as string;
|
||||
const statusMap: Record<string, string> = {
|
||||
online: '在线',
|
||||
busy: '忙碌',
|
||||
offline: '离线',
|
||||
};
|
||||
return (
|
||||
<div className='flex w-[100px] items-center'>
|
||||
<Badge variant={status === 'online' ? 'default' : 'secondary'}>
|
||||
{statusMap[status] || status}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
return value.includes(row.getValue(id));
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'is_active',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='是否启用' />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const isActive = row.getValue('is_active') as boolean;
|
||||
return (
|
||||
<Badge variant={isActive ? 'outline' : 'destructive'}>
|
||||
{isActive ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'compatibility',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='契合度' />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className='flex items-center'>
|
||||
<span>{row.getValue('compatibility')}%</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => <DataTableRowActions row={row} />,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,68 @@
|
||||
import { DotsHorizontalIcon } from '@radix-ui/react-icons';
|
||||
import { Row } from '@tanstack/react-table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Character } from '../data/schema';
|
||||
import { useCharacterDialog } from './character-dialog-context';
|
||||
import { deleteCharacter } from '../data/api';
|
||||
import { toast } from 'sonner';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
interface DataTableRowActionsProps<TData> {
|
||||
row: Row<TData>;
|
||||
}
|
||||
|
||||
export function DataTableRowActions<TData>({
|
||||
row,
|
||||
}: DataTableRowActionsProps<TData>) {
|
||||
const character = row.original as Character;
|
||||
const { setOpen, setCharacter } = useCharacterDialog();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleEdit = () => {
|
||||
setCharacter(character);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (confirm('确认删除该角色吗?')) {
|
||||
try {
|
||||
if (character.id) {
|
||||
await deleteCharacter(character.id);
|
||||
toast.success('删除成功');
|
||||
queryClient.invalidateQueries({ queryKey: ['characters'] });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('删除失败');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
|
||||
>
|
||||
<DotsHorizontalIcon className='h-4 w-4' />
|
||||
<span className='sr-only'>打开菜单</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[160px]'>
|
||||
<DropdownMenuItem onClick={handleEdit}>编辑</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleDelete}>
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
59
shadcn-admin/src/features/characters/data/api.ts
Normal file
59
shadcn-admin/src/features/characters/data/api.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { Character } from './schema';
|
||||
|
||||
export async function getCharacters() {
|
||||
const { data, error } = await supabase
|
||||
.from('characters')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Optimize: validate schema? For now trust Supabase or partial validate
|
||||
return data as Character[];
|
||||
}
|
||||
|
||||
export async function createCharacter(character: Omit<Character, 'id' | 'created_at' | 'updated_at'>) {
|
||||
const { data, error } = await supabase
|
||||
.from('characters')
|
||||
.insert(character)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateCharacter(character: Character) {
|
||||
const { id, ...updates } = character;
|
||||
if (!id) throw new Error('ID is required for update');
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('characters')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteCharacter(id: string) {
|
||||
const { error } = await supabase
|
||||
.from('characters')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
19
shadcn-admin/src/features/characters/data/data.ts
Normal file
19
shadcn-admin/src/features/characters/data/data.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Signal, User, UserX } from 'lucide-react';
|
||||
|
||||
export const statuses = [
|
||||
{
|
||||
value: 'online',
|
||||
label: 'Online',
|
||||
icon: Signal,
|
||||
},
|
||||
{
|
||||
value: 'busy',
|
||||
label: 'Busy',
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
value: 'offline',
|
||||
label: 'Offline',
|
||||
icon: UserX,
|
||||
},
|
||||
];
|
||||
30
shadcn-admin/src/features/characters/data/schema.ts
Normal file
30
shadcn-admin/src/features/characters/data/schema.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// 角色状态枚举
|
||||
export const characterStatusSchema = z.enum(['online', 'busy', 'offline']);
|
||||
|
||||
// 角色 Schema
|
||||
export const characterSchema = z.object({
|
||||
id: z.string().uuid().optional(),
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
tagline: z.string().optional(),
|
||||
avatar_path: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
|
||||
compatibility: z.number().min(0).max(100).default(0),
|
||||
status: characterStatusSchema.default('online'),
|
||||
is_locked: z.boolean().default(false),
|
||||
is_active: z.boolean().default(true),
|
||||
sort_order: z.number().default(0),
|
||||
|
||||
ai_system_prompt: z.string().optional(),
|
||||
ai_greeting: z.string().optional(),
|
||||
// Use z.string() as key type for Record to satisfy TypeScript and Zod v4+ alignment
|
||||
ai_personality: z.record(z.string(), z.any()).default({}),
|
||||
ai_voice_config: z.record(z.string(), z.any()).default({}),
|
||||
|
||||
created_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Character = z.infer<typeof characterSchema>;
|
||||
54
shadcn-admin/src/features/characters/index.tsx
Normal file
54
shadcn-admin/src/features/characters/index.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Header } from '@/components/layout/header';
|
||||
import { Main } from '@/components/layout/main';
|
||||
import { ProfileDropdown } from '@/components/profile-dropdown';
|
||||
import { Search } from '@/components/search';
|
||||
import { ThemeSwitch } from '@/components/theme-switch';
|
||||
import { CharactersTable } from './components/characters-table';
|
||||
import { CharacterDialogProvider } from './components/character-dialog-context';
|
||||
import { CharactersPrimaryButtons } from './components/characters-primary-buttons';
|
||||
import { CharacterDialog } from './components/character-dialog';
|
||||
import { getCharacters } from './data/api';
|
||||
|
||||
export default function Characters() {
|
||||
const { data: characters, isLoading, error } = useQuery({
|
||||
queryKey: ['characters'],
|
||||
queryFn: getCharacters,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <div>Error loading characters: {(error as Error).message}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<CharacterDialogProvider>
|
||||
<Header fixed>
|
||||
<Search />
|
||||
<div className='ms-auto flex items-center space-x-4'>
|
||||
<ThemeSwitch />
|
||||
<ProfileDropdown />
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
<Main className='flex flex-1 flex-col gap-4 sm:gap-6'>
|
||||
<div className='flex flex-wrap items-end justify-between gap-2'>
|
||||
<div>
|
||||
<h2 className='text-2xl font-bold tracking-tight'>Characters</h2>
|
||||
<p className='text-muted-foreground'>
|
||||
Manage your AI characters and their configurations.
|
||||
</p>
|
||||
</div>
|
||||
<CharactersPrimaryButtons />
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div>Loading...</div>
|
||||
) : (
|
||||
<CharactersTable data={characters || []} />
|
||||
)}
|
||||
</Main>
|
||||
|
||||
<CharacterDialog />
|
||||
</CharacterDialogProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user