From dec5748ccac5232492f3a9ebc04f02d76f559309 Mon Sep 17 00:00:00 2001 From: liqupan Date: Mon, 2 Feb 2026 22:48:11 +0800 Subject: [PATCH] feat: Implement character avatar upload and refactor character form fields and API calls. --- shadcn-admin/package.json | 1 + shadcn-admin/pnpm-lock.yaml | 18 +++ .../src/assets/brand-icons/icon-google.tsx | 29 ++++ shadcn-admin/src/assets/brand-icons/index.ts | 1 + shadcn-admin/src/context/auth-provider.tsx | 33 +++++ .../sign-in/components/user-auth-form.tsx | 72 +++++++--- .../components/character-dialog.tsx | 86 +++-------- .../characters/components/columns.tsx | 57 +++----- .../characters/components/image-upload.tsx | 134 ++++++++++++++++++ .../src/features/characters/data/api.ts | 8 +- .../src/features/characters/data/schema.ts | 22 ++- shadcn-admin/src/main.tsx | 5 +- .../lib/core/config/supabase_config.dart | 23 +-- .../interaction/interaction_screen.dart | 73 ++++++---- .../supabase/migrations/001_create_tables.sql | 4 - 15 files changed, 369 insertions(+), 197 deletions(-) create mode 100644 shadcn-admin/src/assets/brand-icons/icon-google.tsx create mode 100644 shadcn-admin/src/context/auth-provider.tsx create mode 100644 shadcn-admin/src/features/characters/components/image-upload.tsx diff --git a/shadcn-admin/package.json b/shadcn-admin/package.json index b19c643..c24fd04 100644 --- a/shadcn-admin/package.json +++ b/shadcn-admin/package.json @@ -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", diff --git a/shadcn-admin/pnpm-lock.yaml b/shadcn-admin/pnpm-lock.yaml index aac4a74..8a9238b 100644 --- a/shadcn-admin/pnpm-lock.yaml +++ b/shadcn-admin/pnpm-lock.yaml @@ -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 diff --git a/shadcn-admin/src/assets/brand-icons/icon-google.tsx b/shadcn-admin/src/assets/brand-icons/icon-google.tsx new file mode 100644 index 0000000..a487f12 --- /dev/null +++ b/shadcn-admin/src/assets/brand-icons/icon-google.tsx @@ -0,0 +1,29 @@ +import { SVGProps } from 'react' + +export function IconGoogle(props: SVGProps) { + return ( + + + + + + + ) +} diff --git a/shadcn-admin/src/assets/brand-icons/index.ts b/shadcn-admin/src/assets/brand-icons/index.ts index 530491c..957cedb 100644 --- a/shadcn-admin/src/assets/brand-icons/index.ts +++ b/shadcn-admin/src/assets/brand-icons/index.ts @@ -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' diff --git a/shadcn-admin/src/context/auth-provider.tsx b/shadcn-admin/src/context/auth-provider.tsx new file mode 100644 index 0000000..290af77 --- /dev/null +++ b/shadcn-admin/src/context/auth-provider.tsx @@ -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}; +} diff --git a/shadcn-admin/src/features/auth/sign-in/components/user-auth-form.tsx b/shadcn-admin/src/features/auth/sign-in/components/user-auth-form.tsx index c056c16..05c8e71 100644 --- a/shadcn-admin/src/features/auth/sign-in/components/user-auth-form.tsx +++ b/shadcn-admin/src/features/auth/sign-in/components/user-auth-form.tsx @@ -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) { + async function onSubmit(data: z.infer) { 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({ -
+ {/*
+ -
+
*/} ) diff --git a/shadcn-admin/src/features/characters/components/character-dialog.tsx b/shadcn-admin/src/features/characters/components/character-dialog.tsx index 6f9dbab..20016b8 100644 --- a/shadcn-admin/src/features/characters/components/character-dialog.tsx +++ b/shadcn-admin/src/features/characters/components/character-dialog.tsx @@ -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() {
- { console.log('📋 Form onSubmit 事件触发', e); console.log('📊 当前表单状态:', form.formState); @@ -133,10 +129,10 @@ export function CharacterDialog() { toast.error('请检查表单输入'); } )(e); - }} + }} className='space-y-4' > -
+
)} /> - ( - - 状态 - - - - )} - />
( - 简述 + 角色头像 - + @@ -203,25 +180,6 @@ export function CharacterDialog() { />
- ( - - 契合度 (%) - - field.onChange(parseInt(e.target.value))} - /> - - - - )} - /> setOpen(false)}> 取消 - +
+ ) : ( +
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 ? ( + + ) : ( + <> + + 选择图片 + + )} +
+ )} + +
+

+ 推荐尺寸: 500x500px
+ 最大限制: 2MB +

