feat: Implement character avatar upload and refactor character form fields and API calls.
This commit is contained in:
@@ -34,6 +34,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@supabase/supabase-js": "^2.93.3",
|
||||
"@tabler/icons-react": "^3.36.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-router": "^1.141.2",
|
||||
|
||||
18
shadcn-admin/pnpm-lock.yaml
generated
18
shadcn-admin/pnpm-lock.yaml
generated
@@ -71,6 +71,9 @@ importers:
|
||||
'@supabase/supabase-js':
|
||||
specifier: ^2.93.3
|
||||
version: 2.93.3
|
||||
'@tabler/icons-react':
|
||||
specifier: ^3.36.1
|
||||
version: 3.36.1(react@19.2.3)
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.18
|
||||
version: 4.1.18(vite@7.3.0(@types/node@25.0.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.3))
|
||||
@@ -1669,6 +1672,14 @@ packages:
|
||||
'@swc/types@0.1.25':
|
||||
resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==}
|
||||
|
||||
'@tabler/icons-react@3.36.1':
|
||||
resolution: {integrity: sha512-/8nOXeNeMoze9xY/QyEKG65wuvRhkT3q9aytaur6Gj8bYU2A98YVJyLc9MRmc5nVvpy+bRlrrwK/Ykr8WGyUWg==}
|
||||
peerDependencies:
|
||||
react: '>= 16'
|
||||
|
||||
'@tabler/icons@3.36.1':
|
||||
resolution: {integrity: sha512-f4Jg3Fof/Vru5ioix/UO4GX+sdDsF9wQo47FbtvG+utIYYVQ/QVAC0QYgcBbAjQGfbdOh2CCf0BgiFOF9Ixtjw==}
|
||||
|
||||
'@tailwindcss/node@4.1.18':
|
||||
resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==}
|
||||
|
||||
@@ -4586,6 +4597,13 @@ snapshots:
|
||||
dependencies:
|
||||
'@swc/counter': 0.1.3
|
||||
|
||||
'@tabler/icons-react@3.36.1(react@19.2.3)':
|
||||
dependencies:
|
||||
'@tabler/icons': 3.36.1
|
||||
react: 19.2.3
|
||||
|
||||
'@tabler/icons@3.36.1': {}
|
||||
|
||||
'@tailwindcss/node@4.1.18':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
|
||||
29
shadcn-admin/src/assets/brand-icons/icon-google.tsx
Normal file
29
shadcn-admin/src/assets/brand-icons/icon-google.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
33
shadcn-admin/src/context/auth-provider.tsx
Normal file
33
shadcn-admin/src/context/auth-provider.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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('💾 保存按钮被点击')}
|
||||
|
||||
@@ -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} />,
|
||||
|
||||
134
shadcn-admin/src/features/characters/components/image-upload.tsx
Normal file
134
shadcn-admin/src/features/characters/components/image-upload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user