1705 lines
42 KiB
Vue
1705 lines
42 KiB
Vue
<template>
|
||
<view class="chat-container">
|
||
<!-- 夜空装饰背景 -->
|
||
<view class="night-sky-decoration">
|
||
<view class="star star-1"></view>
|
||
<view class="star star-2"></view>
|
||
<view class="star star-3"></view>
|
||
<view class="star star-4"></view>
|
||
<view class="star star-5"></view>
|
||
<view class="star star-6"></view>
|
||
<view class="star star-7"></view>
|
||
<view class="star star-8"></view>
|
||
</view>
|
||
|
||
<!-- 自定义导航栏 -->
|
||
<view class="custom-navbar">
|
||
<view class="navbar-left">
|
||
<view v-if="showBackButton" @tap="goBack" class="left-btn">
|
||
<text class="back-icon">←</text>
|
||
<text class="back-text">返回</text>
|
||
</view>
|
||
<text class="clear-btn" @tap="clearContext">清空</text>
|
||
</view>
|
||
<view class="navbar-center" :class="{'centered': !showBackButton}">
|
||
<image class="character-avatar" :src="currentCharacter.avatar" mode="aspectFill" />
|
||
<text class="character-name">{{ currentCharacter.name }}</text>
|
||
</view>
|
||
<view class="navbar-right">
|
||
</view>
|
||
</view>
|
||
|
||
|
||
<!-- 聊天消息区域 - 使用 Ant Design X Bubble.List -->
|
||
<scroll-view
|
||
class="chat-messages"
|
||
scroll-y="true"
|
||
:scroll-top="scrollTop"
|
||
scroll-with-animation="true"
|
||
>
|
||
<view class="message-list">
|
||
<!-- 使用 Bubble 组件展示消息 -->
|
||
<view
|
||
v-for="(message, index) in messages"
|
||
:key="index"
|
||
class="message-wrapper"
|
||
>
|
||
<!-- 系统消息 -->
|
||
<view v-if="message.type === 'system'" class="system-message">
|
||
<text class="system-text">{{ message.content }}</text>
|
||
</view>
|
||
|
||
<!-- AI/用户消息 -->
|
||
<view v-else class="bubble-container" :class="[message.type]">
|
||
<view class="bubble-avatar" v-if="message.type === 'ai'">
|
||
<image :src="currentCharacter.avatar" mode="aspectFill" />
|
||
</view>
|
||
|
||
<view class="bubble-content">
|
||
<view class="bubble" :class="[message.type]">
|
||
<text class="bubble-text">{{ message.content }}</text>
|
||
|
||
<!-- AI 消息操作按钮 - 已隐藏播放按钮 -->
|
||
<!-- <view class="bubble-actions" v-if="message.type === 'ai' && isLoggedIn">
|
||
<view class="action-btn" @click="() => playVoiceResponse(message.content)">
|
||
<text class="action-icon">🔊</text>
|
||
</view>
|
||
</view> -->
|
||
|
||
<view class="bubble-time">{{ message.time }}</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="bubble-avatar" v-if="message.type === 'user'">
|
||
<image :src="userAvatar" mode="aspectFill" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 正在输入指示器 -->
|
||
<view class="typing-indicator" v-if="isTyping">
|
||
<view class="bubble-avatar">
|
||
<image :src="currentCharacter.avatar" mode="aspectFill" />
|
||
</view>
|
||
<view class="typing-bubble">
|
||
<view class="typing-dots">
|
||
<view class="dot"></view>
|
||
<view class="dot"></view>
|
||
<view class="dot"></view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
|
||
<!-- 输入区域 - 使用 Ant Design X Sender 风格 -->
|
||
<view class="input-area">
|
||
<!-- 智能体选择按钮 -->
|
||
<!-- <view class="action-bar" v-if="availableTemplates.length > 0">
|
||
<view class="template-selector-btn" @click="showTemplateSelector">
|
||
<text class="template-icon">🤖</text>
|
||
<text class="template-name">{{ currentTemplateName }}</text>
|
||
<text class="arrow-icon">›</text>
|
||
</view>
|
||
</view> -->
|
||
|
||
<view class="sender-container">
|
||
<!-- 文本输入模式 -->
|
||
<view v-if="!isRecordingMode" class="text-input-mode">
|
||
<input
|
||
class="text-input"
|
||
v-model="inputText"
|
||
placeholder="输入消息..."
|
||
:disabled="isTyping || isLoading"
|
||
@confirm="sendMessage"
|
||
confirm-type="send"
|
||
@focus="handleInputFocus"
|
||
/>
|
||
|
||
<view class="input-actions">
|
||
<!-- 麦克风按钮已隐藏 -->
|
||
<!-- <view class="action-icon-btn" @click="toggleRecordingMode">
|
||
<text class="icon">🎙️</text>
|
||
</view> -->
|
||
<view
|
||
class="send-btn"
|
||
:class="{'active': inputText.trim()}"
|
||
@click="sendMessage"
|
||
>
|
||
<text class="send-icon">发送</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 语音输入模式 -->
|
||
<view v-else class="voice-input-mode">
|
||
<view
|
||
class="hold-to-speak"
|
||
:class="{'recording': isActuallyRecording}"
|
||
@touchstart="startActualRecording"
|
||
@touchend="stopActualRecording"
|
||
@touchcancel="cancelActualRecording"
|
||
@touchmove="handleRecordingMove"
|
||
>
|
||
<text class="speak-text">{{ isActuallyRecording ? '松手发送...' : '按住说话' }}</text>
|
||
</view>
|
||
|
||
<!-- 键盘按钮已隐藏 -->
|
||
<!-- <view class="action-icon-btn" @click="toggleRecordingMode">
|
||
<text class="icon">⌨️</text>
|
||
</view> -->
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 录音状态提示 -->
|
||
<view class="recording-overlay" v-if="isActuallyRecording">
|
||
<view class="recording-modal">
|
||
<text class="recording-tip">{{ recordingTipText }}</text>
|
||
<view class="recording-animation" v-if="!isRecordingCancelled">
|
||
<view class="wave" v-for="i in 5" :key="i"></view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 智能体选择弹窗 -->
|
||
<!-- <view class="modal-overlay" v-if="showTemplateModal" @click="hideTemplateSelector">
|
||
<view class="modal-content" @click.stop>
|
||
<view class="modal-header">
|
||
<text class="modal-title">选择智能体</text>
|
||
<view class="close-btn" @click="hideTemplateSelector">
|
||
<text class="close-icon">✕</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="template-list">
|
||
<view
|
||
class="template-item"
|
||
:class="{'active': currentTemplateId === template.templateId}"
|
||
v-for="template in availableTemplates"
|
||
:key="template.templateId"
|
||
@click="() => selectTemplate(template)"
|
||
>
|
||
<view class="template-info">
|
||
<text class="template-name">{{ template.templateName }}</text>
|
||
<text class="template-desc">{{ template.templateDesc }}</text>
|
||
</view>
|
||
<view class="check-mark" v-if="currentTemplateId === template.templateId">
|
||
<text class="check-icon">✓</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view> -->
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, nextTick } from 'vue';
|
||
import { useUserStore } from '@/stores/user.js';
|
||
import { getCharacterById, getSmartResponse } from '@/utils/aiCharacters.js';
|
||
import { chatAPI, voiceAPI, cleanText, configAPI } from '@/utils/api.js';
|
||
|
||
// 状态管理
|
||
const userStore = useUserStore();
|
||
|
||
// 响应式数据
|
||
const messages = ref([]);
|
||
const inputText = ref('');
|
||
const isTyping = ref(false);
|
||
const isOnline = ref(true);
|
||
const scrollTop = ref(0);
|
||
const currentCharacter = ref({});
|
||
const userAvatar = ref('/static/default-avatar.png');
|
||
const conversationId = ref(null);
|
||
const isLoading = ref(false);
|
||
const isLoggedIn = ref(false);
|
||
const showBackButton = ref(true);
|
||
|
||
// 录音相关
|
||
const isRecordingMode = ref(false);
|
||
const isActuallyRecording = ref(false);
|
||
const isRecordingCancelled = ref(false);
|
||
const recordingTipText = ref('松手发送,上移取消');
|
||
const touchStartY = ref(0);
|
||
const recorderManager = ref(null);
|
||
const audioContext = ref(null);
|
||
|
||
// AI配置
|
||
const availableTemplates = ref([]);
|
||
const currentModelId = ref(null);
|
||
const currentTemplateId = ref(null);
|
||
const showTemplateModal = ref(false);
|
||
|
||
// 当前模板名称
|
||
const currentTemplateName = computed(() => {
|
||
const template = availableTemplates.value.find(t => t.templateId === currentTemplateId.value);
|
||
return template?.templateName || '默认智能体';
|
||
});
|
||
|
||
// 生命周期
|
||
onMounted(() => {
|
||
initPage();
|
||
initRecorder();
|
||
});
|
||
|
||
// 初始化页面
|
||
const initPage = async () => {
|
||
checkLoginStatus();
|
||
|
||
const pages = getCurrentPages();
|
||
const currentPage = pages[pages.length - 1];
|
||
const options = currentPage.options || {};
|
||
|
||
if (!options.characterId && !options.roleId) {
|
||
showBackButton.value = false;
|
||
}
|
||
|
||
// 蔚AI
|
||
if (options.characterId === 'wei-ai' || !options.characterId) {
|
||
currentCharacter.value = {
|
||
id: 'wei-ai',
|
||
name: options.characterName || '蔚AI',
|
||
avatar: options.characterAvatar || '/static/avatar/icon_hushi.jpg',
|
||
greeting: decodeURIComponent(options.introMessage || '你好!我是蔚AI,很高兴为您服务!')
|
||
};
|
||
|
||
await loadAIConfigs();
|
||
createNewConversation('wei-ai');
|
||
|
||
// 尝试加载历史消息
|
||
await loadHistoryMessages();
|
||
|
||
// 如果没有历史消息,显示欢迎消息
|
||
if (messages.value.length === 0) {
|
||
addMessage('ai', currentCharacter.value.greeting);
|
||
|
||
if (!isLoggedIn.value) {
|
||
addMessage('system', '您当前处于未登录状态,将使用本地模拟回复。登录后可享受完整的AI对话功能。');
|
||
}
|
||
}
|
||
}
|
||
// AI角色
|
||
else if (options.roleId) {
|
||
currentCharacter.value = {
|
||
id: options.characterId,
|
||
roleId: options.roleId,
|
||
name: decodeURIComponent(options.roleName || 'AI角色'),
|
||
avatar: decodeURIComponent(options.avatar || '/static/logo.png'),
|
||
greeting: decodeURIComponent(options.greeting || '你好!很高兴认识你!'),
|
||
roleDesc: decodeURIComponent(options.roleDesc || ''),
|
||
modelId: options.modelId || null,
|
||
templateId: options.templateId || null,
|
||
ttsId: options.ttsId || null,
|
||
sttId: options.sttId || null,
|
||
temperature: options.temperature || null,
|
||
topP: options.topP || null
|
||
};
|
||
|
||
await loadAIConfigs();
|
||
|
||
if (options.modelId) {
|
||
currentModelId.value = parseInt(options.modelId);
|
||
}
|
||
if (options.templateId) {
|
||
currentTemplateId.value = parseInt(options.templateId);
|
||
}
|
||
|
||
createNewConversation(options.roleId);
|
||
|
||
// 尝试加载历史消息
|
||
await loadHistoryMessages();
|
||
|
||
// 如果没有历史消息,显示欢迎消息
|
||
if (messages.value.length === 0) {
|
||
addMessage('ai', currentCharacter.value.greeting);
|
||
|
||
if (!isLoggedIn.value) {
|
||
addMessage('system', '您当前处于未登录状态,将使用本地模拟回复。登录后可享受完整的AI对话功能。');
|
||
}
|
||
}
|
||
}
|
||
// 默认角色
|
||
else {
|
||
const characterId = options.characterId || 5;
|
||
const character = getCharacterById(characterId);
|
||
if (character) {
|
||
currentCharacter.value = character;
|
||
await loadAIConfigs();
|
||
createNewConversation(characterId);
|
||
|
||
// 尝试加载历史消息
|
||
await loadHistoryMessages();
|
||
|
||
// 如果没有历史消息,显示欢迎消息
|
||
if (messages.value.length === 0) {
|
||
addMessage('ai', character.greeting);
|
||
|
||
if (!isLoggedIn.value) {
|
||
addMessage('system', '您当前处于未登录状态,将使用本地模拟回复。登录后可享受完整的AI对话功能。');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (userStore.userInfo?.avatar) {
|
||
userAvatar.value = userStore.userInfo.avatar;
|
||
}
|
||
};
|
||
|
||
// 加载AI配置
|
||
const loadAIConfigs = async () => {
|
||
try {
|
||
const templatesResult = await configAPI.getTemplates();
|
||
|
||
if (templatesResult.success) {
|
||
availableTemplates.value = templatesResult.data.data?.list || templatesResult.data.data || templatesResult.data;
|
||
|
||
if (availableTemplates.value.length > 0 && !currentTemplateId.value) {
|
||
currentTemplateId.value = availableTemplates.value[0].templateId;
|
||
}
|
||
} else {
|
||
// 模拟数据
|
||
availableTemplates.value = [
|
||
{ templateId: 1, templateName: '默认AI模型', templateDesc: '在用户与AI角色对话时,这个智能体会记住用户的言语习惯。' },
|
||
{ templateId: 2, templateName: '1号AI模型', templateDesc: '学习用户的表达方式,并逐渐适应个性化的交流风格。' }
|
||
];
|
||
currentTemplateId.value = 1;
|
||
}
|
||
} catch (error) {
|
||
console.error('加载AI配置异常:', error);
|
||
availableTemplates.value = [
|
||
{ templateId: 1, templateName: '默认AI模型', templateDesc: '默认AI模型' }
|
||
];
|
||
currentTemplateId.value = 1;
|
||
}
|
||
};
|
||
|
||
// 检查登录状态
|
||
const checkLoginStatus = () => {
|
||
const customToken = uni.getStorageSync('custom_token');
|
||
const userToken = uni.getStorageSync('user_token');
|
||
const userInfo = uni.getStorageSync('userInfo');
|
||
|
||
isLoggedIn.value = !!(userStore.token || customToken || userToken || userInfo);
|
||
};
|
||
|
||
// 初始化录音器
|
||
const initRecorder = () => {
|
||
// #ifdef MP-WEIXIN
|
||
recorderManager.value = uni.getRecorderManager();
|
||
|
||
recorderManager.value.onStart(() => {
|
||
console.log('录音开始');
|
||
});
|
||
|
||
recorderManager.value.onStop((res) => {
|
||
console.log('录音结束', res);
|
||
if (!isRecordingCancelled.value) {
|
||
handleVoiceMessage(res.tempFilePath);
|
||
}
|
||
});
|
||
|
||
recorderManager.value.onError((err) => {
|
||
console.error('录音错误', err);
|
||
uni.showToast({
|
||
title: '录音失败',
|
||
icon: 'none'
|
||
});
|
||
isActuallyRecording.value = false;
|
||
});
|
||
// #endif
|
||
};
|
||
|
||
// 添加消息
|
||
const addMessage = (type, content) => {
|
||
const now = new Date();
|
||
const time = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
|
||
|
||
messages.value.push({
|
||
type,
|
||
content,
|
||
time
|
||
});
|
||
|
||
nextTick(() => {
|
||
scrollToBottom();
|
||
});
|
||
};
|
||
|
||
// 滚动到底部
|
||
const scrollToBottom = () => {
|
||
const query = uni.createSelectorQuery();
|
||
query.select('.message-list').boundingClientRect();
|
||
query.exec((res) => {
|
||
if (res[0]) {
|
||
scrollTop.value = res[0].height;
|
||
}
|
||
});
|
||
};
|
||
|
||
// 延时工具
|
||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||
|
||
// 根据 & 分段,逐条展示 AI 回复
|
||
const addSegmentedAIResponse = async (text) => {
|
||
if (!text || typeof text !== 'string') return;
|
||
// 使用 & 作为分隔符,按段落逐条展示
|
||
const segments = text
|
||
.split('&')
|
||
.map(s => s.trim())
|
||
.filter(Boolean);
|
||
|
||
if (segments.length === 0) {
|
||
addMessage('ai', text.trim());
|
||
return;
|
||
}
|
||
|
||
for (const seg of segments) {
|
||
addMessage('ai', seg);
|
||
// 简单的节奏控制:与长度相关,介于300-1200ms
|
||
const delay = Math.min(1200, Math.max(300, seg.length * 30));
|
||
await sleep(delay);
|
||
}
|
||
};
|
||
|
||
// 发送消息
|
||
const sendMessage = async () => {
|
||
const text = inputText.value.trim();
|
||
if (!text || isTyping.value || isLoading.value) return;
|
||
|
||
addMessage('user', text);
|
||
inputText.value = '';
|
||
isTyping.value = true;
|
||
|
||
try {
|
||
await getAIResponse(text);
|
||
} catch (error) {
|
||
console.error('发送消息失败:', error);
|
||
uni.showToast({
|
||
title: '发送失败',
|
||
icon: 'none'
|
||
});
|
||
} finally {
|
||
isTyping.value = false;
|
||
}
|
||
};
|
||
|
||
// 创建或获取会话ID(基于角色持久化存储)
|
||
const createNewConversation = (characterId) => {
|
||
// 生成存储key:根据角色类型区分
|
||
let storageKey = '';
|
||
if (characterId === 'wei-ai' || !currentCharacter.value.roleId) {
|
||
// 蔚AI或默认角色
|
||
storageKey = `session_weiai`;
|
||
} else {
|
||
// 剧情角色
|
||
storageKey = `session_role_${currentCharacter.value.roleId}`;
|
||
}
|
||
|
||
// 尝试从本地存储获取已有的sessionId
|
||
let existingSessionId = uni.getStorageSync(storageKey);
|
||
|
||
if (existingSessionId) {
|
||
// 已有sessionId,直接使用(保持上下文)
|
||
conversationId.value = existingSessionId;
|
||
console.log('使用已有sessionId:', existingSessionId, 'storageKey:', storageKey);
|
||
} else {
|
||
// 生成新的sessionId
|
||
const userId = userStore.userInfo?.openid || userStore.userInfo?.userId || 'guest';
|
||
const timestamp = Date.now();
|
||
const newSessionId = `session_${characterId}_${userId}_${timestamp}`;
|
||
|
||
// 存储到本地
|
||
uni.setStorageSync(storageKey, newSessionId);
|
||
conversationId.value = newSessionId;
|
||
console.log('创建新sessionId:', newSessionId, 'storageKey:', storageKey);
|
||
}
|
||
};
|
||
|
||
// 加载历史消息
|
||
const loadHistoryMessages = async () => {
|
||
if (!conversationId.value || !isLoggedIn.value) {
|
||
console.log('没有sessionId或用户未登录,跳过加载历史消息');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
console.log('开始加载历史消息,sessionId:', conversationId.value);
|
||
const result = await chatAPI.getHistoryMessages(conversationId.value);
|
||
|
||
if (result.success && result.data && result.data.length > 0) {
|
||
console.log('获取到历史消息:', result.data.length, '条');
|
||
|
||
// 将后端消息格式转换为前端格式,保持与API回复一致的“清理后回复”逻辑
|
||
const historyMessages = [];
|
||
|
||
result.data.forEach(msg => {
|
||
// 格式化时间
|
||
let timeStr = '';
|
||
if (msg.createTime) {
|
||
const date = new Date(msg.createTime);
|
||
const hours = String(date.getHours()).padStart(2, '0');
|
||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||
timeStr = `${hours}:${minutes}`;
|
||
}
|
||
|
||
const messageType = msg.sender === 'assistant' ? 'ai' : (msg.sender === 'user' ? 'user' : 'system');
|
||
const rawContent = msg.message || '';
|
||
|
||
// 与API一致:先清理文本,再根据 & 分段
|
||
const cleanedContent = cleanText(rawContent);
|
||
|
||
if (messageType === 'ai' && cleanedContent.includes('&')) {
|
||
const segments = cleanedContent
|
||
.split('&')
|
||
.map(s => s.trim())
|
||
.filter(Boolean);
|
||
|
||
segments.forEach(segment => {
|
||
historyMessages.push({
|
||
type: messageType,
|
||
content: segment,
|
||
time: timeStr
|
||
});
|
||
});
|
||
} else {
|
||
// 非 AI 或无分隔符,直接使用清理后的内容
|
||
const content = cleanedContent.trim();
|
||
if (content) {
|
||
historyMessages.push({
|
||
type: messageType,
|
||
content,
|
||
time: timeStr
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// 按时间排序(从旧到新)
|
||
historyMessages.sort((a, b) => {
|
||
if (!a.time || !b.time) return 0;
|
||
return a.time.localeCompare(b.time);
|
||
});
|
||
|
||
// 清空当前消息列表,加载历史消息
|
||
messages.value = historyMessages;
|
||
console.log('历史消息加载完成,共', messages.value.length, '条');
|
||
|
||
// 滚动到底部
|
||
await nextTick();
|
||
scrollToBottom();
|
||
} else {
|
||
console.log('没有历史消息或获取失败');
|
||
}
|
||
} catch (error) {
|
||
console.error('加载历史消息失败:', error);
|
||
}
|
||
};
|
||
|
||
// 获取AI回复
|
||
const getAIResponse = async (userMessage) => {
|
||
try {
|
||
isLoading.value = true;
|
||
|
||
const requestParams = {
|
||
message: userMessage,
|
||
characterId: currentCharacter.value.id,
|
||
conversationId: conversationId.value,
|
||
modelId: 10,
|
||
templateId: 6
|
||
};
|
||
|
||
if (currentCharacter.value.roleId) {
|
||
if (currentCharacter.value.modelId) {
|
||
requestParams.modelId = currentCharacter.value.modelId;
|
||
}
|
||
if (currentCharacter.value.templateId) {
|
||
requestParams.templateId = currentCharacter.value.templateId;
|
||
} else {
|
||
requestParams.templateId = currentCharacter.value.roleId;
|
||
}
|
||
}
|
||
|
||
const result = await chatAPI.syncChat(requestParams);
|
||
|
||
if (result.success) {
|
||
const aiResponse = result.data;
|
||
await addSegmentedAIResponse(aiResponse);
|
||
|
||
if (result.originalResponse && result.originalResponse.conversationId) {
|
||
conversationId.value = result.originalResponse.conversationId;
|
||
}
|
||
} else {
|
||
const fallbackResponse = getSmartResponse(currentCharacter.value, userMessage);
|
||
const cleanedResponse = cleanText(fallbackResponse);
|
||
await addSegmentedAIResponse(cleanedResponse);
|
||
|
||
if (!result.isAnonymous) {
|
||
uni.showToast({
|
||
title: 'AI服务暂时不可用,使用本地回复',
|
||
icon: 'none',
|
||
duration: 2000
|
||
});
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('AI API调用异常:', error);
|
||
const fallbackResponse = getSmartResponse(currentCharacter.value, userMessage);
|
||
const cleanedResponse = cleanText(fallbackResponse);
|
||
await addSegmentedAIResponse(cleanedResponse);
|
||
|
||
uni.showToast({
|
||
title: '网络异常,使用本地回复',
|
||
icon: 'none',
|
||
duration: 2000
|
||
});
|
||
} finally {
|
||
isLoading.value = false;
|
||
}
|
||
};
|
||
|
||
// 切换录音模式
|
||
const toggleRecordingMode = () => {
|
||
if (isTyping.value) return;
|
||
isRecordingMode.value = !isRecordingMode.value;
|
||
|
||
if (!isRecordingMode.value && isActuallyRecording.value) {
|
||
stopActualRecording();
|
||
}
|
||
};
|
||
|
||
// 开始录音
|
||
const startActualRecording = (e) => {
|
||
if (isTyping.value || !isRecordingMode.value) return;
|
||
|
||
touchStartY.value = e.touches[0].clientY;
|
||
isRecordingCancelled.value = false;
|
||
recordingTipText.value = '松手发送,上移取消';
|
||
|
||
// #ifdef MP-WEIXIN
|
||
uni.authorize({
|
||
scope: 'scope.record',
|
||
success: () => {
|
||
isActuallyRecording.value = true;
|
||
recorderManager.value.start({
|
||
duration: 60000,
|
||
sampleRate: 16000,
|
||
numberOfChannels: 1,
|
||
encodeBitRate: 96000,
|
||
format: 'aac'
|
||
});
|
||
},
|
||
fail: () => {
|
||
uni.showModal({
|
||
title: '需要录音权限',
|
||
content: '请在设置中开启录音权限',
|
||
showCancel: false
|
||
});
|
||
}
|
||
});
|
||
// #endif
|
||
|
||
// #ifndef MP-WEIXIN
|
||
uni.showToast({
|
||
title: '语音功能仅支持微信小程序',
|
||
icon: 'none'
|
||
});
|
||
// #endif
|
||
};
|
||
|
||
// 停止录音
|
||
const stopActualRecording = () => {
|
||
if (!isActuallyRecording.value) return;
|
||
|
||
// #ifdef MP-WEIXIN
|
||
isActuallyRecording.value = false;
|
||
isRecordingCancelled.value = false;
|
||
recorderManager.value.stop();
|
||
// #endif
|
||
};
|
||
|
||
// 取消录音
|
||
const cancelActualRecording = () => {
|
||
if (!isActuallyRecording.value) return;
|
||
|
||
// #ifdef MP-WEIXIN
|
||
isActuallyRecording.value = false;
|
||
isRecordingCancelled.value = true;
|
||
recorderManager.value.stop();
|
||
|
||
uni.showToast({
|
||
title: '录音已取消',
|
||
icon: 'none',
|
||
duration: 1000
|
||
});
|
||
// #endif
|
||
};
|
||
|
||
// 处理录音移动
|
||
const handleRecordingMove = (e) => {
|
||
if (isActuallyRecording.value) {
|
||
const currentY = e.touches[0].clientY;
|
||
const deltaY = touchStartY.value - currentY;
|
||
|
||
if (deltaY > 50) {
|
||
isRecordingCancelled.value = true;
|
||
recordingTipText.value = '松开取消录音';
|
||
} else {
|
||
isRecordingCancelled.value = false;
|
||
recordingTipText.value = '松手发送,上移取消';
|
||
}
|
||
}
|
||
};
|
||
|
||
// 处理语音消息
|
||
const handleVoiceMessage = async (filePath) => {
|
||
if (isRecordingCancelled.value) return;
|
||
|
||
addMessage('user', '[语音消息]');
|
||
isTyping.value = true;
|
||
|
||
try {
|
||
let effectiveModelId = currentModelId.value;
|
||
let effectiveTemplateId = currentTemplateId.value;
|
||
|
||
if (currentCharacter.value.roleId) {
|
||
if (currentCharacter.value.modelId) {
|
||
effectiveModelId = currentCharacter.value.modelId;
|
||
}
|
||
if (currentCharacter.value.templateId) {
|
||
effectiveTemplateId = currentCharacter.value.templateId;
|
||
} else {
|
||
effectiveTemplateId = currentCharacter.value.roleId;
|
||
}
|
||
}
|
||
|
||
const voiceChatResult = await voiceAPI.voiceChat(filePath, {
|
||
modelId: effectiveModelId,
|
||
templateId: effectiveTemplateId
|
||
});
|
||
|
||
if (voiceChatResult.success) {
|
||
const { userText, aiResponse, audioUrl } = voiceChatResult.data;
|
||
|
||
if (messages.value.length > 0) {
|
||
const lastMessage = messages.value[messages.value.length - 1];
|
||
if (lastMessage.type === 'user' && lastMessage.content === '[语音消息]') {
|
||
lastMessage.content = userText || '[语音消息]';
|
||
}
|
||
}
|
||
|
||
await addSegmentedAIResponse(aiResponse);
|
||
|
||
if (audioUrl) {
|
||
await playVoiceResponse(aiResponse, audioUrl);
|
||
}
|
||
} else {
|
||
const uploadResult = await voiceAPI.uploadVoiceChat(filePath, {
|
||
modelId: effectiveModelId,
|
||
templateId: effectiveTemplateId
|
||
});
|
||
|
||
if (uploadResult.success) {
|
||
const { userText, aiResponse, audioUrl } = uploadResult.data;
|
||
|
||
if (messages.value.length > 0) {
|
||
const lastMessage = messages.value[messages.value.length - 1];
|
||
if (lastMessage.type === 'user' && lastMessage.content === '[语音消息]') {
|
||
lastMessage.content = userText || '[语音消息]';
|
||
}
|
||
}
|
||
|
||
await addSegmentedAIResponse(aiResponse);
|
||
|
||
if (audioUrl) {
|
||
await playVoiceResponse(aiResponse, audioUrl);
|
||
}
|
||
} else {
|
||
const fallbackResponse = getSmartResponse(currentCharacter.value, '[语音消息]');
|
||
const cleanedResponse = cleanText(fallbackResponse);
|
||
await addSegmentedAIResponse(cleanedResponse);
|
||
|
||
uni.showToast({
|
||
title: '语音处理失败,使用本地回复',
|
||
icon: 'none',
|
||
duration: 2000
|
||
});
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('处理语音消息失败:', error);
|
||
const fallbackResponse = getSmartResponse(currentCharacter.value, '[语音消息]');
|
||
const cleanedResponse = cleanText(fallbackResponse);
|
||
await addSegmentedAIResponse(cleanedResponse);
|
||
|
||
uni.showToast({
|
||
title: '语音处理异常,使用本地回复',
|
||
icon: 'none',
|
||
duration: 2000
|
||
});
|
||
} finally {
|
||
isTyping.value = false;
|
||
}
|
||
};
|
||
|
||
// 播放语音
|
||
const playVoiceResponse = async (text, audioUrl = null) => {
|
||
// #ifdef MP-WEIXIN
|
||
try {
|
||
let finalAudioUrl = audioUrl;
|
||
|
||
if (!finalAudioUrl) {
|
||
const ttsResult = await voiceAPI.textToSpeech(text, currentCharacter.value.voiceStyle);
|
||
|
||
if (ttsResult.success) {
|
||
finalAudioUrl = ttsResult.data.audioUrl;
|
||
} else {
|
||
uni.showToast({
|
||
title: '语音合成失败',
|
||
icon: 'none'
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (audioContext.value) {
|
||
audioContext.value.destroy();
|
||
}
|
||
|
||
audioContext.value = uni.createInnerAudioContext();
|
||
audioContext.value.src = finalAudioUrl;
|
||
|
||
audioContext.value.onPlay(() => {
|
||
uni.showToast({
|
||
title: '正在播放语音',
|
||
icon: 'none',
|
||
duration: 1000
|
||
});
|
||
});
|
||
|
||
audioContext.value.onError((err) => {
|
||
console.error('语音播放错误:', err);
|
||
uni.showToast({
|
||
title: '语音播放失败',
|
||
icon: 'none'
|
||
});
|
||
});
|
||
|
||
audioContext.value.play();
|
||
} catch (error) {
|
||
console.error('语音播放异常:', error);
|
||
uni.showToast({
|
||
title: '语音播放异常',
|
||
icon: 'none'
|
||
});
|
||
}
|
||
// #endif
|
||
|
||
// #ifndef MP-WEIXIN
|
||
uni.showToast({
|
||
title: '语音功能仅支持微信小程序',
|
||
icon: 'none'
|
||
});
|
||
// #endif
|
||
};
|
||
|
||
// 显示模板选择器
|
||
const showTemplateSelector = () => {
|
||
showTemplateModal.value = true;
|
||
};
|
||
|
||
// 隐藏模板选择器
|
||
const hideTemplateSelector = () => {
|
||
showTemplateModal.value = false;
|
||
};
|
||
|
||
// 选择模板
|
||
const selectTemplate = (template) => {
|
||
currentTemplateId.value = template.templateId;
|
||
|
||
uni.showToast({
|
||
title: `已切换到${template.templateName}`,
|
||
icon: 'none',
|
||
duration: 1500
|
||
});
|
||
|
||
hideTemplateSelector();
|
||
};
|
||
|
||
// 处理输入框聚焦
|
||
const handleInputFocus = () => {
|
||
console.log('输入框获得焦点');
|
||
};
|
||
|
||
// 返回
|
||
const goBack = () => {
|
||
uni.navigateBack();
|
||
};
|
||
|
||
|
||
// 清空对话上下文
|
||
const clearContext = async () => {
|
||
uni.showModal({
|
||
title: '清空对话',
|
||
content: '确定要清空与该角色的所有对话记录吗?此操作不可恢复。',
|
||
confirmText: '确定清空',
|
||
cancelText: '取消',
|
||
success: async (res) => {
|
||
if (res.confirm) {
|
||
try {
|
||
uni.showLoading({ title: '清空中...' });
|
||
|
||
// 1. 调用后端清除session(如果用户已登录)
|
||
if (conversationId.value && isLoggedIn.value) {
|
||
try {
|
||
const result = await chatAPI.clearSession(conversationId.value);
|
||
console.log('后端清除会话结果:', result);
|
||
} catch (error) {
|
||
console.log('后端清除失败,继续本地清除:', error);
|
||
}
|
||
}
|
||
|
||
// 2. 删除本地存储的sessionId
|
||
let storageKey = '';
|
||
if (currentCharacter.value.id === 'wei-ai' || !currentCharacter.value.roleId) {
|
||
storageKey = `session_weiai`;
|
||
} else {
|
||
storageKey = `session_role_${currentCharacter.value.roleId}`;
|
||
}
|
||
uni.removeStorageSync(storageKey);
|
||
console.log('删除本地sessionId:', storageKey);
|
||
|
||
// 3. 生成新的sessionId
|
||
const characterIdForSession = currentCharacter.value.id || currentCharacter.value.roleId || 'default';
|
||
createNewConversation(characterIdForSession);
|
||
|
||
// 4. 清空消息列表(保留欢迎消息)
|
||
messages.value = [];
|
||
addMessage('ai', currentCharacter.value.greeting || '你好!很高兴再次见到你!');
|
||
|
||
uni.hideLoading();
|
||
uni.showToast({
|
||
title: '对话已清空',
|
||
icon: 'success'
|
||
});
|
||
} catch (error) {
|
||
console.error('清空对话失败:', error);
|
||
uni.hideLoading();
|
||
uni.showToast({
|
||
title: '清空失败',
|
||
icon: 'none'
|
||
});
|
||
}
|
||
}
|
||
}
|
||
});
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 页面容器 */
|
||
page {
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
background: linear-gradient(180deg, #1a0b2e 0%, #2d1b4e 50%, #1a0b2e 100%);
|
||
}
|
||
|
||
.chat-container {
|
||
width: 100vw;
|
||
height: 100vh;
|
||
background: linear-gradient(180deg, #1a0b2e 0%, #2d1b4e 50%, #1a0b2e 100%);
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
overflow: hidden;
|
||
padding-top: calc(100rpx + env(safe-area-inset-top));
|
||
padding-bottom: 200rpx;
|
||
}
|
||
|
||
/* 夜空装饰 */
|
||
.night-sky-decoration {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
}
|
||
|
||
.star {
|
||
position: absolute;
|
||
width: 6rpx;
|
||
height: 6rpx;
|
||
background: #f9e076;
|
||
border-radius: 50%;
|
||
animation: twinkle 2s infinite;
|
||
box-shadow: 0 0 10rpx #f9e076;
|
||
}
|
||
|
||
@keyframes twinkle {
|
||
0%, 100% { opacity: 0.3; }
|
||
50% { opacity: 1; }
|
||
}
|
||
|
||
.star-1 { top: 10%; left: 15%; animation-delay: 0s; }
|
||
.star-2 { top: 20%; left: 80%; animation-delay: 0.3s; }
|
||
.star-3 { top: 30%; left: 45%; animation-delay: 0.6s; }
|
||
.star-4 { top: 50%; left: 25%; animation-delay: 0.9s; }
|
||
.star-5 { top: 60%; left: 70%; animation-delay: 1.2s; }
|
||
.star-6 { top: 70%; left: 40%; animation-delay: 1.5s; }
|
||
.star-7 { top: 15%; left: 60%; animation-delay: 0.4s; }
|
||
.star-8 { top: 85%; left: 55%; animation-delay: 1.8s; }
|
||
|
||
/* 导航栏 */
|
||
.custom-navbar {
|
||
height: 100rpx;
|
||
background: rgba(26, 11, 46, 0.95);
|
||
backdrop-filter: blur(20rpx);
|
||
box-shadow: 0 2rpx 20rpx rgba(0, 0, 0, 0.3);
|
||
border-bottom: 1rpx solid rgba(249, 224, 118, 0.1);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0 32rpx;
|
||
padding-top: env(safe-area-inset-top);
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.navbar-left {
|
||
position: absolute;
|
||
left: 24rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
color: #f9e076;
|
||
font-size: 28rpx;
|
||
font-weight: 500;
|
||
text-shadow: 0 0 10rpx rgba(249, 224, 118, 0.3);
|
||
}
|
||
|
||
.left-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
}
|
||
|
||
.back-icon {
|
||
font-size: 32rpx;
|
||
}
|
||
|
||
.navbar-center {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.navbar-center.centered {
|
||
position: absolute;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
}
|
||
|
||
.character-avatar {
|
||
width: 64rpx;
|
||
height: 64rpx;
|
||
border-radius: 50%;
|
||
border: 3rpx solid rgba(249, 224, 118, 0.3);
|
||
box-shadow: 0 4rpx 12rpx rgba(249, 224, 118, 0.2);
|
||
}
|
||
|
||
.character-name {
|
||
font-size: 32rpx;
|
||
font-weight: 600;
|
||
color: #f9e076;
|
||
text-shadow: 0 0 15rpx rgba(249, 224, 118, 0.4);
|
||
}
|
||
|
||
.navbar-right {
|
||
position: absolute;
|
||
right: 32rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.clear-btn {
|
||
font-size: 28rpx;
|
||
color: #f9e076;
|
||
font-weight: 500;
|
||
text-shadow: 0 0 8rpx rgba(249, 224, 118, 0.3);
|
||
padding: 8rpx 16rpx;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.clear-btn:active {
|
||
opacity: 0.7;
|
||
transform: scale(0.95);
|
||
}
|
||
|
||
.status-dot.online {
|
||
background: #4caf50;
|
||
box-shadow: 0 0 12rpx rgba(76, 175, 80, 0.8);
|
||
animation: pulse 2s infinite;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% {
|
||
opacity: 1;
|
||
}
|
||
50% {
|
||
opacity: 0.6;
|
||
}
|
||
}
|
||
|
||
|
||
/* 聊天消息区域 */
|
||
.chat-messages {
|
||
height: calc(100vh - 100rpx - 200rpx - env(safe-area-inset-top));
|
||
padding: 24rpx;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.message-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 24rpx;
|
||
}
|
||
|
||
.message-wrapper {
|
||
width: 100%;
|
||
}
|
||
|
||
/* 系统消息 */
|
||
.system-message {
|
||
display: flex;
|
||
justify-content: center;
|
||
margin: 16rpx 0;
|
||
}
|
||
|
||
.system-text {
|
||
background: rgba(249, 224, 118, 0.1);
|
||
backdrop-filter: blur(10px);
|
||
border: 1rpx solid rgba(249, 224, 118, 0.2);
|
||
color: rgba(249, 224, 118, 0.8);
|
||
padding: 12rpx 24rpx;
|
||
border-radius: 20rpx;
|
||
font-size: 24rpx;
|
||
text-align: center;
|
||
max-width: 80%;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* Bubble 样式 */
|
||
.bubble-container {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 16rpx;
|
||
width: 100%;
|
||
}
|
||
|
||
.bubble-container.user {
|
||
flex-direction: row-reverse;
|
||
}
|
||
|
||
.bubble-avatar {
|
||
width: 72rpx;
|
||
height: 72rpx;
|
||
border-radius: 50%;
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.bubble-avatar image {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.bubble-content {
|
||
flex: 1;
|
||
max-width: 70%;
|
||
}
|
||
|
||
.bubble {
|
||
padding: 20rpx 24rpx;
|
||
border-radius: 24rpx;
|
||
position: relative;
|
||
word-wrap: break-word;
|
||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.bubble.ai {
|
||
background: linear-gradient(135deg, rgba(249, 224, 118, 0.15) 0%, rgba(249, 224, 118, 0.08) 100%);
|
||
border: 1rpx solid rgba(249, 224, 118, 0.3);
|
||
color: #f9e076;
|
||
border-bottom-left-radius: 8rpx;
|
||
}
|
||
|
||
.bubble.user {
|
||
background: linear-gradient(135deg, #f9e076 0%, #f5d042 100%);
|
||
color: #1a0b2e;
|
||
border-bottom-right-radius: 8rpx;
|
||
box-shadow: 0 8rpx 24rpx rgba(249, 224, 118, 0.3);
|
||
}
|
||
|
||
.bubble-text {
|
||
font-size: 28rpx;
|
||
line-height: 1.6;
|
||
display: block;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.bubble-actions {
|
||
display: flex;
|
||
gap: 12rpx;
|
||
margin: 12rpx 0 8rpx 0;
|
||
}
|
||
|
||
.action-btn {
|
||
width: 48rpx;
|
||
height: 48rpx;
|
||
border-radius: 50%;
|
||
background: rgba(249, 224, 118, 0.2);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.action-btn:active {
|
||
background: rgba(249, 224, 118, 0.4);
|
||
transform: scale(0.95);
|
||
}
|
||
|
||
.action-icon {
|
||
font-size: 24rpx;
|
||
}
|
||
|
||
.bubble-time {
|
||
font-size: 20rpx;
|
||
opacity: 0.6;
|
||
text-align: right;
|
||
}
|
||
|
||
.bubble.ai .bubble-time {
|
||
text-align: left;
|
||
}
|
||
|
||
/* 正在输入 */
|
||
.typing-indicator {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.typing-bubble {
|
||
background: linear-gradient(135deg, rgba(249, 224, 118, 0.15) 0%, rgba(249, 224, 118, 0.08) 100%);
|
||
border: 1rpx solid rgba(249, 224, 118, 0.3);
|
||
padding: 20rpx 24rpx;
|
||
border-radius: 24rpx;
|
||
border-bottom-left-radius: 8rpx;
|
||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.typing-dots {
|
||
display: flex;
|
||
gap: 8rpx;
|
||
align-items: center;
|
||
}
|
||
|
||
.dot {
|
||
width: 12rpx;
|
||
height: 12rpx;
|
||
border-radius: 50%;
|
||
background: #f9e076;
|
||
box-shadow: 0 0 8rpx rgba(249, 224, 118, 0.5);
|
||
animation: typing 1.4s infinite ease-in-out;
|
||
}
|
||
|
||
.dot:nth-child(1) { animation-delay: -0.32s; }
|
||
.dot:nth-child(2) { animation-delay: -0.16s; }
|
||
|
||
@keyframes typing {
|
||
0%, 80%, 100% {
|
||
transform: scale(0.8);
|
||
opacity: 0.5;
|
||
}
|
||
40% {
|
||
transform: scale(1);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
/* 输入区域 */
|
||
.input-area {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
background: rgba(26, 11, 46, 0.95);
|
||
backdrop-filter: blur(20rpx);
|
||
border-top: 1rpx solid rgba(249, 224, 118, 0.1);
|
||
padding: 16rpx 24rpx;
|
||
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
|
||
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.3);
|
||
z-index: 1000;
|
||
}
|
||
|
||
.action-bar {
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.template-selector-btn {
|
||
background: linear-gradient(135deg, rgba(249, 224, 118, 0.1) 0%, rgba(249, 224, 118, 0.05) 100%);
|
||
border: 2rpx solid rgba(249, 224, 118, 0.3);
|
||
border-radius: 16rpx;
|
||
padding: 12rpx 20rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.template-selector-btn:active {
|
||
background: linear-gradient(135deg, rgba(249, 224, 118, 0.2) 0%, rgba(249, 224, 118, 0.1) 100%);
|
||
transform: scale(0.98);
|
||
}
|
||
|
||
.template-icon {
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.template-name {
|
||
flex: 1;
|
||
font-size: 26rpx;
|
||
color: #f9e076;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.arrow-icon {
|
||
font-size: 32rpx;
|
||
color: #f9e076;
|
||
}
|
||
|
||
/* Sender 样式 */
|
||
.sender-container {
|
||
background: rgba(249, 224, 118, 0.08);
|
||
border: 1rpx solid rgba(249, 224, 118, 0.2);
|
||
border-radius: 24rpx;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.text-input-mode {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
padding: 8rpx 16rpx;
|
||
}
|
||
|
||
.text-input {
|
||
flex: 1;
|
||
font-size: 28rpx;
|
||
color: #f9e076;
|
||
background: transparent;
|
||
border: none;
|
||
outline: none;
|
||
padding: 12rpx 0;
|
||
min-height: 48rpx;
|
||
}
|
||
|
||
.text-input::placeholder {
|
||
color: rgba(249, 224, 118, 0.5);
|
||
}
|
||
|
||
.input-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
.action-icon-btn {
|
||
width: 56rpx;
|
||
height: 56rpx;
|
||
border-radius: 50%;
|
||
background: rgba(249, 224, 118, 0.15);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.action-icon-btn:active {
|
||
background: rgba(249, 224, 118, 0.3);
|
||
transform: scale(0.95);
|
||
}
|
||
|
||
.icon {
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.send-btn {
|
||
padding: 12rpx 24rpx;
|
||
border-radius: 20rpx;
|
||
background: rgba(249, 224, 118, 0.2);
|
||
font-size: 26rpx;
|
||
color: rgba(249, 224, 118, 0.6);
|
||
font-weight: 500;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.send-btn.active {
|
||
background: linear-gradient(135deg, #f9e076 0%, #f5d042 100%);
|
||
color: #1a0b2e;
|
||
box-shadow: 0 4rpx 12rpx rgba(249, 224, 118, 0.4);
|
||
}
|
||
|
||
.send-btn.active:active {
|
||
transform: scale(0.95);
|
||
}
|
||
|
||
.send-icon {
|
||
display: block;
|
||
}
|
||
|
||
/* 语音输入模式 */
|
||
.voice-input-mode {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
padding: 12rpx 16rpx;
|
||
}
|
||
|
||
.hold-to-speak {
|
||
flex: 1;
|
||
background: rgba(249, 224, 118, 0.1);
|
||
border: 2rpx dashed rgba(249, 224, 118, 0.3);
|
||
border-radius: 16rpx;
|
||
padding: 20rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.hold-to-speak.recording {
|
||
background: linear-gradient(135deg, #f9e076 0%, #f5d042 100%);
|
||
border-color: transparent;
|
||
animation: pulse-recording 1s infinite;
|
||
}
|
||
|
||
.speak-text {
|
||
font-size: 28rpx;
|
||
color: #f9e076;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.hold-to-speak.recording .speak-text {
|
||
color: #1a0b2e;
|
||
}
|
||
|
||
@keyframes pulse-recording {
|
||
0%, 100% {
|
||
transform: scale(1);
|
||
}
|
||
50% {
|
||
transform: scale(1.02);
|
||
}
|
||
}
|
||
|
||
/* 录音提示 */
|
||
.recording-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.6);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 2000;
|
||
backdrop-filter: blur(4rpx);
|
||
}
|
||
|
||
.recording-modal {
|
||
background: rgba(26, 11, 46, 0.95);
|
||
backdrop-filter: blur(20rpx);
|
||
border: 2rpx solid rgba(249, 224, 118, 0.3);
|
||
padding: 48rpx;
|
||
border-radius: 32rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 24rpx;
|
||
box-shadow: 0 10rpx 40rpx rgba(0, 0, 0, 0.5);
|
||
}
|
||
|
||
.recording-tip {
|
||
font-size: 28rpx;
|
||
color: #f9e076;
|
||
font-weight: 500;
|
||
text-shadow: 0 0 10rpx rgba(249, 224, 118, 0.5);
|
||
}
|
||
|
||
.recording-animation {
|
||
display: flex;
|
||
gap: 8rpx;
|
||
align-items: center;
|
||
}
|
||
|
||
.wave {
|
||
width: 6rpx;
|
||
height: 40rpx;
|
||
background: linear-gradient(135deg, #f9e076 0%, #f5d042 100%);
|
||
border-radius: 3rpx;
|
||
animation: wave 1.2s infinite ease-in-out;
|
||
box-shadow: 0 0 8rpx rgba(249, 224, 118, 0.5);
|
||
}
|
||
|
||
.wave:nth-child(1) { animation-delay: 0s; }
|
||
.wave:nth-child(2) { animation-delay: 0.1s; }
|
||
.wave:nth-child(3) { animation-delay: 0.2s; }
|
||
.wave:nth-child(4) { animation-delay: 0.3s; }
|
||
.wave:nth-child(5) { animation-delay: 0.4s; }
|
||
|
||
@keyframes wave {
|
||
0%, 100% {
|
||
height: 40rpx;
|
||
}
|
||
50% {
|
||
height: 80rpx;
|
||
}
|
||
}
|
||
|
||
/* 模态框 */
|
||
.modal-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.7);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 2000;
|
||
backdrop-filter: blur(8rpx);
|
||
}
|
||
|
||
.modal-content {
|
||
width: 86%;
|
||
max-width: 640rpx;
|
||
max-height: 80vh;
|
||
background: linear-gradient(135deg, rgba(26, 11, 46, 0.98) 0%, rgba(45, 27, 78, 0.98) 100%);
|
||
border: 2rpx solid rgba(249, 224, 118, 0.3);
|
||
border-radius: 32rpx;
|
||
overflow: hidden;
|
||
box-shadow: 0 12rpx 48rpx rgba(0, 0, 0, 0.5);
|
||
}
|
||
|
||
.modal-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 32rpx;
|
||
border-bottom: 1rpx solid rgba(249, 224, 118, 0.1);
|
||
background: linear-gradient(135deg, rgba(249, 224, 118, 0.08) 0%, rgba(249, 224, 118, 0.03) 100%);
|
||
}
|
||
|
||
.modal-title {
|
||
font-size: 32rpx;
|
||
font-weight: 600;
|
||
color: #f9e076;
|
||
text-shadow: 0 0 15rpx rgba(249, 224, 118, 0.5);
|
||
}
|
||
|
||
.close-btn {
|
||
width: 56rpx;
|
||
height: 56rpx;
|
||
border-radius: 50%;
|
||
background: rgba(249, 224, 118, 0.1);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.close-btn:active {
|
||
background: rgba(249, 224, 118, 0.2);
|
||
transform: scale(0.95);
|
||
}
|
||
|
||
.close-icon {
|
||
font-size: 28rpx;
|
||
color: #f9e076;
|
||
}
|
||
|
||
.template-list {
|
||
max-height: 60vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.template-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 32rpx;
|
||
border-bottom: 1rpx solid rgba(249, 224, 118, 0.1);
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.template-item:active {
|
||
background: rgba(249, 224, 118, 0.05);
|
||
}
|
||
|
||
.template-item.active {
|
||
background: linear-gradient(135deg, rgba(249, 224, 118, 0.15) 0%, rgba(249, 224, 118, 0.08) 100%);
|
||
}
|
||
|
||
.template-info {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8rpx;
|
||
padding-right: 16rpx;
|
||
}
|
||
|
||
.template-item .template-name {
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
color: #f9e076;
|
||
margin-bottom: 4rpx;
|
||
}
|
||
|
||
.template-desc {
|
||
font-size: 24rpx;
|
||
color: rgba(249, 224, 118, 0.7);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.check-mark {
|
||
width: 48rpx;
|
||
height: 48rpx;
|
||
border-radius: 50%;
|
||
background: linear-gradient(135deg, #f9e076 0%, #f5d042 100%);
|
||
box-shadow: 0 4rpx 12rpx rgba(249, 224, 118, 0.4);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.check-icon {
|
||
font-size: 24rpx;
|
||
color: #1a0b2e;
|
||
font-weight: bold;
|
||
}
|
||
</style>
|