feat: Implement character avatar upload and refactor character form fields and API calls.

This commit is contained in:
liqupan
2026-02-02 22:48:11 +08:00
parent 6c32d845a7
commit dec5748cca
15 changed files with 369 additions and 197 deletions

View File

@@ -0,0 +1,29 @@
import { SVGProps } from 'react'
export function IconGoogle(props: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path
d='M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z'
fill='#4285F4'
/>
<path
d='M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z'
fill='#34A853'
/>
<path
d='M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z'
fill='#FBBC05'
/>
<path
d='M12 5.38c1.62 0 3.06.56 4.21 1.66l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z'
fill='#EA4335'
/>
</svg>
)
}

View File

@@ -14,3 +14,4 @@ export { IconTelegram } from './icon-telegram'
export { IconTrello } from './icon-trello'
export { IconWhatsapp } from './icon-whatsapp'
export { IconZoom } from './icon-zoom'
export { IconGoogle } from './icon-google'

View File

@@ -0,0 +1,33 @@
import { useEffect } from 'react';
import { supabase } from '@/lib/supabase';
import { useAuthStore } from '@/stores/auth-store';
export function AuthProvider({ children }: { children: React.ReactNode }) {
const { auth } = useAuthStore();
useEffect(() => {
// 监听 Supabase 身份状态变化
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
console.log('🔄 Supabase Auth Event:', event);
if (session?.user) {
// 同步 Supabase 用户到我们的 store
auth.setUser({
accountNo: session.user.id,
email: session.user.email || '',
role: ['admin'], // 暂定为 admin
exp: session.expires_at ? session.expires_at * 1000 : Date.now() + 86400000,
});
auth.setAccessToken(session.access_token);
} else if (event === 'SIGNED_OUT') {
auth.reset();
}
});
return () => {
subscription.unsubscribe();
};
}, [auth]);
return <>{children}</>;
}

View File