+ + {!value && ( + + )} +
+
+ + ); +} diff --git a/shadcn-admin/src/features/characters/data/api.ts b/shadcn-admin/src/features/characters/data/api.ts index a005317..910307b 100644 --- a/shadcn-admin/src/features/characters/data/api.ts +++ b/shadcn-admin/src/features/characters/data/api.ts @@ -18,9 +18,7 @@ export async function getCharacters() { export async function createCharacter(character: Omit) { 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; diff --git a/shadcn-admin/src/features/characters/data/schema.ts b/shadcn-admin/src/features/characters/data/schema.ts index d089ebf..10b45a2 100644 --- a/shadcn-admin/src/features/characters/data/schema.ts +++ b/shadcn-admin/src/features/characters/data/schema.ts @@ -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(), diff --git a/shadcn-admin/src/main.tsx b/shadcn-admin/src/main.tsx index c31f465..5d02654 100644 --- a/shadcn-admin/src/main.tsx +++ b/shadcn-admin/src/main.tsx @@ -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) { - + + + diff --git a/wei_ai_app/lib/core/config/supabase_config.dart b/wei_ai_app/lib/core/config/supabase_config.dart index e16d207..7470aef 100644 --- a/wei_ai_app/lib/core/config/supabase_config.dart +++ b/wei_ai_app/lib/core/config/supabase_config.dart @@ -2,30 +2,17 @@ import 'dart:io'; /// Supabase 配置 /// -/// 这里配置了本地 Docker 部署的 Supabase 连接信息 -/// 生产环境部署时需要替换为实际的 URL 和 Key +/// 这里配置了 Wei AI 线上项目的连接信息 class SupabaseConfig { SupabaseConfig._(); /// Supabase API URL - /// - /// - 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'; - } + static const String url = 'https://ovkwghuaorazlhijhmpj.supabase.co'; - /// Supabase Anon Key - /// 这个 key 是公开的,用于客户端访问 + /// Supabase Anon Key (Publishable Key) + /// 这个 key 是公开的,用于客户端安全访问 static const String anonKey = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzY5NTk1NjI4LCJleHAiOjE5MjcyNzU2Mjh9.moV0JpCSx3Y1QTZmKZ5K-tQLaWcshxtxFlCoIBQFsEU'; + 'sb_publishable_CXBzerv3x8xYMSV0hnfk6w_nuy5pWtE'; /// 是否启用调试模式 static const bool debug = true; diff --git a/wei_ai_app/lib/screens/interaction/interaction_screen.dart b/wei_ai_app/lib/screens/interaction/interaction_screen.dart index 87ff05f..2dcec93 100644 --- a/wei_ai_app/lib/screens/interaction/interaction_screen.dart +++ b/wei_ai_app/lib/screens/interaction/interaction_screen.dart @@ -71,8 +71,9 @@ class _InteractionScreenState extends ConsumerState { void _scrollToBottom() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { + // 使用 reverse: true 后,0.0 就是列表底部(最新消息处) _scrollController.animateTo( - _scrollController.position.maxScrollExtent, + 0.0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); @@ -158,11 +159,20 @@ class _InteractionScreenState extends ConsumerState { final avatarUrl = CharacterRepository.getAvatarUrl(_character!.avatarPath); - return Stack( - children: [ - Scaffold( - extendBodyBehindAppBar: true, - appBar: AppBar( + return Container( + decoration: const BoxDecoration( + color: Color(0xFF2E1065), + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF2E1065), Color(0xFF0F172A)], + ), + ), + child: Stack( + children: [ + Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, flexibleSpace: ClipRRect( @@ -229,30 +239,27 @@ class _InteractionScreenState extends ConsumerState { ), ], ), - body: Container( - decoration: const BoxDecoration( - color: Color(0xFF2E1065), - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFF2E1065), Color(0xFF0F172A)], - ), - ), - child: Column( - children: [ + body: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Column( + children: [ Expanded( child: ListView.separated( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + reverse: true, 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), separatorBuilder: (_, __) => const SizedBox(height: 16), itemBuilder: (context, index) { - // 如果是正在输入的消息 - if (_isTyping && index == _messages.length) { - return _buildTypingBubble(); + // reverse: true 模式下,索引 0 是列表的最底部 + if (_isTyping) { + 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); }, ), @@ -263,15 +270,16 @@ class _InteractionScreenState extends ConsumerState { ], ), ), - ), - - - 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 { child: Row( children: [ GestureDetector( - onTap: () => setState(() => _isVoiceMode = true), + onTap: () { + FocusScope.of(context).unfocus(); + setState(() => _isVoiceMode = true); + }, child: Container( width: 44, height: 44, diff --git a/wei_ai_app/supabase/migrations/001_create_tables.sql b/wei_ai_app/supabase/migrations/001_create_tables.sql index 87d6c0b..64eedd6 100644 --- a/wei_ai_app/supabase/migrations/001_create_tables.sql +++ b/wei_ai_app/supabase/migrations/001_create_tables.sql @@ -32,13 +32,10 @@ CREATE TABLE IF NOT EXISTS characters ( -- 基本信息 name TEXT NOT NULL, -- 角色名称 - tagline TEXT, -- 角色标语/副标题 avatar_path TEXT, -- Storage 中的头像路径 description TEXT, -- 角色描述 -- 状态信息 - compatibility REAL DEFAULT 0, -- 契合度 (0-100) - status TEXT DEFAULT 'online', -- 状态: online, busy, offline is_locked BOOLEAN DEFAULT false, -- 是否锁定(会员限定) is_active BOOLEAN DEFAULT true, -- 是否上架显示 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_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_character_tags_character ON character_tags(character_id);