feat: add authenticated settings page.

This commit is contained in:
liqupan
2026-02-02 20:12:19 +08:00
parent cb3e16cd16
commit 6c32d845a7
259 changed files with 24685 additions and 0 deletions

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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} />,
},
];

View File

@@ -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>
);
}

View 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;
}
}

View 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,
},
];

View 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>;

View 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>
);
}