@@ -5,10 +5,11 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { Link, useNavigate } from '@tanstack/react-router'
import { Loader2, LogIn } from 'lucide-react'
import { toast } from 'sonner'
import { IconFacebook, IconGithub } from '@/assets/brand-icons'
import { IconFacebook, IconGithub, IconGoogle } from '@/assets/brand-icons'
import { useAuthStore } from '@/stores/auth-store'
import { sleep, cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { supabase } from '@/lib/supabase'
import {
Form,
FormControl,
@@ -51,34 +52,53 @@ export function UserAuthForm({
},
})
function onSubmit(data: z.infer<typeof formSchema>) {
async function onSubmit(data: z.infer<typeof formSchema>) {
setIsLoading(true)
toast.promise(sleep(2000), {
loading: 'Signing in...',
success: () => {
setIsLoading(false)
try {
const { data: authData, error } = await supabase.auth.signInWithPassword({
email: data.email,
password: data.password,
})
// Mock successful authentication with expiry computed at success time
const mockUser = {
accountNo: 'ACC001',
email: data.email,
role: ['user'],
exp: Date.now() + 24 * 60 * 60 * 1000, // 24 hours from now
if (error) throw error
if (authData.session && authData.user) {
setIsLoading(false)
const user = {
accountNo: authData.user.id,
email: authData.user.email || '',
role: ['admin'],
exp: authData.session.expires_at ? authData.session.expires_at * 1000 : Date.now() + 24 * 60 * 60 * 1000,
}
// Set user and access token
auth.setUser(mockUser)
auth.setAccessToken('mock-access-token')
auth.setUser(user)
auth.setAccessToken(authData.session.access_token)
// Redirect to the stored location or default to dashboard
const targetPath = redirectTo || '/'
navigate({ to: targetPath, replace: true })
toast.success(`Welcome back, ${user.email}!`)
}
} catch (error: any) {
setIsLoading(false)
toast.error(error.message || 'Login failed')
}
}
return `Welcome back, ${data.email}!`
},
error: 'Error',
})
const handleGoogleLogin = async () => {
setIsLoading(true)
try {
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/`,
},
})
if (error) throw error
} catch (error: any) {
toast.error(error.message || 'Google login failed')
setIsLoading(false)
}
}
return (
@@ -136,14 +156,22 @@ export function UserAuthForm({
</div>
</div>
<div className='grid grid-cols-2 gap-2'>
{/* <div className='grid grid-cols-3 gap-2'>
<Button
variant='outline'
type='button'
disabled={isLoading}
onClick={handleGoogleLogin}
>
<IconGoogle className='h-4 w-4' /> Google
</Button>
<Button variant='outline' type='button' disabled={isLoading}>
<IconGithub className='h-4 w-4' /> GitHub
</Button>
<Button variant='outline' type='button' disabled={isLoading}>
<IconFacebook className='h-4 w-4' /> Facebook
</Button>
</div>
</div> */}
</form>
</Form>
)

View File

@@ -19,13 +19,13 @@ import {
} 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';
import { ImageUpload } from './image-upload';
export function CharacterDialog() {
const { open, setOpen, character } = useCharacterDialog();
@@ -36,12 +36,10 @@ export function CharacterDialog() {
resolver: zodResolver(characterSchema),
defaultValues: {
name: '',
status: 'online',
compatibility: 0,
is_active: true,
is_locked: false,
sort_order: 0,
tagline: '',
avatar_path: '',
description: '',
ai_system_prompt: '',
ai_greeting: '',
@@ -56,12 +54,10 @@ export function CharacterDialog() {
} else {
form.reset({
name: '',
status: 'online',
compatibility: 0,
is_active: true,
is_locked: false,
sort_order: 0,
tagline: '',
avatar_path: '',
description: '',
ai_system_prompt: '',
ai_greeting: '',
@@ -74,7 +70,7 @@ export function CharacterDialog() {
console.log('📝 表单值:', values);
console.log('✅ 表单验证状态:', form.formState.isValid);
console.log('❌ 表单错误:', form.formState.errors);
try {
// 确保 JSONB 字段有默认值
const sanitizedValues = {
@@ -82,28 +78,28 @@ export function CharacterDialog() {
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 });
await updateCharacter({ ...sanitizedValues, id: character.id } as any);
toast.success('角色更新成功');
} else {
console.log(' 创建新角色');
await createCharacter(sanitizedValues);
await createCharacter(sanitizedValues as any);
toast.success('角色创建成功');
}
setOpen(false);
queryClient.invalidateQueries({ queryKey: ['characters'] });
} catch (error) {
console.error('❌ 保存角色失败,详细错误:', error);
// 改进错误信息显示
const errorMessage = error instanceof Error
? error.message
const errorMessage = error instanceof Error
? error.message
: JSON.stringify(error);
toast.error(`保存角色失败: ${errorMessage}`);
}
};
@@ -120,7 +116,7 @@ export function CharacterDialog() {
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
<form
onSubmit={(e) => {
console.log('📋 Form onSubmit 事件触发', e);
console.log('📊 当前表单状态:', form.formState);
@@ -133,10 +129,10 @@ export function CharacterDialog() {
toast.error('请检查表单输入');
}
)(e);
}}
}}
className='space-y-4'
>
<div className='grid grid-cols-2 gap-4'>
<div className='grid grid-cols-1 gap-4'>
<FormField
control={form.control}
name='name'
@@ -150,38 +146,19 @@ export function CharacterDialog() {
</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'
name='avatar_path'
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder='简短描述' {...field} value={field.value || ''} />
<ImageUpload
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -203,25 +180,6 @@ export function CharacterDialog() {
/>
<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'
@@ -315,7 +273,7 @@ export function CharacterDialog() {
<Button type='button' variant='outline' onClick={() => setOpen(false)}>
</Button>
<Button
<Button
type='submit'
disabled={form.formState.isSubmitting}
onClick={() => console.log('💾 保存按钮被点击')}

View File

@@ -3,6 +3,7 @@ import { Character } from '../data/schema';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { DataTableColumnHeader } from '@/components/data-table';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { DataTableRowActions } from './data-table-row-actions';
export const columns: ColumnDef<Character>[] = [
@@ -30,6 +31,23 @@ export const columns: ColumnDef<Character>[] = [
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'avatar_path',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='头像' />
),
cell: ({ row }) => {
const avatarPath = row.getValue('avatar_path') as string;
return (
<Avatar className='h-10 w-10'>
<AvatarImage src={avatarPath} alt={row.getValue('name')} />
<AvatarFallback>{(row.getValue('name') as string)?.substring(0, 2)}</AvatarFallback>
</Avatar>
);
},
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'name',
header: ({ column }) => (
@@ -39,30 +57,7 @@ export const columns: ColumnDef<Character>[] = [
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 }) => (
@@ -77,19 +72,7 @@ export const columns: ColumnDef<Character>[] = [
);
},
},
{
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,134 @@
import { useState, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { supabase } from '@/lib/supabase';
import { toast } from 'sonner';
import { IconPhotoPlus, IconX, IconLoader2 } from '@tabler/icons-react';
interface ImageUploadProps {
value?: string;
onChange: (url: string) => void;
bucket?: string;
}
export function ImageUpload({
value,
onChange,
bucket = 'avatars'
}: ImageUploadProps) {
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleUpload = async (event: any) => {
try {
setUploading(true);
const file = event.target.files?.[0];
if (!file) return;
// 1. 检查文件类型
if (!file.type.startsWith('image/')) {
toast.error('请选择图片文件');
return;
}
// 2. 检查文件大小 (限制 2MB)
if (file.size > 2 * 1024 * 1024) {
toast.error('图片大小不能超过 2MB');
return;
}
const fileExt = file.name.split('.').pop();
const fileName = `${Math.random().toString(36).substring(2)}.${fileExt}`;
const filePath = `${fileName}`;
// 3. 上传到 Supabase Storage
const { error: uploadError } = await supabase.storage
.from(bucket)
.upload(filePath, file);
if (uploadError) {
throw uploadError;
}
// 4. 获取公共 URL
const { data: { publicUrl } } = supabase.storage
.from(bucket)
.getPublicUrl(filePath);
onChange(publicUrl);
toast.success('上传成功');
} catch (error: any) {
toast.error(`上传失败: ${error.message}`);
} finally {
setUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const removeImage = () => {
onChange('');
};
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
{value ? (
<div className="relative group h-24 w-24 rounded-lg overflow-hidden border">
<img
src={value}
alt="Avatar"
className="h-full w-full object-cover"
/>
<button
type="button"
onClick={removeImage}
className="absolute top-1 right-1 p-1 bg-red-500 rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity"
>
<IconX size={14} />
</button>
</div>
) : (
<div
onClick={() => fileInputRef.current?.click()}
className="h-24 w-24 rounded-lg border-2 border-dashed border-muted-foreground/25 flex flex-col items-center justify-center cursor-pointer hover:border-primary/50 transition-colors bg-muted/30"
>
{uploading ? (
<IconLoader2 className="h-8 w-8 animate-spin text-muted-foreground" />
) : (
<>
<IconPhotoPlus className="h-8 w-8 text-muted-foreground" />
<span className="text-[10px] text-muted-foreground mt-1"></span>
</>
)}
</div>
)}
<div className="flex-1 space-y-1">
<p className="text-xs text-muted-foreground">
推荐尺寸: 500x500px <br />
最大限制: 2MB
</p>
<input
type="file"
ref={fileInputRef}
onChange={handleUpload}
accept="image/*"
className="hidden"
/>
{!value && (
<Button
type="button"
variant="outline"
size="sm"
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
>
{uploading ? '上传中...' : '上传头像'}
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -18,9 +18,7 @@ export async function getCharacters() {
export async function createCharacter(character: Omit<Character, 'id' | 'created_at' | 'updated_at'>) {
const { data, error } = await supabase
.from('characters')
.insert(character)
.select()
.single();
.insert(character);
if (error) {
throw error;
@@ -36,9 +34,7 @@ export async function updateCharacter(character: Character) {
const { data, error } = await supabase
.from('characters')
.update(updates)
.eq('id', id)
.select()
.single();
.eq('id', id);
if (error) {
throw error;

View File

@@ -1,27 +1,21 @@
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(),
id: z.string().optional(),
name: z.string().optional().or(z.literal('')),
avatar_path: z.string().optional().or(z.literal('')),
description: z.string().optional().or(z.literal('')),
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(),
ai_system_prompt: z.string().optional().or(z.literal('')),
ai_greeting: z.string().optional().or(z.literal('')),
// 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({}),
ai_personality: z.record(z.string(), z.any()).optional().default({}),
ai_voice_config: z.record(z.string(), z.any()).optional().default({}),
created_at: z.string().optional(),
updated_at: z.string().optional(),

View File

@@ -13,6 +13,7 @@ import { handleServerError } from '@/lib/handle-server-error'
import { DirectionProvider } from './context/direction-provider'
import { FontProvider } from './context/font-provider'
import { ThemeProvider } from './context/theme-provider'
import { AuthProvider } from './context/auth-provider'
// Generated Routes
import { routeTree } from './routeTree.gen'
// Styles
@@ -97,7 +98,9 @@ if (!rootElement.innerHTML) {
<ThemeProvider>
<FontProvider>
<DirectionProvider>
<RouterProvider router={router} />
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</DirectionProvider>
</FontProvider>
</ThemeProvider>