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

@@ -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",

View File

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

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: '',
@@ -87,11 +83,11 @@ export function CharacterDialog() {
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);
@@ -136,7 +132,7 @@ export function CharacterDialog() {
}}
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'

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>

View File

@@ -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.2iOS 用 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;

View File

@@ -71,8 +71,9 @@ class _InteractionScreenState extends ConsumerState<InteractionScreen> {
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<InteractionScreen> {
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<InteractionScreen> {
),
],
),
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<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(
children: [
GestureDetector(
onTap: () => setState(() => _isVoiceMode = true),
onTap: () {
FocusScope.of(context).unfocus();
setState(() => _isVoiceMode = true);
},
child: Container(
width: 44,
height: 44,

View File

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