更新开发环境API服务地址,将主服务和新功能服务的IP地址从内网地址改为公网地址116.204.74.41,在音频资产管理中新增文本转语音功能按钮和对话框组件,在知识库文件管理中将文件名改为可点击链接支持跳转到文档详情页面

This commit is contained in:
WUSIJIAN
2026-02-03 15:43:16 +08:00
parent 30bfa8ddc3
commit 61ba18b03f
5 changed files with 846 additions and 5 deletions

View File

@@ -11,7 +11,7 @@ ENV = 'development'
# 主服务地址端口8808
# 用途: 系统管理、用户认证、权限控制、模块开通等原有功能
VITE_API_URL = 'http://192.168.3.200:8808/'
VITE_API_URL = 'http://116.204.74.41:8808/'
# 新功能服务地址端口8000
# 用途: 资产管理、分类、SKU、订单等新业务模块
VITE_NEW_API_URL = 'http://192.168.200.8000/'
VITE_NEW_API_URL = 'http://116.204.74.41.8000/'

View File

@@ -0,0 +1,358 @@
<template>
<el-dialog
v-model="visible"
title="文本转语音"
width="700px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="音频名称" prop="name">
<el-input v-model="form.name" placeholder="请输入音频名称" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="文本内容" prop="text">
<el-input
v-model="form.text"
type="textarea"
:rows="5"
placeholder="请输入要转换的文本内容"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item label="音色选择" prop="voice">
<el-select v-model="form.voice" placeholder="请选择音色" style="width: 100%">
<el-option-group label="男声">
<el-option label="商务男声" value="male_business" />
<el-option label="磁性男声" value="male_magnetic" />
<el-option label="新闻男声" value="male_news" />
</el-option-group>
<el-option-group label="女声">
<el-option label="甜美女声" value="female_sweet" />
<el-option label="知性女声" value="female_intellectual" />
<el-option label="温柔女声" value="female_gentle" />
</el-option-group>
<el-option-group label="童声">
<el-option label="活泼童声" value="child_lively" />
<el-option label="可爱童声" value="child_cute" />
</el-option-group>
</el-select>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="语速">
<el-slider v-model="form.speed" :min="0.5" :max="2" :step="0.1" :format-tooltip="(val: number) => val + 'x'" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="音量">
<el-slider v-model="form.volume" :min="0" :max="100" :format-tooltip="(val: number) => val + '%'" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="音调">
<el-slider v-model="form.pitch" :min="-12" :max="12" :step="1" :format-tooltip="(val: number) => (val > 0 ? '+' : '') + val" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="采样率">
<el-select v-model="form.sampleRate" style="width: 100%">
<el-option label="16000Hz" :value="16000" />
<el-option label="22050Hz" :value="22050" />
<el-option label="44100Hz" :value="44100" />
<el-option label="48000Hz" :value="48000" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="输出格式">
<el-radio-group v-model="form.format">
<el-radio label="mp3">MP3</el-radio>
<el-radio label="wav">WAV</el-radio>
<el-radio label="pcm">PCM</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<!-- 预览区域 -->
<div v-if="previewGenerated" class="preview-section">
<div class="preview-header">
<span class="preview-title">预览</span>
<el-tag type="success" size="small">已生成</el-tag>
</div>
<div class="preview-player">
<el-button
:type="isPlaying ? 'danger' : 'primary'"
circle
@click="togglePreview"
>
<el-icon>
<ele-VideoPlay v-if="!isPlaying" />
<ele-VideoPause v-else />
</el-icon>
</el-button>
<el-progress
:percentage="playProgress"
:show-text="false"
style="flex: 1; margin: 0 15px"
/>
<span class="duration">{{ formatDuration(previewDuration) }}</span>
</div>
<div class="preview-info">
<span>文件大小: {{ formatFileSize(previewFileSize) }}</span>
<span>时长: {{ formatDuration(previewDuration) }}</span>
<span>采样率: {{ form.sampleRate }}Hz</span>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="warning" :loading="generating" @click="handleGenerate">
<el-icon><ele-Headset /></el-icon>
{{ generating ? '生成中...' : '生成预览' }}
</el-button>
<el-button type="primary" :disabled="!previewGenerated" :loading="saving" @click="handleSave">
<el-icon><ele-Check /></el-icon>
保存音频
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import { ElMessage } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';
const emit = defineEmits(['success']);
const visible = ref(false);
const formRef = ref<FormInstance>();
const generating = ref(false);
const saving = ref(false);
const previewGenerated = ref(false);
const isPlaying = ref(false);
const playProgress = ref(0);
const previewDuration = ref(0);
const previewFileSize = ref(0);
let progressTimer: ReturnType<typeof setInterval> | null = null;
const form = reactive({
name: '',
text: '',
voice: 'female_sweet',
speed: 1.0,
volume: 80,
pitch: 0,
sampleRate: 44100,
format: 'mp3',
});
const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入音频名称', trigger: 'blur' }],
text: [
{ required: true, message: '请输入文本内容', trigger: 'blur' },
{ min: 1, max: 500, message: '文本内容长度在1-500个字符之间', trigger: 'blur' },
],
voice: [{ required: true, message: '请选择音色', trigger: 'change' }],
});
// 打开弹窗
const openDialog = () => {
visible.value = true;
resetForm();
};
// 重置表单
const resetForm = () => {
form.name = '';
form.text = '';
form.voice = 'female_sweet';
form.speed = 1.0;
form.volume = 80;
form.pitch = 0;
form.sampleRate = 44100;
form.format = 'mp3';
previewGenerated.value = false;
isPlaying.value = false;
playProgress.value = 0;
previewDuration.value = 0;
previewFileSize.value = 0;
};
// 关闭弹窗
const handleClose = () => {
stopPreview();
visible.value = false;
};
// 生成预览
const handleGenerate = async () => {
if (!formRef.value) return;
await formRef.value.validate((valid) => {
if (!valid) return;
generating.value = true;
// 模拟生成过程
setTimeout(() => {
// 根据文本长度估算时长假设每秒5个字
const textLength = form.text.length;
const baseDuration = Math.ceil(textLength / 5);
previewDuration.value = Math.max(3, Math.round(baseDuration / form.speed));
// 估算文件大小
const bitrate = form.format === 'wav' ? 1411 : form.format === 'mp3' ? 128 : 256;
previewFileSize.value = Math.round((previewDuration.value * bitrate * 1000) / 8);
previewGenerated.value = true;
generating.value = false;
ElMessage.success('语音生成成功,请试听预览');
}, 1500);
});
};
// 播放/暂停预览
const togglePreview = () => {
if (isPlaying.value) {
stopPreview();
} else {
startPreview();
}
};
// 开始播放
const startPreview = () => {
isPlaying.value = true;
playProgress.value = 0;
const totalSteps = previewDuration.value * 10;
let currentStep = 0;
progressTimer = setInterval(() => {
currentStep++;
playProgress.value = Math.round((currentStep / totalSteps) * 100);
if (playProgress.value >= 100) {
stopPreview();
}
}, 100);
};
// 停止播放
const stopPreview = () => {
isPlaying.value = false;
playProgress.value = 0;
if (progressTimer) {
clearInterval(progressTimer);
progressTimer = null;
}
};
// 保存音频
const handleSave = async () => {
saving.value = true;
// 模拟保存过程
setTimeout(() => {
saving.value = false;
ElMessage.success('音频保存成功');
emit('success', {
name: form.name,
voiceType: form.voice.split('_')[0],
duration: previewDuration.value,
fileSize: previewFileSize.value,
sampleRate: form.sampleRate,
format: form.format,
});
handleClose();
}, 1000);
};
// 格式化时长
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
// 格式化文件大小
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + 'B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB';
return (bytes / 1024 / 1024).toFixed(1) + 'MB';
};
// 监听表单变化,重置预览状态
watch(
() => [form.text, form.voice, form.speed, form.pitch],
() => {
if (previewGenerated.value) {
previewGenerated.value = false;
stopPreview();
}
}
);
defineExpose({
openDialog,
});
</script>
<style scoped lang="scss">
.preview-section {
margin-top: 20px;
padding: 16px;
background: #f5f7fa;
border-radius: 8px;
border: 1px solid #ebeef5;
.preview-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 15px;
.preview-title {
font-size: 14px;
font-weight: 600;
color: #303133;
}
}
.preview-player {
display: flex;
align-items: center;
margin-bottom: 12px;
.duration {
font-size: 12px;
color: #909399;
min-width: 40px;
}
}
.preview-info {
display: flex;
gap: 20px;
font-size: 12px;
color: #909399;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>

View File

@@ -32,6 +32,10 @@
<el-icon><ele-Plus /></el-icon>
上传音频
</el-button>
<el-button type="warning" @click="handleTts">
<el-icon><ele-Microphone /></el-icon>
文本转语音
</el-button>
</el-form-item>
</el-form>
</div>
@@ -114,12 +118,16 @@
/>
</div>
</el-card>
<!-- 文本转语音弹窗 -->
<TtsDialog ref="ttsDialogRef" @success="handleTtsSuccess" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import TtsDialog from './component/ttsDialog.vue';
interface AudioItem {
id: number;
@@ -305,10 +313,35 @@ const handleReset = () => {
getList();
};
const ttsDialogRef = ref<InstanceType<typeof TtsDialog>>();
const handleAdd = () => {
ElMessage.info('上传音频功能开发中...');
};
// 打开文本转语音弹窗
const handleTts = () => {
ttsDialogRef.value?.openDialog();
};
// 文本转语音成功回调
const handleTtsSuccess = (data: any) => {
// 添加到列表
const newItem: AudioItem = {
id: Date.now(),
name: data.name,
voiceType: data.voiceType,
duration: data.duration,
fileSize: data.fileSize,
sampleRate: data.sampleRate,
audioUrl: '',
status: 1,
createdAt: new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-'),
};
mockData.unshift(newItem);
getList();
};
const handleEdit = (row: AudioItem) => {
ElMessage.info(`编辑音频: ${row.name}`);
};

View File

@@ -0,0 +1,425 @@
<template>
<div class="document-detail-page">
<!-- 面包屑导航 -->
<div class="page-header">
<el-breadcrumb separator=">">
<el-breadcrumb-item>
<span class="back-link" @click="onBackToKnowledge">知识库</span>
</el-breadcrumb-item>
<el-breadcrumb-item>
<span class="back-link" @click="onBackToDataset">{{ datasetName }}</span>
</el-breadcrumb-item>
<el-breadcrumb-item>{{ documentInfo.name }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="page-content">
<!-- 左侧文档原文 -->
<div class="document-content">
<div class="content-header">
<h2>{{ documentInfo.name }}</h2>
<div class="content-meta">
Size{{ formatFileSize(documentInfo.fileSize) }} Uploaded Time{{ documentInfo.createdAt }}
</div>
</div>
<div class="content-body" v-loading="contentLoading">
<pre class="document-text">{{ documentContent }}</pre>
</div>
</div>
<!-- 右侧切片结果 -->
<div class="chunk-panel">
<div class="panel-header">
<h3>切片结果</h3>
<div class="panel-subtitle">查看用于嵌入和召回的切片段落</div>
</div>
<div class="panel-toolbar">
<div class="toolbar-tabs">
<el-radio-group v-model="viewMode" size="small">
<el-radio-button label="full">全文</el-radio-button>
<el-radio-button label="chunk">省略</el-radio-button>
</el-radio-group>
</div>
<div class="toolbar-actions">
<el-input
v-model="chunkSearch"
placeholder="搜索"
clearable
size="small"
style="width: 120px"
>
<template #prefix>
<el-icon><ele-Search /></el-icon>
</template>
</el-input>
<el-button size="small" @click="onAddChunk">
<el-icon><ele-Plus /></el-icon>
</el-button>
</div>
</div>
<div class="chunk-list" v-loading="chunkLoading">
<div class="select-all">
<el-checkbox v-model="selectAll" @change="onSelectAllChange">选择所有</el-checkbox>
</div>
<div
class="chunk-item"
v-for="(chunk, index) in filteredChunks"
:key="chunk.id"
>
<div class="chunk-checkbox">
<el-checkbox v-model="chunk.selected" />
</div>
<div class="chunk-content">
<span class="chunk-text">{{ viewMode === 'full' ? chunk.content : truncateText(chunk.content, 100) }}</span>
</div>
<div class="chunk-actions">
<el-switch v-model="chunk.enabled" size="small" @change="onChunkStatusChange(chunk)" />
</div>
</div>
<el-empty v-if="filteredChunks.length === 0 && !chunkLoading" description="暂无切片" :image-size="60" />
</div>
<!-- 分页 -->
<div class="panel-footer">
<span class="total-info">总共 {{ chunkTotal }} </span>
<el-pagination
v-model:current-page="chunkPage"
:page-size="chunkPageSize"
:total="chunkTotal"
layout="prev, pager, next"
small
@current-change="getChunkList"
/>
<el-select v-model="chunkPageSize" size="small" style="width: 80px" @change="getChunkList">
<el-option :value="10" label="10条/页" />
<el-option :value="20" label="20条/页" />
<el-option :value="50" label="50条/页" />
</el-select>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'documentDetail',
};
</script>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
const route = useRoute();
const router = useRouter();
// 路由参数
const datasetId = ref('');
const datasetName = ref('');
const documentId = ref('');
// 文档信息
const documentInfo = reactive({
id: '',
name: '',
fileType: '',
fileSize: 0,
createdAt: '',
});
// 文档内容
const documentContent = ref('');
const contentLoading = ref(false);
// 切片相关
const chunkLoading = ref(false);
const chunkList = ref<any[]>([]);
const chunkTotal = ref(0);
const chunkPage = ref(1);
const chunkPageSize = ref(50);
const chunkSearch = ref('');
const viewMode = ref('full');
const selectAll = ref(false);
// 过滤后的切片列表
const filteredChunks = computed(() => {
if (!chunkSearch.value) return chunkList.value;
return chunkList.value.filter(chunk =>
chunk.content.toLowerCase().includes(chunkSearch.value.toLowerCase())
);
});
// 格式化文件大小
const formatFileSize = (size: number) => {
if (!size) return '0 Bytes';
if (size < 1024) return size + ' Bytes';
if (size < 1024 * 1024) return (size / 1024).toFixed(0) + ' KB';
return (size / 1024 / 1024).toFixed(1) + ' MB';
};
// 截断文本
const truncateText = (text: string, maxLength: number) => {
if (!text || text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
};
// 返回知识库列表
const onBackToKnowledge = () => {
router.push('/knowledge');
};
// 返回数据集详情
const onBackToDataset = () => {
router.push({
path: '/knowledge',
query: { datasetId: datasetId.value, datasetName: datasetName.value }
});
};
// 获取文档详情
const getDocumentDetail = async () => {
contentLoading.value = true;
try {
// 模拟数据
documentInfo.id = documentId.value;
documentInfo.name = route.query.docName as string || '456_product(1).txt';
documentInfo.fileType = 'txt';
documentInfo.fileSize = 10;
documentInfo.createdAt = '22/01/2026 00:53:32';
// 模拟文档内容
documentContent.value = '<p>123</p>';
} catch (error) {
console.error('获取文档详情失败:', error);
} finally {
contentLoading.value = false;
}
};
// 获取切片列表
const getChunkList = async () => {
chunkLoading.value = true;
try {
// 模拟数据
chunkList.value = [
{
id: '1',
content: '123',
enabled: true,
selected: false,
},
];
chunkTotal.value = 1;
} catch (error) {
console.error('获取切片列表失败:', error);
} finally {
chunkLoading.value = false;
}
};
// 全选变化
const onSelectAllChange = (val: boolean) => {
chunkList.value.forEach(chunk => {
chunk.selected = val;
});
};
// 切片状态变化
const onChunkStatusChange = (chunk: any) => {
ElMessage.success(chunk.enabled ? '已启用' : '已禁用');
};
// 添加切片
const onAddChunk = () => {
ElMessage.info('添加切片功能开发中');
};
// 初始化
onMounted(() => {
datasetId.value = route.query.datasetId as string || '';
datasetName.value = route.query.datasetName as string || 'dataset_tenant_1';
documentId.value = route.query.docId as string || '';
getDocumentDetail();
getChunkList();
});
</script>
<style scoped lang="scss">
.document-detail-page {
height: 100%;
display: flex;
flex-direction: column;
background: #f5f7fa;
.page-header {
padding: 12px 20px;
background: #fff;
border-bottom: 1px solid #ebeef5;
.back-link {
color: #409eff;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
.page-content {
flex: 1;
display: flex;
overflow: hidden;
padding: 15px;
gap: 15px;
// 左侧文档内容
.document-content {
flex: 1;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
overflow: hidden;
.content-header {
padding: 16px 20px;
border-bottom: 1px solid #ebeef5;
h2 {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
color: #303133;
}
.content-meta {
font-size: 12px;
color: #909399;
}
}
.content-body {
flex: 1;
padding: 16px 20px;
overflow: auto;
background: #fafafa;
.document-text {
margin: 0;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.6;
color: #303133;
white-space: pre-wrap;
word-break: break-all;
}
}
}
// 右侧切片面板
.chunk-panel {
width: 420px;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid #ebeef5;
h3 {
margin: 0 0 4px 0;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.panel-subtitle {
font-size: 12px;
color: #909399;
}
}
.panel-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid #ebeef5;
.toolbar-actions {
display: flex;
gap: 8px;
}
}
.chunk-list {
flex: 1;
overflow: auto;
padding: 12px 20px;
.select-all {
margin-bottom: 12px;
}
.chunk-item {
display: flex;
align-items: flex-start;
padding: 12px;
background: #f5f7fa;
border-radius: 6px;
margin-bottom: 8px;
border: 1px solid #ebeef5;
.chunk-checkbox {
margin-right: 12px;
padding-top: 2px;
}
.chunk-content {
flex: 1;
min-width: 0;
.chunk-text {
font-size: 14px;
color: #303133;
line-height: 1.5;
word-break: break-all;
}
}
.chunk-actions {
margin-left: 12px;
flex-shrink: 0;
}
}
}
.panel-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 12px 20px;
border-top: 1px solid #ebeef5;
.total-info {
font-size: 12px;
color: #909399;
}
}
}
}
}
</style>

View File

@@ -152,11 +152,11 @@
<el-table-column type="selection" width="50" align="center" />
<el-table-column prop="name" label="名称" min-width="200" sortable>
<template #default="scope">
<div class="file-name">
<div class="file-name" @click="onViewDocumentDetail(scope.row)">
<el-icon class="file-icon" :style="{ color: getFileIconColor(scope.row.fileType) }">
<ele-Document />
</el-icon>
<span>{{ scope.row.name }}</span>
<span class="file-link">{{ scope.row.name }}</span>
</div>
</template>
</el-table-column>
@@ -334,9 +334,12 @@ export default {
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { FormInstance, FormRules, UploadFile } from 'element-plus';
const router = useRouter();
// 数据集相关
const datasetLoading = ref(false);
const datasetList = ref<any[]>([]);
@@ -585,9 +588,22 @@ const onFileStatusChange = (row: any) => {
ElMessage.success(row.enabled ? '已启用' : '已禁用');
};
// 查看文档详情
const onViewDocumentDetail = (row: any) => {
router.push({
path: '/knowledge/document/detail',
query: {
datasetId: currentDataset.value?.id,
datasetName: currentDataset.value?.name,
docId: row.id,
docName: row.name,
},
});
};
// 预览文件
const onPreviewFile = (row: any) => {
ElMessage.info(`预览文件: ${row.name}`);
onViewDocumentDetail(row);
};
// 下载文件
@@ -928,11 +944,20 @@ onMounted(() => {
.file-name {
display: flex;
align-items: center;
cursor: pointer;
.file-icon {
margin-right: 8px;
font-size: 16px;
}
.file-link {
color: #409eff;
&:hover {
text-decoration: underline;
}
}
}
}