ai-clone/frontend-ai/pages/revival/revival.vue
2026-03-05 14:29:21 +08:00

2547 lines
69 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>
<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: white;
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;
}
.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>