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-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@supabase/supabase-js": "^2.93.3",
|
"@supabase/supabase-js": "^2.93.3",
|
||||||
|
"@tabler/icons-react": "^3.36.1",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"@tanstack/react-router": "^1.141.2",
|
"@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':
|
'@supabase/supabase-js':
|
||||||
specifier: ^2.93.3
|
specifier: ^2.93.3
|
||||||
version: 2.93.3
|
version: 2.93.3
|
||||||
|
'@tabler/icons-react':
|
||||||
|
specifier: ^3.36.1
|
||||||
|
version: 3.36.1(react@19.2.3)
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.1.18
|
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))
|
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':
|
'@swc/types@0.1.25':
|
||||||
resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==}
|
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':
|
'@tailwindcss/node@4.1.18':
|
||||||
resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==}
|
resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==}
|
||||||
|
|
||||||
@@ -4586,6 +4597,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@swc/counter': 0.1.3
|
'@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':
|
'@tailwindcss/node@4.1.18':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/remapping': 2.3.5
|
'@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 { IconTrello } from './icon-trello'
|
||||||
export { IconWhatsapp } from './icon-whatsapp'
|
export { IconWhatsapp } from './icon-whatsapp'
|
||||||
export { IconZoom } from './icon-zoom'
|
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 { Link, useNavigate } from '@tanstack/react-router'
|
||||||
import { Loader2, LogIn } from 'lucide-react'
|
import { Loader2, LogIn } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
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 { useAuthStore } from '@/stores/auth-store'
|
||||||
import { sleep, cn } from '@/lib/utils'
|
import { sleep, cn } from '@/lib/utils'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
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)
|
setIsLoading(true)
|
||||||
|
|
||||||
toast.promise(sleep(2000), {
|
try {
|
||||||
loading: 'Signing in...',
|
const { data: authData, error } = await supabase.auth.signInWithPassword({
|
||||||
success: () => {
|
email: data.email,
|
||||||
setIsLoading(false)
|
password: data.password,
|
||||||
|
})
|
||||||
|
|
||||||
// Mock successful authentication with expiry computed at success time
|
if (error) throw error
|
||||||
const mockUser = {
|
|
||||||
accountNo: 'ACC001',
|
if (authData.session && authData.user) {
|
||||||
email: data.email,
|
setIsLoading(false)
|
||||||
role: ['user'],
|
const user = {
|
||||||
exp: Date.now() + 24 * 60 * 60 * 1000, // 24 hours from now
|
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(user)
|
||||||
auth.setUser(mockUser)
|
auth.setAccessToken(authData.session.access_token)
|
||||||
auth.setAccessToken('mock-access-token')
|
|
||||||
|
|
||||||
// Redirect to the stored location or default to dashboard
|
|
||||||
const targetPath = redirectTo || '/'
|
const targetPath = redirectTo || '/'
|
||||||
navigate({ to: targetPath, replace: true })
|
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}!`
|
const handleGoogleLogin = async () => {
|
||||||
},
|
setIsLoading(true)
|
||||||
error: 'Error',
|
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 (
|
return (
|
||||||
@@ -136,14 +156,22 @@ export function UserAuthForm({
|
|||||||
</div>
|
</div>
|
||||||
</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}>
|
<Button variant='outline' type='button' disabled={isLoading}>
|
||||||
<IconGithub className='h-4 w-4' /> GitHub
|
<IconGithub className='h-4 w-4' /> GitHub
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant='outline' type='button' disabled={isLoading}>
|
<Button variant='outline' type='button' disabled={isLoading}>
|
||||||
<IconFacebook className='h-4 w-4' /> Facebook
|
<IconFacebook className='h-4 w-4' /> Facebook
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div> */}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ import {
|
|||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useCharacterDialog } from './character-dialog-context';
|
import { useCharacterDialog } from './character-dialog-context';
|
||||||
import { Character, characterSchema } from '../data/schema';
|
import { Character, characterSchema } from '../data/schema';
|
||||||
import { createCharacter, updateCharacter } from '../data/api';
|
import { createCharacter, updateCharacter } from '../data/api';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { ImageUpload } from './image-upload';
|
||||||
|
|
||||||
export function CharacterDialog() {
|
export function CharacterDialog() {
|
||||||
const { open, setOpen, character } = useCharacterDialog();
|
const { open, setOpen, character } = useCharacterDialog();
|
||||||
@@ -36,12 +36,10 @@ export function CharacterDialog() {
|
|||||||
resolver: zodResolver(characterSchema),
|
resolver: zodResolver(characterSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
status: 'online',
|
|
||||||
compatibility: 0,
|
|
||||||
is_active: true,
|
is_active: true,
|
||||||
is_locked: false,
|
is_locked: false,
|
||||||
sort_order: 0,
|
sort_order: 0,
|
||||||
tagline: '',
|
avatar_path: '',
|
||||||
description: '',
|
description: '',
|
||||||
ai_system_prompt: '',
|
ai_system_prompt: '',
|
||||||
ai_greeting: '',
|
ai_greeting: '',
|
||||||
@@ -56,12 +54,10 @@ export function CharacterDialog() {
|
|||||||
} else {
|
} else {
|
||||||
form.reset({
|
form.reset({
|
||||||
name: '',
|
name: '',
|
||||||
status: 'online',
|
|
||||||
compatibility: 0,
|
|
||||||
is_active: true,
|
is_active: true,
|
||||||
is_locked: false,
|
is_locked: false,
|
||||||
sort_order: 0,
|
sort_order: 0,
|
||||||
tagline: '',
|
avatar_path: '',
|
||||||
description: '',
|
description: '',
|
||||||
ai_system_prompt: '',
|
ai_system_prompt: '',
|
||||||
ai_greeting: '',
|
ai_greeting: '',
|
||||||
@@ -87,11 +83,11 @@ export function CharacterDialog() {
|
|||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
console.log('🔄 更新角色,ID:', character.id);
|
console.log('🔄 更新角色,ID:', character.id);
|
||||||
await updateCharacter({ ...sanitizedValues, id: character.id });
|
await updateCharacter({ ...sanitizedValues, id: character.id } as any);
|
||||||
toast.success('角色更新成功');
|
toast.success('角色更新成功');
|
||||||
} else {
|
} else {
|
||||||
console.log('➕ 创建新角色');
|
console.log('➕ 创建新角色');
|
||||||
await createCharacter(sanitizedValues);
|
await createCharacter(sanitizedValues as any);
|
||||||
toast.success('角色创建成功');
|
toast.success('角色创建成功');
|
||||||
}
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -136,7 +132,7 @@ export function CharacterDialog() {
|
|||||||
}}
|
}}
|
||||||
className='space-y-4'
|
className='space-y-4'
|
||||||
>
|
>
|
||||||
<div className='grid grid-cols-2 gap-4'>
|
<div className='grid grid-cols-1 gap-4'>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='name'
|
name='name'
|
||||||
@@ -150,38 +146,19 @@ export function CharacterDialog() {
|
|||||||
</FormItem>
|
</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>
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='tagline'
|
name='avatar_path'
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>简述</FormLabel>
|
<FormLabel>角色头像</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder='简短描述' {...field} value={field.value || ''} />
|
<ImageUpload
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -203,25 +180,6 @@ export function CharacterDialog() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className='grid grid-cols-2 gap-4'>
|
<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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='sort_order'
|
name='sort_order'
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Character } from '../data/schema';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { DataTableColumnHeader } from '@/components/data-table';
|
import { DataTableColumnHeader } from '@/components/data-table';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { DataTableRowActions } from './data-table-row-actions';
|
import { DataTableRowActions } from './data-table-row-actions';
|
||||||
|
|
||||||
export const columns: ColumnDef<Character>[] = [
|
export const columns: ColumnDef<Character>[] = [
|
||||||
@@ -30,6 +31,23 @@ export const columns: ColumnDef<Character>[] = [
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
enableHiding: 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',
|
accessorKey: 'name',
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
@@ -39,30 +57,7 @@ export const columns: ColumnDef<Character>[] = [
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
enableHiding: 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',
|
accessorKey: 'is_active',
|
||||||
header: ({ column }) => (
|
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',
|
id: 'actions',
|
||||||
cell: ({ row }) => <DataTableRowActions row={row} />,
|
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'>) {
|
export async function createCharacter(character: Omit<Character, 'id' | 'created_at' | 'updated_at'>) {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('characters')
|
.from('characters')
|
||||||
.insert(character)
|
.insert(character);
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -36,9 +34,7 @@ export async function updateCharacter(character: Character) {
|
|||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('characters')
|
.from('characters')
|
||||||
.update(updates)
|
.update(updates)
|
||||||
.eq('id', id)
|
.eq('id', id);
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -1,27 +1,21 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
// 角色状态枚举
|
|
||||||
export const characterStatusSchema = z.enum(['online', 'busy', 'offline']);
|
|
||||||
|
|
||||||
// 角色 Schema
|
// 角色 Schema
|
||||||
export const characterSchema = z.object({
|
export const characterSchema = z.object({
|
||||||
id: z.string().uuid().optional(),
|
id: z.string().optional(),
|
||||||
name: z.string().min(1, 'Name is required'),
|
name: z.string().optional().or(z.literal('')),
|
||||||
tagline: z.string().optional(),
|
avatar_path: z.string().optional().or(z.literal('')),
|
||||||
avatar_path: z.string().optional(),
|
description: z.string().optional().or(z.literal('')),
|
||||||
description: z.string().optional(),
|
|
||||||
|
|
||||||
compatibility: z.number().min(0).max(100).default(0),
|
|
||||||
status: characterStatusSchema.default('online'),
|
|
||||||
is_locked: z.boolean().default(false),
|
is_locked: z.boolean().default(false),
|
||||||
is_active: z.boolean().default(true),
|
is_active: z.boolean().default(true),
|
||||||
sort_order: z.number().default(0),
|
sort_order: z.number().default(0),
|
||||||
|
|
||||||
ai_system_prompt: z.string().optional(),
|
ai_system_prompt: z.string().optional().or(z.literal('')),
|
||||||
ai_greeting: z.string().optional(),
|
ai_greeting: z.string().optional().or(z.literal('')),
|
||||||
// Use z.string() as key type for Record to satisfy TypeScript and Zod v4+ alignment
|
// 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_personality: z.record(z.string(), z.any()).optional().default({}),
|
||||||
ai_voice_config: z.record(z.string(), z.any()).default({}),
|
ai_voice_config: z.record(z.string(), z.any()).optional().default({}),
|
||||||
|
|
||||||
created_at: z.string().optional(),
|
created_at: z.string().optional(),
|
||||||
updated_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 { DirectionProvider } from './context/direction-provider'
|
||||||
import { FontProvider } from './context/font-provider'
|
import { FontProvider } from './context/font-provider'
|
||||||
import { ThemeProvider } from './context/theme-provider'
|
import { ThemeProvider } from './context/theme-provider'
|
||||||
|
import { AuthProvider } from './context/auth-provider'
|
||||||
// Generated Routes
|
// Generated Routes
|
||||||
import { routeTree } from './routeTree.gen'
|
import { routeTree } from './routeTree.gen'
|
||||||
// Styles
|
// Styles
|
||||||
@@ -97,7 +98,9 @@ if (!rootElement.innerHTML) {
|
|||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<FontProvider>
|
<FontProvider>
|
||||||
<DirectionProvider>
|
<DirectionProvider>
|
||||||
<RouterProvider router={router} />
|
<AuthProvider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</AuthProvider>
|
||||||
</DirectionProvider>
|
</DirectionProvider>
|
||||||
</FontProvider>
|
</FontProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -2,30 +2,17 @@ import 'dart:io';
|
|||||||
|
|
||||||
/// Supabase 配置
|
/// Supabase 配置
|
||||||
///
|
///
|
||||||
/// 这里配置了本地 Docker 部署的 Supabase 连接信息
|
/// 这里配置了 Wei AI 线上项目的连接信息
|
||||||
/// 生产环境部署时需要替换为实际的 URL 和 Key
|
|
||||||
class SupabaseConfig {
|
class SupabaseConfig {
|
||||||
SupabaseConfig._();
|
SupabaseConfig._();
|
||||||
|
|
||||||
/// Supabase API URL
|
/// Supabase API URL
|
||||||
///
|
static const String url = 'https://ovkwghuaorazlhijhmpj.supabase.co';
|
||||||
/// - Android 模拟器: 使用 10.0.2.2 访问主机
|
|
||||||
/// - iOS 模拟器/macOS: 使用 localhost
|
|
||||||
/// - 真机测试: 需要替换为电脑的局域网 IP 地址
|
|
||||||
static String get url {
|
|
||||||
// 真机测试: Android 和 iOS 都使用电脑的局域网 IP 地址
|
|
||||||
// 模拟器测试: Android 用 10.0.2.2,iOS 用 localhost
|
|
||||||
if (Platform.isAndroid || Platform.isIOS) {
|
|
||||||
return 'http://192.168.3.118:8000';
|
|
||||||
}
|
|
||||||
// macOS 使用 localhost
|
|
||||||
return 'http://localhost:8000';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Supabase Anon Key
|
/// Supabase Anon Key (Publishable Key)
|
||||||
/// 这个 key 是公开的,用于客户端访问
|
/// 这个 key 是公开的,用于客户端安全访问
|
||||||
static const String anonKey =
|
static const String anonKey =
|
||||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzY5NTk1NjI4LCJleHAiOjE5MjcyNzU2Mjh9.moV0JpCSx3Y1QTZmKZ5K-tQLaWcshxtxFlCoIBQFsEU';
|
'sb_publishable_CXBzerv3x8xYMSV0hnfk6w_nuy5pWtE';
|
||||||
|
|
||||||
/// 是否启用调试模式
|
/// 是否启用调试模式
|
||||||
static const bool debug = true;
|
static const bool debug = true;
|
||||||
|
|||||||
@@ -71,8 +71,9 @@ class _InteractionScreenState extends ConsumerState<InteractionScreen> {
|
|||||||
void _scrollToBottom() {
|
void _scrollToBottom() {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (_scrollController.hasClients) {
|
if (_scrollController.hasClients) {
|
||||||
|
// 使用 reverse: true 后,0.0 就是列表底部(最新消息处)
|
||||||
_scrollController.animateTo(
|
_scrollController.animateTo(
|
||||||
_scrollController.position.maxScrollExtent,
|
0.0,
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
);
|
);
|
||||||
@@ -158,11 +159,20 @@ class _InteractionScreenState extends ConsumerState<InteractionScreen> {
|
|||||||
|
|
||||||
final avatarUrl = CharacterRepository.getAvatarUrl(_character!.avatarPath);
|
final avatarUrl = CharacterRepository.getAvatarUrl(_character!.avatarPath);
|
||||||
|
|
||||||
return Stack(
|
return Container(
|
||||||
children: [
|
decoration: const BoxDecoration(
|
||||||
Scaffold(
|
color: Color(0xFF2E1065),
|
||||||
extendBodyBehindAppBar: true,
|
gradient: LinearGradient(
|
||||||
appBar: AppBar(
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Color(0xFF2E1065), Color(0xFF0F172A)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Scaffold(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
flexibleSpace: ClipRRect(
|
flexibleSpace: ClipRRect(
|
||||||
@@ -229,30 +239,27 @@ class _InteractionScreenState extends ConsumerState<InteractionScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Container(
|
body: GestureDetector(
|
||||||
decoration: const BoxDecoration(
|
onTap: () => FocusScope.of(context).unfocus(),
|
||||||
color: Color(0xFF2E1065),
|
child: Column(
|
||||||
gradient: LinearGradient(
|
children: [
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [Color(0xFF2E1065), Color(0xFF0F172A)],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
|
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
|
reverse: true,
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.only(top: 120, bottom: 20, left: 16, right: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
||||||
itemCount: _messages.length + (_isTyping ? 1 : 0),
|
itemCount: _messages.length + (_isTyping ? 1 : 0),
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 16),
|
separatorBuilder: (_, __) => const SizedBox(height: 16),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
// 如果是正在输入的消息
|
// reverse: true 模式下,索引 0 是列表的最底部
|
||||||
if (_isTyping && index == _messages.length) {
|
if (_isTyping) {
|
||||||
return _buildTypingBubble();
|
if (index == 0) return _buildTypingBubble();
|
||||||
|
final msg = _messages[_messages.length - index];
|
||||||
|
return _buildMessageBubble(msg, avatarUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
final msg = _messages[index];
|
final msg = _messages[_messages.length - 1 - index];
|
||||||
return _buildMessageBubble(msg, avatarUrl);
|
return _buildMessageBubble(msg, avatarUrl);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -263,15 +270,16 @@ class _InteractionScreenState extends ConsumerState<InteractionScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
|
|
||||||
if (_isVoiceMode && _character != null)
|
|
||||||
VoiceModeOverlay(
|
|
||||||
character: _character!,
|
|
||||||
onClose: () => setState(() => _isVoiceMode = false),
|
|
||||||
),
|
),
|
||||||
],
|
|
||||||
|
|
||||||
|
if (_isVoiceMode && _character != null)
|
||||||
|
VoiceModeOverlay(
|
||||||
|
character: _character!,
|
||||||
|
onClose: () => setState(() => _isVoiceMode = false),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +405,10 @@ class _InteractionScreenState extends ConsumerState<InteractionScreen> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => setState(() => _isVoiceMode = true),
|
onTap: () {
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
setState(() => _isVoiceMode = true);
|
||||||
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 44,
|
width: 44,
|
||||||
height: 44,
|
height: 44,
|
||||||
|
|||||||
@@ -32,13 +32,10 @@ CREATE TABLE IF NOT EXISTS characters (
|
|||||||
|
|
||||||
-- 基本信息
|
-- 基本信息
|
||||||
name TEXT NOT NULL, -- 角色名称
|
name TEXT NOT NULL, -- 角色名称
|
||||||
tagline TEXT, -- 角色标语/副标题
|
|
||||||
avatar_path TEXT, -- Storage 中的头像路径
|
avatar_path TEXT, -- Storage 中的头像路径
|
||||||
description TEXT, -- 角色描述
|
description TEXT, -- 角色描述
|
||||||
|
|
||||||
-- 状态信息
|
-- 状态信息
|
||||||
compatibility REAL DEFAULT 0, -- 契合度 (0-100)
|
|
||||||
status TEXT DEFAULT 'online', -- 状态: online, busy, offline
|
|
||||||
is_locked BOOLEAN DEFAULT false, -- 是否锁定(会员限定)
|
is_locked BOOLEAN DEFAULT false, -- 是否锁定(会员限定)
|
||||||
is_active BOOLEAN DEFAULT true, -- 是否上架显示
|
is_active BOOLEAN DEFAULT true, -- 是否上架显示
|
||||||
sort_order INTEGER DEFAULT 0, -- 排序顺序
|
sort_order INTEGER DEFAULT 0, -- 排序顺序
|
||||||
@@ -76,7 +73,6 @@ CREATE INDEX IF NOT EXISTS idx_tags_category ON tags(category_id);
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_characters_active ON characters(is_active);
|
CREATE INDEX IF NOT EXISTS idx_characters_active ON characters(is_active);
|
||||||
CREATE INDEX IF NOT EXISTS idx_characters_locked ON characters(is_locked);
|
CREATE INDEX IF NOT EXISTS idx_characters_locked ON characters(is_locked);
|
||||||
CREATE INDEX IF NOT EXISTS idx_characters_status ON characters(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_characters_sort ON characters(sort_order);
|
CREATE INDEX IF NOT EXISTS idx_characters_sort ON characters(sort_order);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_character_tags_character ON character_tags(character_id);
|
CREATE INDEX IF NOT EXISTS idx_character_tags_character ON character_tags(character_id);
|
||||||
|
|||||||
Reference in New Issue
Block a user