新增 CLAUDE.md 文件以提供项目指导,添加 .claudeignore 文件以排除不必要的文件,更新 pom.xml 版本至 3.9.2,修复多个路径遍历和 SQL 注入漏洞,优化字典翻译切面逻辑,增强文件上传和下载的安全性,新增音频文件类型支持,改进动态数据源的安全校验。
This commit is contained in:
34
jeecgboot-vue3/src/views/super/airag/aivoice/AiVoice.api.ts
Normal file
34
jeecgboot-vue3/src/views/super/airag/aivoice/AiVoice.api.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defHttp } from '@/utils/http/axios';
|
||||
|
||||
enum Api {
|
||||
generate = '/airag/voice/generate',
|
||||
generateAsync = '/airag/voice/generateAsync',
|
||||
queryTask = '/airag/voice/queryTask',
|
||||
listByUser = '/airag/voice/listByUser',
|
||||
deleteVoiceRecord = '/airag/voice/deleteVoiceRecord',
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交语音生成任务(同步,保留兼容)
|
||||
*/
|
||||
export const submitVoiceTask = (params) => defHttp.post({ url: Api.generate, params }, { isTransformResponse: false });
|
||||
|
||||
/**
|
||||
* 异步提交语音生成任务,立即返回 taskId
|
||||
*/
|
||||
export const generateVoiceAsync = (params) => defHttp.post({ url: Api.generateAsync, params }, { isTransformResponse: false });
|
||||
|
||||
/**
|
||||
* 查询异步语音任务结果
|
||||
*/
|
||||
export const queryVoiceTask = (taskId: string) => defHttp.get({ url: `${Api.queryTask}/${taskId}` }, { isTransformResponse: false });
|
||||
|
||||
/**
|
||||
* 根据用户id查询语音列表
|
||||
*/
|
||||
export const getVoiceListByUser = (params: { userId: string }) => defHttp.get({ url: Api.listByUser, params },{ isTransformResponse: false });
|
||||
|
||||
/**
|
||||
* 删除语音记录
|
||||
*/
|
||||
export const deleteVoiceRecord = (params) => defHttp.delete({ url: Api.deleteVoiceRecord, params }, { isTransformResponse: false, joinParamsToUrl: true });
|
||||
117
jeecgboot-vue3/src/views/super/airag/aivoice/AiVoice.data.ts
Normal file
117
jeecgboot-vue3/src/views/super/airag/aivoice/AiVoice.data.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { FormSchema } from '@/components/Form';
|
||||
|
||||
// 左侧语音控制表单
|
||||
export const voiceFormSchemas: FormSchema[] = [
|
||||
/* {
|
||||
label: '模型',
|
||||
field: 'model',
|
||||
component: 'JDictSelectTag',
|
||||
required: true,
|
||||
defaultValue: 'voice-generation-1',
|
||||
componentProps: {
|
||||
placeholder: '请选择语音模型',
|
||||
dictCode: "airag_model where model_type = 'VOICE' and activate_flag = 1,name,id",
|
||||
},
|
||||
},*/
|
||||
{
|
||||
label: '倍速',
|
||||
field: 'speed',
|
||||
component: 'Slider',
|
||||
defaultValue: 1,
|
||||
colProps: {
|
||||
span: 24,
|
||||
},
|
||||
componentProps: {
|
||||
min: 0.25,
|
||||
max: 4,
|
||||
step: 0.1,
|
||||
marks: {
|
||||
0.5: '0.5x',
|
||||
1: '1x',
|
||||
1.5: '1.5x',
|
||||
2: '2x',
|
||||
3: '3x',
|
||||
4: '4x',
|
||||
},
|
||||
tooltip: {
|
||||
formatter: (value: number) => `${value.toFixed(1)}x`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '音量增益(dB)',
|
||||
field: 'volume',
|
||||
component: 'Slider',
|
||||
defaultValue: 0,
|
||||
colProps: {
|
||||
span: 24,
|
||||
},
|
||||
componentProps: {
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
marks: {
|
||||
'-10': '-10',
|
||||
0: '0',
|
||||
10: '10',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '声色',
|
||||
field: 'voice',
|
||||
component: 'Select',
|
||||
required: true,
|
||||
defaultValue: 'tongtong',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '彤彤', value: 'tongtong' },
|
||||
{ label: '锤锤', value: 'chuichui' },
|
||||
{ label: '小陈', value: 'xiaochen' },
|
||||
{ label: 'Jam', value: 'jam' },
|
||||
{ label: 'Kazi', value: 'kazi' },
|
||||
{ label: 'Douji', value: 'douji' },
|
||||
{ label: 'Luodo', value: 'luodo' },
|
||||
],
|
||||
placeholder: '请选择声色',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '文案',
|
||||
field: 'text',
|
||||
component: 'InputTextArea',
|
||||
required: true,
|
||||
colProps: {
|
||||
span: 24,
|
||||
},
|
||||
componentProps: {
|
||||
rows: 6,
|
||||
placeholder: '请输入要合成的文案内容',
|
||||
maxlength: 500,
|
||||
showCount: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 历史记录表格列配置
|
||||
*/
|
||||
export const historyColumns = [
|
||||
{
|
||||
title: '文案',
|
||||
dataIndex: 'text',
|
||||
width: 100,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
width: 100,
|
||||
fixed: 'right',
|
||||
},
|
||||
];
|
||||
382
jeecgboot-vue3/src/views/super/airag/aivoice/AiVoice.less
Normal file
382
jeecgboot-vue3/src/views/super/airag/aivoice/AiVoice.less
Normal file
@@ -0,0 +1,382 @@
|
||||
// AI Voice 样式文件
|
||||
.ai-voice-page {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
background-color: #f0f2f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 16px;
|
||||
background: #fff;
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #8f959e;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
width: 300px;
|
||||
min-width: 260px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
|
||||
.form-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 中间区域:试听 + 文案 + 常用场景 + 生成按钮
|
||||
.middle-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
|
||||
.preview-panel {
|
||||
flex: 0 0 auto;
|
||||
height: 300px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
|
||||
.panel-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
margin-bottom: 16px;
|
||||
padding-left: 8px;
|
||||
border-left: 4px solid #1890ff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
background: #f7f8fc;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.audio-info-section {
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: 12px;
|
||||
|
||||
.current-audio-info {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
.info-label {
|
||||
font-size: 13px;
|
||||
color: #8f959e;
|
||||
flex-shrink: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 13px;
|
||||
color: #1f2329;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-section {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.form-item-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.form-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
:deep(.ant-input-textarea) {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.preset-group {
|
||||
.preset-label {
|
||||
font-size: 13px;
|
||||
color: #8f959e;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.preset-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preset-item {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #1890ff;
|
||||
background: #fff;
|
||||
color: #1890ff;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn-group {
|
||||
margin-top: auto;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.history-panel {
|
||||
width: 400px;
|
||||
min-width: 280px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
|
||||
.history-list-wrapper {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-history {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #b2b8c6;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.history-item {
|
||||
padding: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
background: #fafbfc;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f9ff;
|
||||
border-color: #d9e8f7;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 8px;
|
||||
gap: 8px;
|
||||
|
||||
.item-title {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: #1f2329;
|
||||
font-weight: 500;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.item-time {
|
||||
font-size: 10px;
|
||||
color: #b2b8c6;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: flex-end;
|
||||
|
||||
:deep(.ant-btn) {
|
||||
padding: 0 4px;
|
||||
height: auto;
|
||||
min-width: auto;
|
||||
font-size: 12px;
|
||||
|
||||
&.ant-btn-text {
|
||||
color: #1890ff;
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-btn-dangerous.ant-btn-text {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
color: #ff7875;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
margin-bottom: 20px;
|
||||
padding-left: 8px;
|
||||
border-left: 4px solid #1890ff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #8f959e;
|
||||
|
||||
p {
|
||||
margin-top: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tip {
|
||||
font-size: 12px;
|
||||
color: #b2b8c6;
|
||||
}
|
||||
}
|
||||
|
||||
.audio-player-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
332
jeecgboot-vue3/src/views/super/airag/aivoice/AiVoice.vue
Normal file
332
jeecgboot-vue3/src/views/super/airag/aivoice/AiVoice.vue
Normal file
@@ -0,0 +1,332 @@
|
||||
<template>
|
||||
<div class="ai-voice-page">
|
||||
<!-- 头部区域 -->
|
||||
<div class="page-header">
|
||||
<div class="title">AI语音</div>
|
||||
<div class="subtitle">将文本快速转换为自然流畅的语音</div>
|
||||
</div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<!-- 左侧语音控制 -->
|
||||
<div class="control-panel">
|
||||
<div class="panel-title">语音控制</div>
|
||||
<div class="form-container">
|
||||
<BasicForm @register="registerForm" :schemas="leftFormSchemas" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间区域:试听 + 文案 + 常用场景 + 生成按钮 -->
|
||||
<div class="middle-wrapper">
|
||||
<!-- 试听区域 -->
|
||||
<div class="preview-panel">
|
||||
<div class="panel-title">试听区域</div>
|
||||
<div class="preview-content">
|
||||
<div v-if="!currentAudioUrl" class="empty-state">
|
||||
<Icon icon="ant-design:customer-service-outlined" size="72" color="#c0c4cc" />
|
||||
<p>填写下方文案并点击「开始合成」</p>
|
||||
<p class="tip">支持调整倍速、音量增益和声色,生成更加个性化的语音效果</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="audio-player-wrapper">
|
||||
<audio ref="audioRef" :src="currentAudioUrl" controls class="audio-control" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 当前播放音频信息 -->
|
||||
<div v-if="currentAudioUrl" class="audio-info-section">
|
||||
<div class="current-audio-info">
|
||||
<span class="info-label">当前语音:</span>
|
||||
<span class="info-text">{{ currentText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文案输入和常用场景 -->
|
||||
<div class="input-section">
|
||||
<!-- 文案输入框 -->
|
||||
<div class="form-item-group">
|
||||
<label class="form-label">文案</label>
|
||||
<a-textarea v-model:value="formText" :rows="4" :maxlength="500" show-count placeholder="请输入要合成的文案内容" />
|
||||
</div>
|
||||
|
||||
<!-- 常用场景 -->
|
||||
<div class="preset-group">
|
||||
<div class="preset-label">常用场景</div>
|
||||
<div class="preset-items">
|
||||
<div v-for="item in presetTexts" :key="item" class="preset-item" @click.prevent="handleApplyPreset(item)">
|
||||
{{ item }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 开始合成按钮 -->
|
||||
<div class="action-btn-group">
|
||||
<a-button type="primary" size="large" block :loading="generating" @click="handleSynthesize">
|
||||
<Icon icon="ant-design:sound-outlined" />
|
||||
开始合成
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧生成历史 - 列表风格 -->
|
||||
<div class="history-panel">
|
||||
<div class="panel-title">生成历史</div>
|
||||
<div class="history-list-wrapper">
|
||||
<div v-if="historyList.length === 0" class="empty-history">
|
||||
<p>暂无生成历史</p>
|
||||
</div>
|
||||
<div v-else class="history-list">
|
||||
<div v-for="item in historyList" :key="item.id" class="history-item">
|
||||
<div class="item-header">
|
||||
<span class="item-title" :title="item.content">{{ item.content }}</span>
|
||||
<span class="item-time">{{ item.createTime }}</span>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<a-button type="text" size="small" @click="handlePlay(item)">
|
||||
<Icon icon="ant-design:sound-outlined" />
|
||||
播放
|
||||
</a-button>
|
||||
<a-button type="text" size="small" @click="handleUseText(item)">
|
||||
<Icon icon="ant-design:copy-outlined" />
|
||||
复用文案
|
||||
</a-button>
|
||||
<a-button type="text" size="small" @click="handleDownload(item)">
|
||||
<Icon icon="ant-design:download-outlined" />
|
||||
下载
|
||||
</a-button>
|
||||
<a-button type="text" size="small" danger @click="handleDelete(item.id)">
|
||||
<Icon icon="ant-design:delete-outlined" />
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { BasicForm, useForm } from '@/components/Form';
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { useMessage } from '@/hooks/web/useMessage';
|
||||
import { voiceFormSchemas } from './AiVoice.data';
|
||||
import { generateVoiceAsync, queryVoiceTask, getVoiceListByUser, deleteVoiceRecord } from '@/views/super/airag/aivoice/AiVoice.api';
|
||||
import { getFileAccessHttpUrl } from '@/utils/common/compUtils';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
|
||||
const TASK_ID_KEY = 'ai_voice_task_id';
|
||||
|
||||
const { createMessage } = useMessage();
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 左侧表单:只包含模型、倍速、音量、声色,不包含文案
|
||||
const leftFormSchemas = voiceFormSchemas.filter((item) => !['text'].includes(item.field));
|
||||
|
||||
const [registerForm, { validate }] = useForm({
|
||||
schemas: leftFormSchemas,
|
||||
showActionButtonGroup: false,
|
||||
wrapperCol: { span: 24 },
|
||||
labelCol: { span: 24 },
|
||||
});
|
||||
|
||||
const generating = ref(false);
|
||||
const audioRef = ref<HTMLAudioElement | null>(null);
|
||||
const currentAudioUrl = ref<string>('');
|
||||
const currentText = ref<string>('');
|
||||
const formText = ref<string>(''); // 文案输入框的独立状态
|
||||
const historyList = ref<any[]>([]);
|
||||
const isPresetApplying = ref(false); // 防抖标志
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/**
|
||||
* 根据当前用户id加载语音列表
|
||||
*/
|
||||
async function loadVoiceList() {
|
||||
try {
|
||||
const userId = userStore.getUserInfo?.id;
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
await getVoiceListByUser({ userId }).then((res) =>{
|
||||
if(res && res.result){
|
||||
for (const rs of res.result) {
|
||||
if (rs.voiceUrl) {
|
||||
rs.audioUrl = getFileAccessHttpUrl(rs.voiceUrl) || '';
|
||||
}
|
||||
}
|
||||
historyList.value = res.result || [];
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时获取历史列表
|
||||
loadVoiceList();
|
||||
|
||||
const presetTexts = [
|
||||
'欢迎来到我们的平台,祝您使用愉快!',
|
||||
'今天的天气非常好,适合出门散步。',
|
||||
'尊敬的客户,您的订单已发货,请注意查收。',
|
||||
'Welcome to our platform, we hope you enjoy the experience!',
|
||||
];
|
||||
|
||||
/**
|
||||
* 应用预设文案
|
||||
*/
|
||||
function handleApplyPreset(text: string) {
|
||||
if (isPresetApplying.value) return;
|
||||
|
||||
isPresetApplying.value = true;
|
||||
formText.value = text;
|
||||
|
||||
// 300ms 后取消防抖标志
|
||||
setTimeout(() => {
|
||||
isPresetApplying.value = false;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始合成(异步轮询模式)
|
||||
*/
|
||||
async function handleSynthesize() {
|
||||
try {
|
||||
const values = await validate();
|
||||
|
||||
if (!formText.value.trim()) {
|
||||
createMessage.warn('请输入文案内容');
|
||||
return;
|
||||
}
|
||||
|
||||
generating.value = true;
|
||||
currentAudioUrl.value = '';
|
||||
values.content = formText.value.trim();
|
||||
|
||||
//update-begin---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
const res = await generateVoiceAsync(values);
|
||||
if (res.success && res.result) {
|
||||
const taskId = res.result as string;
|
||||
localStorage.setItem(TASK_ID_KEY, taskId);
|
||||
startPolling(taskId);
|
||||
} else {
|
||||
generating.value = false;
|
||||
createMessage.error(res.message || '提交任务失败');
|
||||
}
|
||||
} catch (e) {
|
||||
generating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 轮询查询语音任务结果 */
|
||||
function startPolling(taskId: string) {
|
||||
const poll = () => {
|
||||
queryVoiceTask(taskId)
|
||||
//update-end---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
//update-begin---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
if (res.result === 'pending' || res.result === null) {
|
||||
pollTimer = setTimeout(poll, 3000);
|
||||
} else {
|
||||
generating.value = false;
|
||||
localStorage.removeItem(TASK_ID_KEY);
|
||||
currentAudioUrl.value = getFileAccessHttpUrl(res.result.voiceUrl) || '';
|
||||
currentText.value = formText.value;
|
||||
createMessage.success('语音合成完成');
|
||||
loadVoiceList();
|
||||
}
|
||||
} else {
|
||||
generating.value = false;
|
||||
localStorage.removeItem(TASK_ID_KEY);
|
||||
createMessage.error(res.message || '语音合成失败');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
pollTimer = setTimeout(poll, 3000);
|
||||
});
|
||||
};
|
||||
poll();
|
||||
//update-end---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
}
|
||||
|
||||
//update-begin---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
onMounted(() => {
|
||||
const savedTaskId = localStorage.getItem(TASK_ID_KEY);
|
||||
if (savedTaskId) {
|
||||
generating.value = true;
|
||||
currentAudioUrl.value = '';
|
||||
startPolling(savedTaskId);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
});
|
||||
//update-end---wangshuai---date:20260415 for:[QQYUN-14944]AI 改成异步的,支持切换菜单------------
|
||||
|
||||
/**
|
||||
* 从历史记录中播放
|
||||
*/
|
||||
function handlePlay(record: any) {
|
||||
currentAudioUrl.value = getFileAccessHttpUrl(record.voiceUrl) || '';
|
||||
currentText.value = record.content;
|
||||
setTimeout(() => {
|
||||
(audioRef.value as any)?.play?.();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将历史记录的文案回填到文案输入框
|
||||
*/
|
||||
function handleUseText(record: any) {
|
||||
formText.value = record.content;
|
||||
createMessage.success('已将文案填入输入框');
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载语音文件
|
||||
*/
|
||||
function handleDownload(record: any) {
|
||||
const url = getFileAccessHttpUrl(record.voiceUrl);
|
||||
if (!url) {
|
||||
createMessage.error('下载地址不存在');
|
||||
return;
|
||||
}
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = record.fileName || 'voice.wav';
|
||||
link.target = '_blank';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除历史记录
|
||||
*/
|
||||
async function handleDelete(id: string) {
|
||||
const userId = userStore.getUserInfo?.id;
|
||||
if (!userId) return;
|
||||
try {
|
||||
const res = await deleteVoiceRecord({ userId: userId, recordId: id });
|
||||
if (res.success) {
|
||||
createMessage.success('已删除');
|
||||
loadVoiceList();
|
||||
} else {
|
||||
createMessage.error(res.message || '删除失败');
|
||||
}
|
||||
} catch (e) {
|
||||
createMessage.error('删除失败');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import './AiVoice.less';
|
||||
</style>
|
||||
Reference in New Issue
Block a user