2547 lines
69 KiB
Vue
2547 lines
69 KiB
Vue
|
|
<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_name(voice1, 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>
|