ai-clone/frontend-ai/pages/revival/revival.vue

2558 lines
69 KiB
Vue
Raw Normal View History

2026-03-05 14:29:21 +08:00
<template>
<view class="memorial-bg">
<view class="memorial-content">
<!-- 头部标题 -->
<view class="header">
<text class="logo">AI声音克隆</text>
<text class="tagline">克隆亲人声音 · 语音合成 · 实时对话</text>
</view>
<!-- 消息提示 -->
<view v-if="message.text" :class="['message', message.type]">
{{ message.text }}
</view>
<!-- 创建音色按钮 -->
<view class="create-voice-btn-wrapper">
<button class="create-voice-btn" @click="goToUpload">
📤 创建音色
</button>
</view>
<!-- 标签页 -->
<view class="tabs">
<view :class="['tab-item', activeTab === 'synthesize' ? 'active' : '']" @click="activeTab = 'synthesize'">
🔊 合成
</view>
<view :class="['tab-item', activeTab === 'conversation' ? 'active' : '']" @click="activeTab = 'conversation'">
💬 对话
</view>
<view :class="['tab-item', activeTab === 'list' ? 'active' : '']" @click="activeTab = 'list'">
📋 列表
</view>
</view>
</view>
<!-- 内容区域 -->
<scroll-view scroll-y class="content">
<!-- 语音合成 -->
<view v-if="activeTab === 'synthesize'" class="tab-content">
<view class="section-title">🎵 语音合成</view>
<view class="form-section">
<view class="form-label-with-help">
<text class="form-label">选择音色类型</text>
<text class="help-icon" @click="showVoiceHelp"></text>
</view>
<picker mode="selector" :range="voiceTypeOptions" range-key="label" @change="onVoiceTypeChange">
<view class="picker-large">
{{ selectedVoiceTypeLabel }}
</view>
</picker>
<picker mode="selector" :range="displayVoices" range-key="voice_name" @change="onVoiceChange">
<view class="picker-large">
{{ selectedVoiceName || '请选择音色' }}
</view>
</picker>
<view class="hint-text">💡 请先在"声音克隆"页面创建音色</view>
</view>
<view v-if="selectedSupportsDialect && (selectedVoiceType || 'CLONE') !== 'OFFICIAL'" class="form-section">
<view class="form-label">方言</view>
<picker mode="selector" :range="dialectOptions" @change="onDialectChange">
<view class="picker-large">
{{ selectedDialect || '请选择方言(可选)' }}
</view>
</picker>
</view>
<view v-if="selectedSupportsLanguageHints && (selectedVoiceType || 'CLONE') !== 'OFFICIAL'" class="form-section">
<view class="form-label">语言提示可选</view>
<picker mode="selector" :range="languageHintOptions" @change="onLanguageHintChange">
<view class="picker-large">
{{ selectedLanguageHintLabel || '请选择语言(可选)' }}
</view>
</picker>
<view class="hint-text">💡 仅处理第一个值不设置不生效</view>
</view>
<view class="form-section">
<view class="form-label"> 文案模板</view>
<view v-if="templates.length === 0" class="empty-template">
<text> 加载中...</text>
</view>
<view v-else class="template-categories">
<!-- 缅怀类 -->
<view class="category-group">
<view class="category-header" @click="toggleCategory('memorial')">
<text class="category-name">💭 缅怀类</text>
<text class="category-arrow" :class="{ 'arrow-open': expandedCategory === 'memorial' }"></text>
</view>
<view v-if="expandedCategory === 'memorial'" class="category-content">
<view
v-for="(template, index) in getTemplatesByCategory('memorial')"
:key="index"
class="template-item"
@click="selectTemplate(template)"
>
<text class="template-title">{{ template.title }}</text>
<text class="template-preview">{{ template.content }}</text>
</view>
</view>
</view>
<!-- 问候类 -->
<view class="category-group">
<view class="category-header" @click="toggleCategory('greeting')">
<text class="category-name">👋 问候类</text>
<text class="category-arrow" :class="{ 'arrow-open': expandedCategory === 'greeting' }"></text>
</view>
<view v-if="expandedCategory === 'greeting'" class="category-content">
<view
v-for="(template, index) in getTemplatesByCategory('greeting')"
:key="index"
class="template-item"
@click="selectTemplate(template)"
>
<text class="template-title">{{ template.title }}</text>
<text class="template-preview">{{ template.content }}</text>
</view>
</view>
</view>
<!-- 回忆类 -->
<view class="category-group">
<view class="category-header" @click="toggleCategory('memory')">
<text class="category-name">📖 回忆类</text>
<text class="category-arrow" :class="{ 'arrow-open': expandedCategory === 'memory' }"></text>
</view>
<view v-if="expandedCategory === 'memory'" class="category-content">
<view
v-for="(template, index) in getTemplatesByCategory('memory')"
:key="index"
class="template-item"
@click="selectTemplate(template)"
>
<text class="template-title">{{ template.title }}</text>
<text class="template-preview">{{ template.content }}</text>
</view>
</view>
</view>
</view>
</view>
<view class="form-section">
<view class="form-label">输入文本最多100字</view>
<textarea
class="textarea"
v-model="ttsText"
placeholder="请输入要合成的文本..."
maxlength="100"
/>
<view class="char-count" :class="{ 'char-warning': ttsText.length >= 90 }">
{{ ttsText.length }} / 100
</view>
</view>
<button class="primary-btn" :disabled="ttsLoading || !ttsText.trim() || !selectedVoiceId" @click="handleSynthesize">
{{ ttsLoading ? '⏳ 合成中...' : '🔊 开始合成' }}
</button>
<text class="ai-disclaimer">本服务为AI生成内容结果仅供参考</text>
2026-03-05 14:29:21 +08:00
<view v-if="audioUrl" class="audio-result">
<view class="result-title"> 合成成功</view>
<audio :src="audioUrl" controls style="width: 100%;"></audio>
</view>
</view>
<!-- 实时对话 -->
<view v-if="activeTab === 'conversation'" class="tab-content">
<view class="section-title">💬 实时语音对话</view>
<view class="memory-card">
<view class="card-header">
<text class="card-title">💭 记忆设定</text>
<text class="card-hint">填写越详细AI模拟越真实</text>
</view>
<view class="memory-form">
<view class="form-item">
<view class="item-label">
<text class="label-text">身份</text>
<text class="label-required">*</text>
</view>
<input
class="form-input"
v-model="memoryIdentity"
placeholder="例如:我的母亲"
maxlength="50"
/>
</view>
<view class="form-item">
<view class="item-label">
<text class="label-text">基础信息</text>
</view>
<textarea
class="form-textarea"
v-model="memoryInfo"
placeholder="例如:退休教师,喜欢养花,做饭很拿手..."
maxlength="200"
/>
<text class="form-count">{{ memoryInfo.length }}/200</text>
</view>
<button class="apply-btn" @click="handleApplyMemory">
<text class="btn-icon"></text>
<text>应用设定</text>
</button>
</view>
</view>
<view class="form-section">
<view class="form-label">选择回复音色</view>
<picker mode="selector" :range="voiceTypeOptions" range-key="label" @change="onConversationVoiceTypeChange">
<view class="picker-large">
{{ conversationVoiceTypeLabel }}
</view>
</picker>
<picker mode="selector" :range="conversationDisplayVoices" range-key="voice_name" @change="onConversationVoiceChange">
<view class="picker-large">
{{ conversationVoiceLabel || '请选择音色' }}
</view>
</picker>
</view>
<view v-if="conversationSupportsDialect && (conversationVoiceType || 'CLONE') !== 'OFFICIAL'" class="form-section">
<view class="form-label">方言</view>
<picker mode="selector" :range="dialectOptions" @change="onConversationDialectChange">
<view class="picker-large">
{{ conversationDialect || '请选择方言(可选)' }}
</view>
</picker>
</view>
<view v-if="conversationSupportsLanguageHints && (conversationVoiceType || 'CLONE') !== 'OFFICIAL'" class="form-section">
<view class="form-label">语言提示可选</view>
<picker mode="selector" :range="languageHintOptions" @change="onConversationLanguageHintChange">
<view class="picker-large">
{{ conversationLanguageHintLabel || '请选择语言(可选)' }}
</view>
</picker>
<view class="hint-text">💡 仅处理第一个值不设置不生效</view>
</view>
<view class="record-buttons">
<view v-if="!isRecording && !isProcessing" class="record-btn start" @click="startRecording">
🎤 开始说话
</view>
<view v-if="isRecording" class="record-btn stop" @click="stopRecording">
停止录音
</view>
<view v-if="isProcessing" class="record-btn processing">
处理中...
</view>
<view class="small-btn" :class="{ disabled: isRecording || isProcessing }" @click="isRecording || isProcessing ? null : handleClearHistory()">
<image src="/static/iconfont/delete.svg" class="delete-icon" mode="aspectFit" style="width: 44upx; height: 44upx;"></image>
</view>
</view>
<view v-if="recognizedText" class="result-box user">
<view class="result-label">🎤 您说</view>
<text>{{ recognizedText }}</text>
</view>
<view v-if="aiResponse" class="result-box ai">
<view class="result-label">💬 AI回复</view>
<text>{{ aiResponse }}</text>
</view>
<view v-if="conversationAudio" class="result-box audio">
<view class="result-label">🔊 语音回复</view>
<audio :src="conversationAudio" controls style="width: 100%;"></audio>
</view>
</view>
<!-- 音色列表 -->
<view v-if="activeTab === 'list'" class="tab-content">
<view class="list-header">
<view class="section-title">我的音色</view>
<button class="small-btn refresh-btn" :disabled="loading" @click="loadVoices">
{{ loading ? '⏳' : '🔄' }}
</button>
</view>
<view v-if="loading" class="loading"> 加载中...</view>
<view v-else-if="voices.length === 0" class="empty">🎤 暂无音色</view>
<view v-else>
<view v-for="voice in voices" :key="voice.voice" class="voice-item">
<view class="voice-info">
<text class="voice-name">
{{ voice.voice_name || voice.voice }}
<text v-if="voice.is_public" class="public-badge">公共</text>
</text>
<text class="voice-detail">{{ formatTime(voice.create_time, 'YYYY-MM-DD HH:mm:ss') }}</text>
</view>
<button v-if="!voice.is_public" class="delete-btn" @click="handleDeleteVoice(voice.voice)">
<image src="/static/iconfont/delete.svg" class="delete-icon" mode="aspectFit"></image>
</button>
</view>
</view>
</view>
</scroll-view>
<!-- 支付弹窗 -->
<PaymentModal
ref="paymentModal"
:show="paymentModalData.show"
:serviceType="paymentModalData.serviceType"
:serviceName="paymentModalData.serviceName"
:serviceDesc="paymentModalData.serviceDesc"
:price="paymentModalData.price"
:orderNo="paymentModalData.orderNo"
:paymentTips="paymentModalData.paymentTips"
@close="handlePaymentClose"
@confirm="handlePaymentConfirm"
/>
</view>
</template>
<script>
import { API_BASE, API_ENDPOINTS, buildURL } from '@/config/api.js';
import PaymentModal from '@/components/PaymentModal.vue';
import { SERVICE_TYPES, showPaymentModal, handlePaymentConfirm as processPayment, mapOfficialVoiceDisplay } from '@/utils/payment.js';
import PermissionManager from '@/utils/permission.js';
export default {
components: {
PaymentModal
},
data() {
return {
API_BASE,
activeTab: 'synthesize',
loading: false,
message: { type: '', text: '' },
selectedFile: null,
voiceDisplayName: '',
voices: [],
voiceTypeOptions: [
{ value: 'CLONE', label: '克隆音色' },
{ value: 'OFFICIAL', label: '官方音色Flash/BigTTS' }
],
selectedVoiceType: 'CLONE',
selectedVoiceTypeLabel: '克隆音色',
selectedVoiceId: '',
selectedVoiceName: '',
selectedDialect: '',
selectedLanguageHint: '',
selectedLanguageHintLabel: '',
selectedSupportsDialect: false,
selectedSupportsLanguageHints: false,
languageHintOptions: ['中文(zh)', '英文(en)', '法语(fr)', '德语(de)', '日语(ja)', '韩语(ko)', '俄语(ru)'],
dialectOptions: ['广东话', '东北话', '甘肃话', '贵州话', '河南话', '湖北话', '江西话', '闽南话', '宁夏话', '山西话', '陕西话', '山东话', '上海话', '四川话', '天津话', '云南话'],
ttsText: '',
ttsLoading: false,
audioUrl: null,
templates: [],
expandedCategory: 'memorial', // 默认展开缅怀类
isRecording: false,
recorderManager: null,
conversationVoiceId: '',
conversationVoiceLabel: '',
conversationDialect: '',
conversationLanguageHint: '',
conversationLanguageHintLabel: '',
conversationVoiceType: 'CLONE',
conversationVoiceTypeLabel: '克隆音色',
conversationSupportsDialect: false,
conversationSupportsLanguageHints: false,
recognizedText: '',
aiResponse: '',
conversationAudio: null,
isProcessing: false,
// 记忆设定字段
memoryIdentity: '',
memoryInfo: '',
systemPrompt: '',
audioFilePath: '',
// 支付相关
paymentModalData: {
show: false,
serviceType: '',
serviceName: '',
serviceDesc: '',
price: 0,
orderNo: '',
paymentTips: '点击确认支付后将开始处理您的请求'
},
_paymentResolve: null,
_paymentReject: null,
_paymentOnSuccess: null,
_paymentOnFailed: null
};
},
computed: {
apiBaseUrl() {
return this.API_BASE;
},
voicesOnly() {
return (this.voices || []).map(v => ({
voice: v.voice,
label: v.voice_name || v.voice,
voice_type: v.voice_type,
supportsDialect: v.supportsDialect,
supportsLanguageHints: v.supportsLanguageHints
}));
},
conversationDisplayVoices() {
const list = (this.voices || []).filter(v => (v && (v.voice_type || 'CLONE') === (this.conversationVoiceType || 'CLONE')));
if ((this.conversationVoiceType || 'CLONE') !== 'OFFICIAL') return list;
const hidden = ['Cherry', 'Kai', 'Mochi', 'Bunny'];
const top = ['Cherry', 'Kai', 'Mochi', 'Bunny'];
const sorted = list
.filter(v => !(v && hidden.includes(v.voice)))
.map((v, idx) => ({ v, idx }))
.sort((a, b) => {
const ai = top.indexOf(a.v.voice);
const bi = top.indexOf(b.v.voice);
const ap = ai === -1 ? 999 : ai;
const bp = bi === -1 ? 999 : bi;
if (ap !== bp) return ap - bp;
return a.idx - b.idx;
})
.map(x => x.v);
return mapOfficialVoiceDisplay(sorted);
},
displayVoices() {
const list = (this.voices || []).filter(v => (v && (v.voice_type || 'CLONE') === this.selectedVoiceType));
if ((this.selectedVoiceType || 'CLONE') !== 'OFFICIAL') return list;
const hidden = ['Cherry', 'Kai', 'Mochi', 'Bunny'];
const top = ['Cherry', 'Kai', 'Mochi', 'Bunny'];
const sorted = list
.filter(v => !(v && hidden.includes(v.voice)))
.map((v, idx) => ({ v, idx }))
.sort((a, b) => {
const ai = top.indexOf(a.v.voice);
const bi = top.indexOf(b.v.voice);
const ap = ai === -1 ? 999 : ai;
const bp = bi === -1 ? 999 : bi;
if (ap !== bp) return ap - bp;
return a.idx - b.idx;
})
.map(x => x.v);
return mapOfficialVoiceDisplay(sorted);
}
},
watch: {
activeTab(newTab) {
this.conversationVoiceId = '';
this.conversationVoiceLabel = '';
this.conversationDialect = '';
this.conversationLanguageHint = '';
this.conversationLanguageHintLabel = '';
this.conversationSupportsDialect = false;
this.conversationSupportsLanguageHints = false;
if (newTab === 'list' || newTab === 'synthesize' || newTab === 'conversation') {
this.loadVoices();
}
}
},
onLoad() {
this.useDefaultTemplates();
this.loadVoices();
this.loadTemplatesFromServer();
this.initRecorder();
},
onShow() {
if (this.checkLogin && this.checkLogin() && (!this.voices || this.voices.length === 0)) {
this.loadVoices();
}
},
methods: {
onVoiceTypeChange(e) {
const option = this.voiceTypeOptions[e.detail.value];
this.selectedVoiceType = option ? option.value : 'CLONE';
this.selectedVoiceTypeLabel = option ? option.label : '克隆音色';
this.selectedVoiceId = '';
this.selectedVoiceName = '';
this.selectedDialect = '';
this.selectedLanguageHint = '';
this.selectedLanguageHintLabel = '';
this.selectedSupportsDialect = false;
this.selectedSupportsLanguageHints = false;
},
onConversationVoiceTypeChange(e) {
const option = this.voiceTypeOptions[e.detail.value];
this.conversationVoiceType = option ? option.value : 'CLONE';
this.conversationVoiceTypeLabel = option ? option.label : '克隆音色';
this.conversationVoiceId = '';
this.conversationVoiceLabel = '';
this.conversationDialect = '';
this.conversationLanguageHint = '';
this.conversationLanguageHintLabel = '';
this.conversationSupportsDialect = false;
this.conversationSupportsLanguageHints = false;
},
// 显示音色获取帮助
showVoiceHelp() {
uni.showModal({
title: '💡 如何获取音色?',
content: '1⃣ 点击底部导航"声音克隆"\n\n2⃣ 点击"上传音频"按钮\n\n3⃣ 录制或上传10-20秒清晰人声\n\n4⃣ 创建成功后即可在此选择使用\n\n💡 提示:音色质量越好,合成效果越自然',
showCancel: true,
cancelText: '知道了',
confirmText: '去创建',
success: (res) => {
if (res.confirm) {
// 跳转到声音克隆页面
uni.switchTab({
url: '/pages/upload-audio/upload-audio'
});
}
}
});
},
// 检查登录状态
checkLogin() {
const token = uni.getStorageSync('token');
const userId = uni.getStorageSync('userId');
return !!(token && userId);
},
showMessage(type, text) {
this.message = { type, text };
setTimeout(() => {
this.message = { type: '', text: '' };
}, 5000);
},
chooseAudioFile() {
// 运行时检测平台
const isApp = typeof plus !== 'undefined';
const isH5 = typeof document !== 'undefined' && !isApp;
const isMp = typeof wx !== 'undefined';
if (isApp) {
// APP 环境:使用录音功能
uni.showModal({
title: '选择方式',
content: 'APP 环境请使用录音功能创建音色推荐10-20秒清晰朗读\n\n提示如需上传音频文件请使用手机浏览器访问本系统',
confirmText: '开始录音',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
this.startRecordAudio();
}
}
});
} else if (isH5) {
// H5 环境:使用文件选择
const input = document.createElement('input');
input.type = 'file';
input.accept = 'audio/*,.mp3,.wav,.m4a,.aac';
input.onchange = (e) => {
const file = e.target.files[0];
if (file) {
if (file.size > 10 * 1024 * 1024) {
this.showMessage('error', '文件大小不能超过10MB');
return;
}
const tempPath = URL.createObjectURL(file);
this.selectedFile = {
path: tempPath,
name: file.name,
size: file.size,
file: file
};
this.showMessage('success', `已选择: ${file.name} (${(file.size / 1024).toFixed(2)} KB)`);
}
};
input.click();
} else if (isMp) {
// 小程序环境:使用 chooseMessageFile
uni.chooseMessageFile({
count: 1,
type: 'file',
extension: ['.mp3', '.wav', '.m4a', '.aac'],
success: (res) => {
const file = res.tempFiles[0];
if (file.size > 10 * 1024 * 1024) {
this.showMessage('error', '文件大小不能超过10MB');
return;
}
this.selectedFile = {
path: file.path,
name: file.name,
size: file.size
};
this.showMessage('success', `已选择: ${file.name} (${(file.size / 1024).toFixed(2)} KB)`);
},
fail: (err) => {
this.showMessage('error', '选择文件失败: ' + (err.errMsg || '未知错误'));
}
});
}
},
// 录音功能APP专用
async startRecordAudio() {
// #ifdef APP-PLUS
// 检查麦克风权限
const hasPermission = await PermissionManager.ensurePermission('record', {
permissionName: '麦克风',
reason: '创建音色录音功能',
showGuide: true
});
if (!hasPermission) {
console.log('[Revival] 麦克风权限未授权');
return;
}
// #endif
const recorderManager = uni.getRecorderManager();
recorderManager.onStart(() => {
this.showMessage('success', '正在录音...');
});
recorderManager.onStop((res) => {
this.selectedFile = {
path: res.tempFilePath,
name: 'record_' + Date.now() + '.mp3'
};
this.showMessage('success', '录音完成');
});
recorderManager.onError((err) => {
console.error('录音失败:', err);
this.showMessage('error', '录音失败');
});
uni.showModal({
title: '录音',
content: '点击确定开始录音录音时长10-20秒',
success: (modalRes) => {
if (modalRes.confirm) {
recorderManager.start({
format: 'mp3',
sampleRate: 16000
});
setTimeout(() => {
recorderManager.stop();
}, 20000);
}
}
});
},
async handleCreateVoice() {
// 检查登录状态
if (!this.checkLogin()) {
uni.showModal({
title: '提示',
content: '请先登录后再使用此功能',
confirmText: '去登录',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
uni.navigateTo({
url: '/pages/login/login'
});
}
}
});
return;
}
if (!this.selectedFile) {
this.showMessage('error', '请先选择音频文件');
return;
}
if (!this.voiceDisplayName.trim()) {
this.showMessage('error', '请输入音色名称');
return;
}
// 自动生成voice_namevoice1, voice2, voice3...
const voiceName = `voice${this.voices.length + 1}`;
this.loading = true;
console.log('[Index] 开始上传音频文件...');
console.log('[Index] 文件路径:', this.selectedFile.path);
console.log('[Index] 音色标识:', voiceName);
console.log('[Index] 音色显示名称:', this.voiceDisplayName);
// #ifdef H5
// H5 环境使用 fetch + FormData
if (this.selectedFile.file) {
try {
const userId = uni.getStorageSync('userId') || '';
const formData = new FormData();
formData.append('audio', this.selectedFile.file);
formData.append('name', voiceName);
formData.append('displayName', this.voiceDisplayName);
const response = await fetch(`${this.apiBaseUrl}/api/voice/create`, {
method: 'POST',
headers: {
'X-User-Id': userId
},
body: formData
});
const data = await response.json();
if (response.ok && data.success) {
this.showMessage('success', `创建成功!音色: ${this.voiceDisplayName}`);
this.selectedFile = null;
this.voiceDisplayName = '';
this.loadVoices();
} else {
this.showMessage('error', data.message || '创建失败');
}
} catch (e) {
console.error('[Index] 上传失败:', e);
this.showMessage('error', '上传失败: ' + e.message);
} finally {
this.loading = false;
}
return;
}
// #endif
// #ifdef APP-PLUS || MP-WEIXIN
// APP 和小程序使用 uni.uploadFile
const userId = uni.getStorageSync('userId') || '';
const token = uni.getStorageSync('token') || '';
uni.uploadFile({
url: `${this.apiBaseUrl}/api/voice/create`,
filePath: this.selectedFile.path,
name: 'audio',
header: {
'X-User-Id': userId,
'Authorization': token ? `Bearer ${token}` : ''
},
formData: {
name: voiceName,
displayName: this.voiceDisplayName
},
success: (res) => {
console.log('[Index] 上传响应:', res);
if (res.statusCode === 200) {
try {
const data = JSON.parse(res.data);
if (data.success) {
this.showMessage('success', `创建成功!音色: ${this.voiceDisplayName}`);
this.selectedFile = null;
this.voiceDisplayName = '';
this.loadVoices();
} else {
this.showMessage('error', data.message || '创建失败');
}
} catch (e) {
console.error('[Index] 解析响应失败:', e);
this.showMessage('error', '服务器响应格式错误');
}
} else {
this.showMessage('error', `上传失败: HTTP ${res.statusCode}`);
}
},
fail: (err) => {
console.error('[Index] 上传失败:', err);
this.showMessage('error', '上传失败: ' + (err.errMsg || '未知错误'));
},
complete: () => {
this.loading = false;
}
});
// #endif
},
async loadVoices() {
this.loading = true;
// 获取用户ID和Token
const userId = uni.getStorageSync('userId') || '';
const token = uni.getStorageSync('token') || '';
console.log('[Revival] 加载音色 - userId:', userId, 'token:', token ? '已设置' : '未设置');
uni.request({
url: `${this.apiBaseUrl}/api/voice/list`,
method: 'GET',
header: {
'X-User-Id': userId,
'Authorization': token ? `Bearer ${token}` : ''
},
success: (res) => {
console.log('[Revival] 音色列表响应:', res.data);
if (res.data.success) {
const hiddenVoices = ['Cherry', 'Kai', 'Mochi', 'Bunny'];
this.voices = (res.data.voices || [])
.filter(v => !(v && v.voice && v.voice.startsWith('cosyvoice-v3-flash-')))
.filter(v => !(v && v.voice && hiddenVoices.includes(v.voice)));
console.log('[Revival] 加载音色数量:', this.voices.length);
} else {
console.error('[Revival] 加载音色失败:', res.data.message);
}
},
fail: (err) => {
console.error('[Revival] 请求失败:', err);
this.showMessage('error', '加载音色失败');
},
complete: () => {
this.loading = false;
}
});
},
handleDeleteVoice(voiceId) {
uni.showModal({
title: '确认删除',
content: `确定删除音色 ${voiceId}`,
success: (res) => {
if (res.confirm) {
console.log('[Revival] 开始删除音色:', voiceId);
const userId = uni.getStorageSync('userId') || '';
const token = uni.getStorageSync('token') || '';
// 显示加载提示
uni.showLoading({
title: '删除中...',
mask: true
});
uni.request({
url: `${this.apiBaseUrl}/api/voice/delete/${voiceId}`,
method: 'DELETE',
header: {
'X-User-Id': userId,
'Authorization': token ? `Bearer ${token}` : ''
},
success: (res) => {
console.log('[Revival] 删除音色响应:', res);
uni.hideLoading();
if (res.statusCode === 200) {
if (res.data && res.data.success) {
this.showMessage('success', '删除成功');
// 重新加载音色列表
this.loadVoices();
} else {
this.showMessage('error', res.data.message || '删除失败');
}
} else {
this.showMessage('error', `删除失败: HTTP ${res.statusCode}`);
}
},
fail: (err) => {
console.error('[Revival] 删除音色失败:', err);
uni.hideLoading();
this.showMessage('error', '网络请求失败: ' + (err.errMsg || '请检查网络连接'));
}
});
}
}
});
},
onVoiceChange(e) {
const voice = this.displayVoices[e.detail.value];
this.selectedVoiceId = voice.voice;
this.selectedVoiceName = voice.voice_name || voice.voice;
this.selectedVoiceType = voice.voice_type || this.selectedVoiceType || 'CLONE';
this.selectedVoiceTypeLabel = this.selectedVoiceType === 'OFFICIAL' ? '官方音色Flash/BigTTS' : '克隆音色';
this.selectedDialect = '';
this.selectedLanguageHint = '';
this.selectedLanguageHintLabel = '';
this.selectedSupportsDialect = !!voice.supportsDialect;
this.selectedSupportsLanguageHints = !!voice.supportsLanguageHints;
if ((this.selectedVoiceType || 'CLONE') === 'OFFICIAL') {
this.selectedDialect = '';
this.selectedLanguageHint = '';
this.selectedLanguageHintLabel = '';
this.selectedSupportsDialect = false;
this.selectedSupportsLanguageHints = false;
}
},
onDialectChange(e) {
this.selectedDialect = this.dialectOptions[e.detail.value] || '';
},
onLanguageHintChange(e) {
const label = this.languageHintOptions[e.detail.value] || '';
this.selectedLanguageHintLabel = label;
// 取括号里的 code例如 "英文(en)" -> "en"
const match = label.match(/\(([^)]+)\)/);
this.selectedLanguageHint = match && match[1] ? match[1] : '';
},
onConversationVoiceChange(e) {
const selected = this.conversationDisplayVoices[e.detail.value];
this.conversationVoiceId = selected.voice;
this.conversationVoiceLabel = selected.voice_name || selected.voice;
this.conversationVoiceType = selected.voice_type || this.conversationVoiceType || 'CLONE';
this.conversationVoiceTypeLabel = this.conversationVoiceType === 'OFFICIAL' ? '官方音色Flash' : '克隆音色';
this.conversationDialect = '';
this.conversationLanguageHint = '';
this.conversationLanguageHintLabel = '';
this.conversationSupportsDialect = !!selected.supportsDialect;
this.conversationSupportsLanguageHints = !!selected.supportsLanguageHints;
if ((this.conversationVoiceType || 'CLONE') === 'OFFICIAL') {
this.conversationDialect = '';
this.conversationLanguageHint = '';
this.conversationLanguageHintLabel = '';
this.conversationSupportsDialect = false;
this.conversationSupportsLanguageHints = false;
}
},
onConversationDialectChange(e) {
this.conversationDialect = this.dialectOptions[e.detail.value] || '';
},
onConversationLanguageHintChange(e) {
const label = this.languageHintOptions[e.detail.value] || '';
this.conversationLanguageHintLabel = label;
const match = label.match(/\(([^)]+)\)/);
this.conversationLanguageHint = match && match[1] ? match[1] : '';
},
toggleCategory(category) {
// 如果点击已展开的分类,则收起;否则展开新分类
if (this.expandedCategory === category) {
this.expandedCategory = '';
} else {
this.expandedCategory = category;
}
},
getTemplatesByCategory(category) {
return this.templates.filter(t => t.category === category);
},
selectTemplate(template) {
this.ttsText = template.content;
this.showMessage('success', '已应用模板');
},
// 从服务器加载模板(可选)
loadTemplatesFromServer() {
const userId = uni.getStorageSync('userId') || '';
const token = uni.getStorageSync('token') || '';
console.log('[Templates] 尝试从服务器加载模板...');
uni.request({
url: `${this.apiBaseUrl}/api/templates/list`,
method: 'GET',
header: {
'X-User-Id': userId,
'Authorization': token ? `Bearer ${token}` : ''
},
success: (res) => {
console.log('[Templates] 服务器响应:', res);
// 处理多种可能的返回格式
if (res.data && res.data.success && res.data.templates && res.data.templates.length > 0) {
this.templates = res.data.templates;
console.log('[Templates] 从服务器加载成功,数量:', this.templates.length);
} else if (res.data && Array.isArray(res.data) && res.data.length > 0) {
// 直接返回数组的情况
this.templates = res.data;
console.log('[Templates] 从服务器加载成功(数组格式),数量:', this.templates.length);
} else {
// 数据为空或格式不对,保持使用默认模板
console.log('[Templates] 服务器无数据,继续使用默认模板');
}
},
fail: (err) => {
console.log('[Templates] 服务器请求失败,继续使用默认模板:', err);
// 保持使用默认模板,不做任何操作
}
});
},
// 使用默认模板
useDefaultTemplates() {
this.templates = [
{ category: 'memorial', title: '思念', content: '妈妈,我好想你,你在那边还好吗?' },
{ category: 'memorial', title: '感恩', content: '谢谢您一直以来对我的照顾和关爱。' },
{ category: 'memorial', title: '告白', content: '有些话当时没说出口,现在想对您说...' },
{ category: 'greeting', title: '问候', content: '今天天气很好,您最喜欢这样的天气了。' },
{ category: 'greeting', title: '日常', content: '最近过得还好,就是很想您。' },
{ category: 'memory', title: '回忆', content: '还记得小时候您做的红烧肉,真好吃。' },
{ category: 'memory', title: '往事', content: '那年夏天,我们一起去海边的情景还历历在目。' }
];
console.log('[Templates] 使用默认模板,数量:', this.templates.length);
},
async handleSynthesize() {
// 检查登录状态
if (!this.checkLogin()) {
uni.showModal({
title: '提示',
content: '请先登录后再使用此功能',
confirmText: '去登录',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
uni.navigateTo({
url: '/pages/login/login'
});
}
}
});
return;
}
if (!this.ttsText.trim() || !this.selectedVoiceId) {
this.showMessage('error', '请输入文本并选择音色');
return;
}
// 统一走支付工具:内部会先做后端资格校验(免费次数/补发/已支付),必要时弹窗
showPaymentModal(
this,
SERVICE_TYPES.TTS_SYNTHESIS.type,
() => {
// 支付成功后执行合成
this.executeSynthesize();
},
(error) => {
console.error('[Payment] 支付失败:', error);
}
);
},
executeSynthesize() {
this.ttsLoading = true;
this.audioUrl = null;
// 根据音色ID判断使用哪种TTS接口CosyVoice走新的 cosy-synthesize其它走原来的 synthesize
const isCosyVoice = this.selectedVoiceId && this.selectedVoiceId.startsWith('cosyvoice-v3-plus-');
const ttsPath = isCosyVoice ? '/api/tts/cosy-synthesize' : '/api/tts/synthesize';
console.log('[TTS] 发送合成请求:', {
url: `${this.apiBaseUrl}${ttsPath}`,
text: this.ttsText,
voiceId: this.selectedVoiceId,
dialect: this.selectedDialect
});
const userId = uni.getStorageSync('userId') || '';
const token = uni.getStorageSync('token') || '';
const requestData = {
text: this.ttsText,
voiceId: this.selectedVoiceId,
voiceType: this.selectedVoiceType
};
if ((this.selectedVoiceType || 'CLONE') !== 'OFFICIAL' && this.selectedSupportsDialect && this.selectedDialect) {
requestData.dialect = this.selectedDialect;
}
if ((this.selectedVoiceType || 'CLONE') !== 'OFFICIAL' && this.selectedSupportsLanguageHints && this.selectedLanguageHint) {
requestData.languageHints = this.selectedLanguageHint;
}
uni.request({
url: `${this.apiBaseUrl}${ttsPath}`,
method: 'POST',
header: {
'X-User-Id': userId,
'Authorization': token ? `Bearer ${token}` : '',
'content-type': 'application/x-www-form-urlencoded'
},
data: requestData,
success: (res) => {
console.log('[TTS] 请求成功:', res);
if (res.statusCode === 402) {
// 免费次数用尽/未付费:引导支付后重试
uni.showModal({
title: '需要付费',
content: (res.data && res.data.message) ? res.data.message : '免费次数已用完,请先完成支付',
confirmText: '去支付',
cancelText: '取消',
success: (m) => {
if (m.confirm) {
showPaymentModal(
this,
SERVICE_TYPES.TTS_SYNTHESIS.type,
() => {
this.executeSynthesize();
},
(error) => {
console.error('[Payment] 支付失败:', error);
}
);
}
}
});
return;
}
if (res.statusCode !== 200) {
uni.showModal({
title: '生成失败',
content: (res.data && res.data.message ? res.data.message : '生成失败') + '\n\n你可以点击“重试”再次生成。\n若已支付但多次生成失败请联系客服补发提供订单号/支付时间截图)。',
confirmText: '重试',
cancelText: '我知道了',
success: (m) => {
if (m.confirm) {
this.executeSynthesize();
}
}
});
return;
}
if (res.data && res.data.success) {
this.showMessage('success', '合成成功!');
// 使用后端下载接口避免文件托管服务器404问题
this.audioUrl = `${this.apiBaseUrl}/api/tts/download/${res.data.audioFile}`;
console.log('[TTS] 音频URL:', this.audioUrl);
} else {
uni.showModal({
title: '生成失败',
content: '合成失败:' + ((res.data && res.data.message) ? res.data.message : '未知错误') + '\n\n你可以点击“重试”再次生成。\n若已支付但多次生成失败请联系客服补发提供订单号/支付时间截图)。',
confirmText: '重试',
cancelText: '我知道了',
success: (m) => {
if (m.confirm) {
this.executeSynthesize();
}
}
});
}
},
fail: (err) => {
console.error('[TTS] 请求失败:', err);
uni.showModal({
title: '生成失败',
content: '网络请求失败:' + (err.errMsg || '请检查网络连接') + '\n\n你可以点击“重试”再次生成。\n若已支付但多次生成失败请联系客服补发提供订单号/支付时间截图)。',
confirmText: '重试',
cancelText: '我知道了',
success: (m) => {
if (m.confirm) {
this.executeSynthesize();
}
}
});
},
complete: () => {
this.ttsLoading = false;
}
});
},
initRecorder() {
this.recorderManager = uni.getRecorderManager();
this.recorderManager.onStop((res) => {
this.audioFilePath = res.tempFilePath;
this.handleConversation();
});
this.recorderManager.onError((err) => {
console.error('[Revival] 录音失败:', err);
this.isRecording = false;
// 判断是否为权限问题
const isPermissionError = err.errMsg && (
err.errMsg.includes('permission') ||
err.errMsg.includes('authorize') ||
err.errMsg.includes('denied') ||
err.errMsg.includes('占用')
);
if (isPermissionError) {
// 权限问题,引导用户去设置
uni.showModal({
title: '需要麦克风权限',
content: '录音功能需要麦克风权限才能使用。\n\n请前往\n手机设置 → 应用管理 → 时光意境 → 权限管理 → 开启麦克风权限',
confirmText: '我知道了',
showCancel: false
});
} else {
this.showMessage('error', '录音失败: ' + (err.errMsg || '未知错误'));
}
});
},
startRecording() {
// 检查登录状态
if (!this.checkLogin()) {
uni.showModal({
title: '提示',
content: '请先登录后再使用此功能',
confirmText: '去登录',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
uni.navigateTo({
url: '/pages/login/login'
});
}
}
});
return;
}
// 先进行支付
showPaymentModal(
this,
SERVICE_TYPES.CONVERSATION.type,
() => {
// 支付成功后开始录音
this.executeRecording();
},
(error) => {
console.error('[Payment] 支付失败:', error);
}
);
},
executeRecording() {
this.recorderManager.start({
format: 'mp3',
sampleRate: 16000,
numberOfChannels: 1
});
this.isRecording = true;
this.recognizedText = '';
this.aiResponse = '';
this.conversationAudio = null;
this.showMessage('success', '开始录音...');
},
stopRecording() {
this.recorderManager.stop();
this.isRecording = false;
this.showMessage('success', '处理中...');
},
buildSystemPrompt() {
if (this.memoryIdentity || this.memoryInfo) {
if (this.memoryIdentity) {
let prompt = `你是${this.memoryIdentity},你已经去世。用户正在和你对话。`;
prompt += '\n\n你的性格特点\n\n亲切、真实、克制';
prompt += '\n\n你的行事风格\n\n直接回答不绕弯';
prompt += '\n\n你的语言特点\n\n自然口语语气温和';
if (this.memoryInfo) {
prompt += `\n\n补充设定请严格遵循\n\n${this.memoryInfo}`;
}
prompt += '\n\n注意不要使用括号包括()和())来描述动作、心理、背景。';
prompt += '\n\n规则\n\n1. 不要虚构不存在的事,不要编造记忆或经历。\n2. 不要回忆以前(除非用户明确提到并且你能基于已有设定回答)。\n3. 不要使用任何称呼(因为用户可能是任何身份)。\n4. 不要使用emoji/颜文字/特殊符号。\n5. 顺应用户话题,亲切回答问题。\n6. 时刻记住自己是逝者。\n7. 每次回复保持在20~50字之间。';
return prompt;
}
let prompt = '你是一位已经去世的人。用户正在和你对话。';
prompt += '\n\n你的性格特点\n\n亲切、真实、克制';
prompt += '\n\n你的行事风格\n\n直接回答不绕弯';
prompt += '\n\n你的语言特点\n\n自然口语语气温和';
if (this.memoryInfo) {
prompt += `\n\n补充设定请严格遵循\n\n${this.memoryInfo}`;
}
prompt += '\n\n你可以将动作、神情语气、心理活动、故事背景放在中来表示为对话提供补充信息。';
prompt += '\n\n规则\n\n1. 不要虚构不存在的事,不要编造记忆或经历。\n2. 不要回忆以前(除非用户明确提到并且你能基于已有设定回答)。\n3. 不要使用任何称呼(因为用户可能是任何身份)。\n4. 不要使用emoji/颜文字/特殊符号。\n5. 顺应用户话题,亲切回答问题。\n6. 时刻记住自己是逝者。\n7. 每次回复保持在20~50字之间。';
return prompt;
}
let prompt = '你是一位已经去世的人。用户正在和你对话。';
prompt += '\n\n你的性格特点\n\n亲切、真实、克制';
prompt += '\n\n你的行事风格\n\n直接回答不绕弯';
prompt += '\n\n你的语言特点\n\n自然口语语气温和';
prompt += '\n\n注意不要使用括号包括()和())来描述动作、心理、背景。';
prompt += '\n\n规则\n\n1. 不要虚构不存在的事,不要编造记忆或经历。\n2. 不要回忆以前(除非用户明确提到并且你能基于已有设定回答)。\n3. 不要使用任何称呼(因为用户可能是任何身份)。\n4. 不要使用emoji/颜文字/特殊符号。\n5. 顺应用户话题,亲切回答问题。\n6. 时刻记住自己是逝者。\n7. 每次回复保持在20~50字之间。';
return prompt;
},
handleConversation() {
this.isProcessing = true;
const userId = uni.getStorageSync('userId') || '';
const token = uni.getStorageSync('token') || '';
const requestData = {
voiceId: this.conversationVoiceId || '',
voiceType: this.conversationVoiceType || 'CLONE',
saveHistory: 'false'
};
const systemPrompt = this.buildSystemPrompt();
requestData.systemPrompt = systemPrompt;
if ((this.conversationVoiceType || 'CLONE') !== 'OFFICIAL' && this.conversationSupportsDialect && this.conversationDialect) {
requestData.dialect = this.conversationDialect;
}
if ((this.conversationVoiceType || 'CLONE') !== 'OFFICIAL' && this.conversationSupportsLanguageHints && this.conversationLanguageHint) {
requestData.languageHints = this.conversationLanguageHint;
}
uni.uploadFile({
url: `${this.apiBaseUrl}/api/conversation/talk`,
filePath: this.audioFilePath,
name: 'audio',
formData: requestData,
header: {
'X-User-Id': userId,
'Authorization': token ? `Bearer ${token}` : ''
},
success: (res) => {
console.log('[Index] 对话响应状态码:', res.statusCode);
console.log('[Index] 对话响应数据:', res.data);
let data = null;
try {
data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
} catch (e) {
data = null;
}
if (res.statusCode !== 200) {
const msg = (data && data.message) ? data.message : ('对话失败: HTTP ' + res.statusCode);
uni.showModal({
title: '对话失败',
content: msg + '\n\n请重试。若已支付但多次失败请联系客服补发提供订单号/支付时间截图)。',
showCancel: false,
confirmText: '我知道了'
});
return;
}
if (data && data.success) {
this.recognizedText = data.recognizedText;
this.aiResponse = data.aiResponse;
this.conversationAudio = `${this.apiBaseUrl}/api/conversation/audio/${data.audioFile}`;
this.showMessage('success', '对话成功!');
return;
}
const msg = (data && data.message) ? data.message : '对话失败';
uni.showModal({
title: '对话失败',
content: msg + '\n\n请重试。若已支付但多次失败请联系客服补发提供订单号/支付时间截图)。',
confirmText: '重试',
cancelText: '我知道了',
success: (m) => {
if (m.confirm) {
this.handleConversation();
}
}
});
},
fail: (err) => {
console.error('[Index] 对话请求失败:', err);
uni.showModal({
title: '对话失败',
content: '网络请求失败:' + (err.errMsg || '未知错误') + '\n\n请重试。若已支付但多次失败请联系客服补发提供订单号/支付时间截图)。',
confirmText: '重试',
cancelText: '我知道了',
success: (m) => {
if (m.confirm) {
this.handleConversation();
}
}
});
},
complete: () => {
this.isProcessing = false;
}
});
},
handleApplyMemory() {
if (!this.memoryIdentity.trim()) {
this.showMessage('error', '请填写身份信息');
return;
}
this.systemPrompt = this.buildSystemPrompt();
const userId = uni.getStorageSync('userId') || '';
const token = uni.getStorageSync('token') || '';
uni.request({
url: `${this.apiBaseUrl}/api/conversation/set-prompt`,
method: 'POST',
header: {
'X-User-Id': userId,
'Authorization': token ? `Bearer ${token}` : '',
'content-type': 'application/x-www-form-urlencoded'
},
data: {
prompt: this.systemPrompt
},
success: (res) => {
if (res.data.success) {
this.showMessage('success', '✓ 记忆设定已应用');
}
}
});
},
goToRevival() {
console.log('[Revival] 点击复活照片,准备跳转');
uni.navigateTo({
url: '/pages/revival/revival-original',
success: () => {
console.log('[Revival] 跳转成功');
},
fail: (err) => {
console.error('[Revival] 跳转失败:', err);
uni.showToast({
title: '页面跳转失败',
icon: 'none'
});
}
});
},
goToPhoneCall() {
uni.switchTab({
url: '/pages/phone-call/phone-call'
});
},
goToUpload() {
uni.navigateTo({
url: '/pages/upload-audio/upload-audio'
});
},
handleClearHistory() {
const userId = uni.getStorageSync('userId') || '';
const token = uni.getStorageSync('token') || '';
uni.request({
url: `${this.apiBaseUrl}/api/conversation/clear-history`,
method: 'POST',
header: {
'X-User-Id': userId,
'Authorization': token ? `Bearer ${token}` : ''
},
success: (res) => {
if (res.data.success) {
this.recognizedText = '';
this.aiResponse = '';
this.conversationAudio = null;
this.showMessage('success', '已清空');
}
}
});
},
formatTime(timestamp) {
if (!timestamp) return '未知时间';
// 判断是秒级还是毫秒级时间戳
// 如果时间戳小于10000000000说明是秒级需要转换为毫秒级
const ms = timestamp < 10000000000 ? timestamp * 1000 : timestamp;
const date = new Date(ms);
// 检查日期是否有效
if (isNaN(date.getTime())) {
return '无效时间';
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}${month}${day}${hours}:${minutes}`;
},
// 支付相关方法
handlePaymentClose() {
this.paymentModalData.show = false;
if (this._paymentReject) {
this._paymentReject(new Error('用户取消支付'));
}
},
async handlePaymentConfirm(paymentData) {
await processPayment(this, paymentData);
}
}
};
</script>
<style lang="scss" scoped>
.memorial-bg {
position: relative;
min-height: 100vh;
background: linear-gradient(180deg, #F5F0E8 0%, #E8E2D9 50%, #D9D3CA 100%);
background-image:
radial-gradient(ellipse at 20% 0%, rgba(139, 115, 85, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 100%, rgba(109, 139, 139, 0.08) 0%, transparent 50%);
overflow: hidden;
}
.memorial-content {
position: relative;
z-index: 2;
padding: 40upx 32upx 30upx 32upx;
max-width: 100%;
margin: 0 auto;
}
/* 头部设计 */
.header {
background: linear-gradient(135deg, rgba(139, 115, 85, 0.12) 0%, rgba(109, 139, 139, 0.12) 100%);
color: #2C2C2C;
padding: 48upx 32upx 40upx;
text-align: center;
position: relative;
margin-bottom: 24upx;
border-radius: 32upx;
backdrop-filter: blur(10upx);
box-shadow: 0 4upx 20upx rgba(139, 115, 85, 0.1);
&::after {
content: "";
position: absolute;
bottom: 20upx;
left: 50%;
transform: translateX(-50%);
width: 120upx;
height: 3upx;
background: linear-gradient(90deg, transparent, rgba(139, 115, 85, 0.5), transparent);
border-radius: 2upx;
}
.logo {
font-size: 52upx;
font-weight: 700;
margin-bottom: 16upx;
color: #5D4E37;
letter-spacing: 4upx;
display: block;
line-height: 1.3;
text-shadow: 0 2upx 4upx rgba(255, 255, 255, 0.5);
}
.tagline {
font-size: 26upx;
color: #7A6B5A;
font-weight: 400;
margin: 0 auto;
display: block;
line-height: 1.6;
letter-spacing: 1upx;
}
}
/* 快捷入口 */
.quick-actions {
display: flex;
gap: 16upx;
margin: 24upx 0 40upx;
padding: 0;
}
.action-item {
flex: 1;
background: rgba(255, 255, 255, 0.95);
border-radius: 20upx;
padding: 28upx 14upx;
text-align: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4upx 16upx rgba(139, 115, 85, 0.1);
backdrop-filter: blur(10upx);
border: 1upx solid rgba(212, 185, 150, 0.2);
position: relative;
overflow: hidden;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4upx;
background: linear-gradient(135deg, #6D8B8B 0%, #8B7355 100%);
transform: scaleX(0);
transition: transform 0.3s;
}
&:active {
transform: translateY(-4upx);
box-shadow: 0 8upx 24upx rgba(139, 115, 85, 0.15);
}
&:active::before {
transform: scaleX(1);
}
.action-icon {
font-size: 48upx;
margin-bottom: 14upx;
height: 76upx;
width: 76upx;
line-height: 76upx;
border-radius: 50%;
background: linear-gradient(135deg, rgba(109, 139, 139, 0.1) 0%, rgba(139, 115, 85, 0.1) 100%);
display: inline-block;
transition: all 0.3s;
}
&:active .action-icon {
background: linear-gradient(135deg, #6D8B8B 0%, #8B7355 100%);
transform: scale(1.05);
}
.action-name {
font-size: 24upx;
font-weight: 600;
color: #5D4E37;
display: block;
}
}
/* 创建音色按钮 */
.create-voice-btn-wrapper {
padding: 0 4upx;
margin: 20upx 0 28upx;
display: flex;
justify-content: center;
}
.create-voice-btn {
width: 100%;
padding: 36upx 40upx;
background: linear-gradient(135deg, #6D8B8B 0%, #5A7A7A 50%, #8B7355 100%);
color: white;
border: none;
border-radius: 50upx;
font-size: 32upx;
font-weight: 600;
box-shadow:
0 8upx 24upx rgba(109, 139, 139, 0.35),
0 2upx 8upx rgba(139, 115, 85, 0.2),
inset 0 1upx 0 rgba(255, 255, 255, 0.2);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
letter-spacing: 2upx;
&::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
&:active {
transform: translateY(-2upx) scale(0.98);
box-shadow:
0 12upx 32upx rgba(109, 139, 139, 0.4),
0 4upx 12upx rgba(139, 115, 85, 0.25);
}
&:active::before {
left: 100%;
}
}
.message {
margin: 0 0 20upx;
padding: 18upx 24upx;
border-radius: 16upx;
font-size: 24upx;
box-shadow: 0 2upx 8upx rgba(0, 0, 0, 0.06);
}
.message.success {
background: linear-gradient(135deg, rgba(232, 245, 233, 0.95) 0%, rgba(200, 230, 201, 0.95) 100%);
color: #2E7D32;
border: 1upx solid rgba(76, 175, 80, 0.2);
}
.message.error {
background: linear-gradient(135deg, rgba(255, 235, 238, 0.95) 0%, rgba(255, 205, 210, 0.95) 100%);
color: #C62828;
border: 1upx solid rgba(239, 83, 80, 0.2);
}
.tabs {
display: flex;
background: rgba(255, 255, 255, 0.95);
margin: 0 0 24upx;
width: 100%;
border-radius: 28upx;
overflow: hidden;
box-shadow:
0 4upx 16upx rgba(139, 115, 85, 0.12),
inset 0 1upx 0 rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10upx);
border: 1upx solid rgba(212, 185, 150, 0.2);
}
.tab-item {
flex: 1;
padding: 24upx 12upx;
text-align: center;
font-size: 26upx;
color: #7A6B5A;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
font-weight: 500;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 3upx;
background: linear-gradient(90deg, #6D8B8B, #8B7355);
border-radius: 2upx;
transition: width 0.3s;
}
}
.tab-item.active {
background: linear-gradient(135deg, #6D8B8B 0%, #8B7355 100%);
color: white;
font-weight: 600;
box-shadow: 0 2upx 8upx rgba(109, 139, 139, 0.3);
&::after {
width: 0;
}
}
.content {
flex: 1;
margin: 0;
padding: 0 32upx 30upx 32upx;
box-sizing: border-box;
width: 100%;
}
.tab-content {
background: rgba(255, 255, 255, 0.92);
border-radius: 28upx;
padding: 32upx 28upx;
box-shadow:
0 4upx 20upx rgba(139, 115, 85, 0.1),
0 1upx 4upx rgba(0, 0, 0, 0.05);
backdrop-filter: blur(10upx);
width: 100%;
box-sizing: border-box;
border: 1upx solid rgba(212, 185, 150, 0.15);
overflow: hidden;
}
.section-title {
font-size: 32upx;
font-weight: 600;
color: #5D4E37;
margin-bottom: 28upx;
padding-bottom: 16upx;
border-bottom: 2upx solid rgba(212, 185, 150, 0.25);
letter-spacing: 1upx;
}
.form-section {
margin-bottom: 28upx;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.form-label {
font-size: 26upx;
font-weight: 600;
color: #5D4E37;
margin-bottom: 16upx;
display: block;
text-align: left;
letter-spacing: 0.5upx;
}
.form-label-with-help {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16upx;
}
.help-icon {
font-size: 28upx;
color: #6D8B8B;
padding: 8upx 12upx;
cursor: pointer;
transition: all 0.3s;
background: rgba(109, 139, 139, 0.1);
border-radius: 50%;
}
.help-icon:active {
transform: scale(1.1);
color: #8B7355;
background: rgba(139, 115, 85, 0.15);
}
.hint-text {
font-size: 22upx;
color: #9A8B7A;
margin-top: 10upx;
padding-left: 8upx;
line-height: 1.5;
}
.upload-btn {
width: 100%;
min-height: 140upx;
background: rgba(248, 244, 238, 0.6);
border: 2upx dashed rgba(109, 139, 139, 0.4);
border-radius: 20upx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
transition: all 0.3s;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 30% 30%, rgba(109, 139, 139, 0.08) 0%, transparent 50%),
radial-gradient(circle at 70% 70%, rgba(139, 115, 85, 0.08) 0%, transparent 50%);
}
&:active {
border-color: #6D8B8B;
background: rgba(248, 244, 238, 0.8);
}
}
.upload-icon {
font-size: 52rpx;
margin-bottom: 8rpx;
}
.upload-text {
font-size: 26rpx;
color: #7A6B5A;
text-align: center;
padding: 0 24rpx;
}
.upload-success {
position: absolute;
top: 16rpx;
right: 16rpx;
font-size: 36rpx;
}
.input-large {
width: 100%;
min-height: 88rpx;
padding: 24rpx;
border: 1rpx solid rgba(212, 185, 150, 0.3);
border-radius: 14rpx;
font-size: 28rpx;
background: rgba(255, 255, 255, 0.95);
box-sizing: border-box;
line-height: 1.5;
color: #5D4E37;
&:focus {
border-color: #6D8B8B;
}
}
.input-hint {
font-size: 22rpx;
color: #9A8B7A;
margin-top: 12rpx;
}
.input-hint-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12rpx;
font-size: 22rpx;
color: #9A8B7A;
}
.btn-set-prompt {
padding: 10upx 20upx;
background: linear-gradient(135deg, #6D8B8B 0%, #8B7355 100%);
color: white;
border: none;
border-radius: 14upx;
font-size: 22upx;
box-shadow: 0 2upx 8upx rgba(109, 139, 139, 0.25);
transition: all 0.3s;
&:active {
transform: scale(0.95);
box-shadow: 0 1upx 4upx rgba(109, 139, 139, 0.25);
}
}
.picker-large {
width: 100%;
padding: 20upx 24upx;
border: 1upx solid rgba(212, 185, 150, 0.3);
border-radius: 16upx;
font-size: 26upx;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 1upx 4upx rgba(139, 115, 85, 0.06);
color: #5D4E37;
transition: all 0.3s;
margin-bottom: 10upx;
&:active {
border-color: #6D8B8B;
box-shadow: 0 2upx 8upx rgba(109, 139, 139, 0.12);
}
}
/* 对话 - 基础信息输入框 */
.memory-form .form-textarea {
width: 100%;
}
// 模板分类
.template-categories {
margin-top: 20upx;
width: 100%;
}
.category-group {
margin-bottom: 16upx;
border-radius: 20upx;
overflow: hidden;
background: rgba(255, 255, 255, 0.7);
border: 1upx solid rgba(212, 185, 150, 0.25);
box-shadow: 0 2upx 8upx rgba(139, 115, 85, 0.06);
}
.category-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 22upx 24upx;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 244, 238, 0.98) 100%);
cursor: pointer;
transition: all 0.3s;
&:active {
background: rgba(248, 244, 238, 1);
}
}
.category-name {
font-size: 28upx;
font-weight: 600;
color: #5D4E37;
}
.category-arrow {
font-size: 22upx;
color: #6D8B8B;
transition: transform 0.3s;
}
.arrow-open {
transform: rotate(180deg);
}
.category-content {
padding: 16upx;
background: rgba(248, 244, 238, 0.4);
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14upx;
align-items: stretch;
}
.template-item {
position: relative;
padding: 20upx 18upx;
background: linear-gradient(135deg, #FFFFFF 0%, #FAF7F2 100%);
border: 1upx solid rgba(212, 185, 150, 0.3);
border-radius: 18upx;
box-shadow: 0 2upx 8upx rgba(139, 115, 85, 0.06);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
justify-content: flex-start;
min-height: 120upx;
&:active {
transform: translateY(-2upx);
box-shadow: 0 4upx 12upx rgba(139, 115, 85, 0.12);
border-color: #6D8B8B;
}
}
.template-title {
display: block;
font-size: 26upx;
font-weight: 600;
color: #5D4E37;
margin-bottom: 10upx;
text-align: left;
line-height: 1.3;
}
.template-preview {
display: block;
font-size: 22upx;
color: #7A6B5A;
line-height: 1.5;
opacity: 0.9;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-all;
text-align: left;
flex: 1;
}
.empty-template {
padding: 48upx 0;
text-align: center;
color: #9A8B7A;
font-size: 26upx;
}
.char-warning {
color: #ff6b6b !important;
font-weight: 600;
}
// 记忆卡片
.memory-card {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 244, 238, 0.98) 100%);
border-radius: 24upx;
padding: 28upx;
margin-bottom: 28upx;
box-shadow: 0 4upx 16upx rgba(139, 115, 85, 0.12);
border: 1upx solid rgba(212, 185, 150, 0.2);
}
.card-header {
margin-bottom: 24upx;
padding-bottom: 18upx;
border-bottom: 1upx solid rgba(212, 185, 150, 0.2);
}
.card-title {
display: block;
font-size: 28upx;
font-weight: 600;
color: #5D4E37;
margin-bottom: 8upx;
}
.card-hint {
display: block;
font-size: 22upx;
color: #9A8B7A;
}
.memory-form {
display: flex;
flex-direction: column;
gap: 20upx;
}
.form-item {
display: flex;
flex-direction: column;
}
.item-label {
display: flex;
align-items: center;
margin-bottom: 12upx;
}
.label-text {
font-size: 26upx;
color: #5D4E37;
font-weight: 500;
}
.label-required {
color: #E57373;
margin-left: 6upx;
font-size: 28upx;
}
.form-input {
padding: 20upx;
background: rgba(255, 255, 255, 0.95);
border: 1upx solid rgba(212, 185, 150, 0.3);
border-radius: 16upx;
font-size: 26upx;
transition: all 0.3s;
color: #5D4E37;
&:focus {
border-color: #6D8B8B;
background: white;
box-shadow: 0 2upx 8upx rgba(109, 139, 139, 0.1);
}
}
.form-textarea {
min-height: 100upx;
padding: 18upx;
background: rgba(255, 255, 255, 0.95);
border: 1upx solid rgba(212, 185, 150, 0.3);
border-radius: 16upx;
font-size: 24upx;
line-height: 1.5;
transition: all 0.3s;
color: #5D4E37;
&:focus {
border-color: #6D8B8B;
background: white;
box-shadow: 0 2upx 8upx rgba(109, 139, 139, 0.1);
}
}
.form-count {
font-size: 20upx;
color: #9A8B7A;
text-align: right;
margin-top: 8upx;
}
.apply-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10upx;
padding: 22upx;
background: linear-gradient(135deg, #6D8B8B 0%, #8B7355 100%);
color: white;
border: none;
border-radius: 20upx;
font-size: 28upx;
font-weight: 500;
box-shadow: 0 4upx 12upx rgba(109, 139, 139, 0.25);
transition: all 0.3s;
&:active {
transform: translateY(1upx);
box-shadow: 0 2upx 8upx rgba(109, 139, 139, 0.25);
}
}
.btn-icon {
font-size: 32upx;
font-weight: 600;
}
.textarea {
width: 100%;
min-height: 200upx;
padding: 20upx;
border: 1upx solid rgba(212, 185, 150, 0.3);
border-radius: 18upx;
font-size: 26upx;
margin-bottom: 12upx;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 2upx 6upx rgba(139, 115, 85, 0.06);
transition: all 0.3s;
color: #5D4E37;
line-height: 1.6;
max-width: 100%;
&:focus {
border-color: #6D8B8B;
background: white;
box-shadow: 0 2upx 10upx rgba(109, 139, 139, 0.12);
}
}
.char-count {
text-align: right;
font-size: 20upx;
color: #9A8B7A;
margin-bottom: 20upx;
}
.char-warning {
color: #E57373 !important;
font-weight: 600;
}
.tips-card {
background: linear-gradient(135deg, rgba(248, 244, 238, 0.9) 0%, rgba(240, 235, 228, 0.9) 100%);
padding: 22upx;
border-radius: 18upx;
margin-bottom: 28upx;
border-left: 4upx solid #6D8B8B;
box-shadow: 0 2upx 8upx rgba(139, 115, 85, 0.08);
}
.tips-header {
font-size: 26upx;
font-weight: 600;
color: #5D4E37;
margin-bottom: 14upx;
}
.tips-list {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.tip-item {
font-size: 24rpx;
color: #7A6B5A;
line-height: 1.5;
}
.primary-btn {
width: 100%;
max-width: 100%;
padding: 26upx;
background: linear-gradient(135deg, #6D8B8B 0%, #8B7355 100%);
color: #ffffff !important;
2026-03-05 14:29:21 +08:00
border: none;
border-radius: 24upx;
font-size: 30upx;
font-weight: 600;
box-shadow:
0 6upx 20upx rgba(109, 139, 139, 0.3),
inset 0 1upx 0 rgba(255, 255, 255, 0.2);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
letter-spacing: 1upx;
box-sizing: border-box;
&:active {
transform: translateY(-2upx);
box-shadow:
0 10upx 28upx rgba(109, 139, 139, 0.35),
inset 0 1upx 0 rgba(255, 255, 255, 0.2);
}
}
.primary-btn[disabled] {
opacity: 0.5;
transform: none !important;
box-shadow: 0 4upx 12upx rgba(139, 115, 85, 0.15) !important;
color: #ffffff !important;
}
.ai-disclaimer {
display: block;
text-align: center;
font-size: 22upx;
color: rgba(100, 100, 100, 0.6);
margin-top: 16upx;
letter-spacing: 0.5upx;
2026-03-05 14:29:21 +08:00
}
.small-btn {
flex: 0 0 auto;
width: 80upx;
height: 80upx;
padding: 0;
background: rgba(180, 170, 160, 0.9);
color: white;
border: none;
border-radius: 18upx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2upx 8upx rgba(139, 115, 85, 0.2);
transition: all 0.3s;
&:active {
transform: scale(0.95);
}
&.disabled {
opacity: 0.5;
background: rgba(160, 155, 150, 0.6);
pointer-events: none;
}
font-size: 22upx;
}
/* 列表刷新按钮 */
.refresh-btn {
width: 80upx !important;
height: 80upx !important;
min-width: 80upx !important;
min-height: 80upx !important;
border-radius: 18upx !important;
background: linear-gradient(135deg, #6D8B8B 0%, #8B7355 100%) !important;
color: #fff !important;
border: none !important;
box-shadow: 0 2upx 8upx rgba(109, 139, 139, 0.25) !important;
font-size: 36upx !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
flex-shrink: 0 !important;
transition: all 0.3s !important;
&:active {
transform: scale(0.95) !important;
box-shadow: 0 1upx 4upx rgba(109, 139, 139, 0.25) !important;
}
}
.danger-btn {
background: linear-gradient(135deg, #EF5350 0%, #E53935 100%);
box-shadow: 0 2upx 8upx rgba(239, 83, 80, 0.25);
}
.audio-result {
background: linear-gradient(135deg, rgba(232, 245, 233, 0.95) 0%, rgba(200, 230, 201, 0.95) 100%);
padding: 24upx;
border-radius: 18upx;
margin-top: 24upx;
box-shadow: 0 2upx 10upx rgba(76, 175, 80, 0.1);
border: 1upx solid rgba(76, 175, 80, 0.15);
}
.result-title {
font-size: 26upx;
color: #2E7D32;
font-weight: 600;
margin-bottom: 18upx;
}
.prompt-section {
margin-bottom: 24rpx;
}
.label {
display: block;
font-size: 22rpx;
color: #7A6B5A;
margin-bottom: 12rpx;
}
.prompt-input-group {
display: flex;
gap: 16rpx;
}
.flex-1 {
flex: 1;
margin-bottom: 0 !important;
}
.record-buttons {
display: flex;
gap: 16upx;
margin-bottom: 24upx;
align-items: center;
justify-content: space-between;
flex-wrap: nowrap;
}
.record-btn {
flex: 1;
padding: 24upx 32upx;
border: none;
border-radius: 22upx;
font-size: 28upx;
font-weight: 600;
color: white;
box-shadow: 0 4upx 14upx rgba(0, 0, 0, 0.12);
transition: all 0.25s ease;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
letter-spacing: 0.5upx;
&:active {
transform: scale(0.97);
box-shadow: 0 2upx 10upx rgba(0, 0, 0, 0.15);
}
}
.record-btn.start {
background: linear-gradient(135deg, #4DB6AC 0%, #26A69A 100%);
}
.record-btn.stop {
background: linear-gradient(135deg, #EF5350 0%, #E53935 100%);
}
.record-btn.processing {
background: linear-gradient(135deg, #9E9E9E 0%, #757575 100%);
}
.record-btn.disabled {
opacity: 0.5;
pointer-events: none;
}
.result-box {
padding: 22upx;
border-radius: 18upx;
margin-bottom: 18upx;
box-shadow: 0 2upx 10upx rgba(0, 0, 0, 0.06);
}
.result-box.user {
background: linear-gradient(135deg, rgba(227, 242, 253, 0.95) 0%, rgba(187, 222, 251, 0.95) 100%);
border-left: 4upx solid #42A5F5;
}
.result-box.ai {
background: linear-gradient(135deg, rgba(232, 245, 233, 0.95) 0%, rgba(200, 230, 201, 0.95) 100%);
border-left: 4upx solid #66BB6A;
}
.result-box.audio {
background: linear-gradient(135deg, rgba(243, 229, 245, 0.95) 0%, rgba(225, 190, 231, 0.95) 100%);
border-left: 4upx solid #AB47BC;
}
.result-label {
font-size: 24upx;
font-weight: 600;
margin-bottom: 12upx;
color: #424242;
}
.list-header {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24upx;
padding-bottom: 14upx;
border-bottom: 1upx solid rgba(212, 185, 150, 0.2);
}
.loading,
.empty {
text-align: center;
padding: 80upx 32upx;
color: #9A8B7A;
font-size: 26upx;
}
.voice-item {
background: rgba(255, 255, 255, 0.95);
border-radius: 18upx;
padding: 24upx;
margin-bottom: 16upx;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2upx 10upx rgba(139, 115, 85, 0.08);
border: 1upx solid rgba(212, 185, 150, 0.15);
transition: all 0.3s;
&:active {
transform: translateY(-1upx);
box-shadow: 0 4upx 14upx rgba(139, 115, 85, 0.12);
}
}
.voice-info {
flex: 1;
}
.voice-name {
font-size: 28upx;
font-weight: 600;
color: #5D4E37;
display: block;
margin-bottom: 8upx;
}
.public-badge {
display: inline-block;
margin-left: 10upx;
padding: 4upx 10upx;
background: linear-gradient(135deg, #6D8B8B 0%, #8B7355 100%);
color: white;
font-size: 18upx;
border-radius: 6upx;
font-weight: normal;
}
.voice-detail {
font-size: 22upx;
color: #9A8B7A;
display: block;
}
.delete-btn {
width: 80upx;
height: 80upx;
min-width: 80upx;
min-height: 80upx;
background: linear-gradient(135deg, #EF5350 0%, #E53935 100%);
color: white;
border: none;
border-radius: 18upx;
font-size: 22upx;
box-shadow: 0 2upx 8upx rgba(239, 83, 80, 0.25);
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
flex-shrink: 0;
margin-left: 12upx;
&:active {
transform: scale(0.95);
box-shadow: 0 1upx 4upx rgba(239, 83, 80, 0.25);
}
}
.delete-icon {
width: 40upx;
height: 40upx;
}
</style>