Files
museum_admin/src/components/MarkdownEditor.vue

886 lines
28 KiB
Vue
Raw Normal View History

<template>
<el-dialog
title="Markdown 文件与TTS编辑"
v-model="dialogVisible"
width="85%"
align-center
:before-close="handleDialogClose"
>
<div class="input-section">
<div class="form-group">
<el-row>
<el-col :span="24" style="border: 1px solid lightgray; padding: 10px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
<span style="font-weight: bold;">展品ID: {{formData.id}} - {{formData.label}}</span>
<div style="display: flex; gap: 10px; align-items: center;">
<span>Markdown文件地址:</span>
<a v-if="formData.md_file_url" :href="formData.md_file_url" target="_blank" style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">
{{formData.md_file_url}}
</a>
<span v-else style="color: #999;">未上传</span>
</div>
</div>
<!-- Markdown 文件上传区域 -->
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 20px; padding: 10px; background: #f5f7fa; border-radius: 4px;">
<el-upload
ref="uploadMdRef"
action=""
:auto-upload="false"
:limit="1"
:on-change="handleMdFileChange"
:on-exceed="handleMdFileExceed"
:show-file-list="false"
accept=".md,.markdown,.txt">
<template #trigger>
<el-button type="primary" size="small">选择Markdown文件</el-button>
</template>
</el-upload>
<span v-if="mdFile" style="color: #67C23A;">{{mdFile.name}}</span>
<el-button
size="small"
type="success"
:disabled="!mdFile"
@click="submitMdFileUpload">
上传并更新
</el-button>
<el-button
size="small"
type="danger"
:disabled="!formData.md_file_url"
@click="removeMdFileUrl">
删除Markdown地址
</el-button>
<el-button
size="small"
type="info"
:disabled="!formData.md_file_url"
@click="loadMdContent">
读取Markdown内容
</el-button>
<el-button
size="small"
type="warning"
:disabled="!formData.md_file_url || !mdContent || mdContent.length === 0"
@click="saveMdContent">
保存修改到MinIO
</el-button>
</div>
<!-- Markdown 内容编辑区域 -->
<div style="margin-bottom: 20px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
<span style="font-weight: bold;">Markdown 内容编辑:</span>
<span style="color: #909399; font-size: 12px;">字符数: {{mdContent.length}}</span>
</div>
<textarea
v-model="mdContent"
rows="15"
placeholder="请先上传Markdown文件或读取现有内容..."
style="width: 100%; font-family: monospace; padding: 10px; border: 1px solid #dcdfe6; border-radius: 4px;">
</textarea>
</div>
</el-col>
</el-row>
</div>
<!-- TTS 音频生成与上传区域 -->
<div style="display: flex; align-items: center; justify-content: space-between; color: blue; margin: 10px 0;">
<div>Markdown TTS 音频</div>
</div>
<el-row v-loading="isTTSGenerating" element-loading-text="音频生成中...">
<el-col :span="24" style="border: 1px solid gray; padding: 15px;">
<div style="display: flex; flex-direction: row; align-items: center; justify-content: flex-start; margin-bottom: 15px;">
<span style="margin-right: 20px; display: inline-block; width: 200px;">TTS音频地址:</span>
<span style="display: inline-block; flex: 1;">
<a v-if="formData.md_tts_url" :href="formData.md_tts_url" target="_blank">{{formData.md_tts_url}}</a>
<span v-else style="color: #999;">未生成</span>
</span>
<el-button
type="danger"
size="default"
:disabled="!formData.md_tts_url"
@click="removeMdTtsUrl">
删除音频地址
</el-button>
</div>
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 15px;">
<div style="display: flex; justify-content: flex-start; align-items: center;">
<label>音色选择</label>
<el-select
v-model="voiceSelected_md"
clearable
placeholder="请选择音色"
@change="voiceSelectedChangeMd"
allow-create
default-first-option
style="width: 140px;">
<el-option
v-for="item in voiceOptions"
:key="item.value"
:label="item.label"
:value="item.value">
</el-option>
</el-select>
</div>
<el-button
type="primary"
:disabled="!mdContent || mdContent.length === 0"
@click="generateMdTTS">
生成语音
</el-button>
<audio class="audio-player" ref="audioPlayerMd" :src="preview.audioSrc_md" controls></audio>
<el-button
type="primary"
:disabled="!preview.downReady_md"
@click="downloadMdTTS">
下载语音
</el-button>
</div>
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 15px;">
<div style="width: 90%; display: flex; justify-content: flex-start; align-items: center;">
<span>下载文件名</span>
<el-input v-model="downloadFilename_md"/>
</div>
<div style="display: flex; justify-content: flex-start; align-items: center;">
<span style="display: inline-block; width: 100px;">{{downloadPromptMd}}</span>
</div>
</div>
<!-- 上传区域 -->
<div class="upload-section">
<el-row v-show="preview.visible_md" align="middle" style="height: 5vh">
<el-col :span="14">
<div style="margin-left: 10px; width: 100%; height: 5vh; display: flex; align-items: center;">
<el-upload
action=""
:auto-upload="false"
:on-change="handleUploadChangeMd"
:show-file-list="false"
style="display: flex; align-items: center">
<template #trigger>
<el-button type="primary">选取文件</el-button>
</template>
<div style="display: inline-block; width: 200px;">{{uploadFileName_md}}</div>
</el-upload>
</div>
</el-col>
<el-col :span="8">
<el-button
:disabled="!uploadFile_md"
style="width: 90%;"
size="default"
type="success"
@click="submitMdTTSUpload">
上传并修改音频地址
</el-button>
</el-col>
</el-row>
</div>
</el-col>
</el-row>
</div>
<!-- 结果展示 -->
<div class="result-section">
<div v-if="result.error" class="error-message">
错误{{ result.error }}
</div>
<div v-if="result.success" class="success-message">
<p>操作成功</p>
</div>
</div>
</el-dialog>
</template>
<script>
import {
BASE_API_URL,
WEIXIN_WS_URL,
chatId,
exhibit_tts_bucket_name,
exhibit_md_bucket_name,
exhibit_photo_bucket_name
} from "@/API/api";
export default {
name: "MarkdownEditor",
props: {
'mesumSelectedId': {default: -1},
'mesumSelected': {},
'initData': {},
'visible': {default: false},
'photo_prefix': {default: 'temp'}
},
inject: {
'root': {default: null},
'getProject': {default: null},
'getSchedule': {default: null}
},
data() {
return {
formData: {
mesum_id: 1,
id: "",
label: "",
md_file_url: "",
md_tts_url: ""
},
mdFile: null,
mdContent: "",
preview: {
visible_md: false,
downReady_md: false,
audioSrc_md: '',
tempBlob_md: null
},
uploadFile_md: null,
result: {
error: null,
success: false,
minioUrl: ''
},
isTTSGenerating: false,
voiceSelected_md: "sambert-zhichu-v1@Tongyi-Qianwen",
downloadFilename_md: "md_tts.mp3",
voiceOptions: [
{"value": "cosyvoice-v1/longyuan@Tongyi-Qianwen", "label": "亲切女生"},
{"value": "sambert-zhiru-v1@Tongyi-Qianwen", "label": "新闻女生"},
{"value": "cosyvoice-v1/longhua@Tongyi-Qianwen", "label": "活泼女童"},
{"value": "cosyvoice-v1/longxiang@Tongyi-Qianwen", "label": "解说男声"},
{"value": "cosyvoice-v1/longyue@Tongyi-Qianwen", "label": "longyue"},
{"value": "cosyvoice-v1/longwan@Tongyi-Qianwen", "label": "longwan"},
{"value": "sambert-zhichu-v1@Tongyi-Qianwen", "label": "舌尖男声"},
{"value": "sambert-zhiying-v1@Tongyi-Qianwen", "label": "软萌童声"},
],
dialogVisible: false,
downloadStateMd: 'idle',
};
},
created() {
},
mounted() {
},
methods: {
// Markdown 文件选择
handleMdFileChange(file) {
this.mdFile = file.raw;
console.log("选择的Markdown文件:", file.name);
},
handleMdFileExceed(files) {
this.$refs.uploadMdRef.clearFiles();
const newFile = files[0];
this.$refs.uploadMdRef.handleStart(newFile);
this.handleMdFileChange({raw: newFile, name: newFile.name});
},
// 上传 Markdown 文件到 MinIO
async submitMdFileUpload() {
if (!this.mdFile) {
this.$message.warning('请先选择Markdown文件');
return;
}
try {
// 如果已有MD文件先删除旧的MinIO对象
await this.removeMdFileFromMinio(this.formData.md_file_url);
// 上传新文件
const formData = new FormData();
formData.append('file', this.mdFile);
// 使用原始文件名,不重命名
const fileName = this.mdFile.name;
formData.append('bucket', exhibit_md_bucket_name); // 使用专门的bucket存放markdown
formData.append('file_name', `${this.photo_prefix}/category/${fileName}`);
const response = await fetch(`${BASE_API_URL}/minio/put`, {
method: 'POST',
body: formData,
headers: {
"Authorization": "Bearer ragflow-NhZTY5Y2M4YWQ1MzExZWY4Zjc3MDI0Mm"
}
});
if (!response.ok) {
throw new Error('上传失败');
}
const res = await response.json();
const {data} = res;
if (data?.put) {
// 更新数据库
await this.updateAntiqueMdFile(data?.url);
this.$message.success('Markdown文件上传成功');
this.mdFile = null;
if (this.$refs.uploadMdRef) {
this.$refs.uploadMdRef.clearFiles();
}
}
} catch (error) {
console.error('上传Markdown文件失败:', error);
this.$message.error('上传Markdown文件失败');
}
},
// 读取 Markdown 文件内容
async loadMdContent() {
if (!this.formData.md_file_url) {
this.$message.warning('没有Markdown文件URL');
return;
}
try {
// 添加时间戳参数避免浏览器缓存
const url = this.formData.md_file_url + '?t=' + Date.now();
const response = await fetch(url);
if (!response.ok) {
throw new Error('读取文件失败');
}
const content = await response.text();
this.mdContent = content;
this.$message.success('Markdown内容加载成功');
} catch (error) {
console.error('读取Markdown内容失败:', error);
this.$message.error('读取Markdown内容失败');
}
},
// 保存 Markdown 内容修改到 MinIO
async saveMdContent() {
if (!this.formData.md_file_url) {
this.$message.warning('没有Markdown文件URL');
return;
}
if (!this.mdContent || this.mdContent.length === 0) {
this.$message.warning('Markdown内容为空');
return;
}
try {
const loading = this.$loading({
lock: true,
text: '正在保存Markdown内容到MinIO...',
background: 'rgba(0, 0, 0, 0.7)'
});
// 从URL中解析出bucket和文件名
let bucket = exhibit_md_bucket_name;
let fileName = '';
const urlParts = this.formData.md_file_url.split(`${exhibit_md_bucket_name}/`);
if (urlParts.length > 1) {
fileName = urlParts[1];
} else {
// 兼容旧的photo bucket格式
const photoUrlParts = this.formData.md_file_url.split(`${exhibit_photo_bucket_name}/`);
if (photoUrlParts.length > 1) {
bucket = exhibit_photo_bucket_name;
fileName = photoUrlParts[1];
} else {
throw new Error('无法解析文件路径');
}
}
// 将Markdown内容转换为Blob
const blob = new Blob([this.mdContent], { type: 'text/markdown' });
const file = new File([blob], fileName.split('/').pop() || 'content.md', { type: 'text/markdown' });
// 先删除旧文件根据经验记忆某些MinIO版本需要先删除再上传
await this.removeMinioObject(bucket, fileName);
// 上传新内容
const formData = new FormData();
formData.append('file', file);
formData.append('bucket', bucket);
formData.append('file_name', fileName);
const response = await fetch(`${BASE_API_URL}/minio/put`, {
method: 'POST',
body: formData,
headers: {
"Authorization": "Bearer ragflow-NhZTY5Y2M4YWQ1MzExZWY4Zjc3MDI0Mm"
}
});
if (!response.ok) {
throw new Error('上传失败');
}
const res = await response.json();
const {data} = res;
if (data?.put) {
// 验证上传是否成功:下载文件并检查内容
const verifyResponse = await fetch(this.formData.md_file_url + '?t=' + Date.now());
if (verifyResponse.ok) {
const verifyContent = await verifyResponse.text();
if (verifyContent === this.mdContent) {
loading.close();
this.$message.success('Markdown内容已成功保存到MinIO并验证通过');
} else {
loading.close();
this.$message.warning('Markdown内容已上传但验证发现内容可能未完全更新请刷新后重试');
}
} else {
loading.close();
this.$message.success('Markdown内容已保存到MinIO');
}
} else {
loading.close();
this.$message.error('保存失败:服务器返回失败状态');
}
} catch (error) {
console.error('保存Markdown内容到MinIO失败:', error);
this.$message.error('保存Markdown内容失败: ' + error.message);
}
},
// 从MD文件URL中删除MinIO对象独立函数
async removeMdFileFromMinio(md_file_url) {
if (!md_file_url || md_file_url === '') {
return;
}
const urlParts = md_file_url.split(`${exhibit_md_bucket_name}/`);
if (urlParts.length > 1) {
const fileName = urlParts[1];
if (fileName && fileName !== "") {
await this.removeMinioObject(exhibit_md_bucket_name, fileName);
console.log('已删除Markdown文件:', fileName);
}
} else {
// 兼容旧的URL格式或其他bucket
const photoUrlParts = md_file_url.split(`${exhibit_photo_bucket_name}/`);
if (photoUrlParts.length > 1) {
const fileName = photoUrlParts[1];
if (fileName && fileName !== "") {
await this.removeMinioObject(exhibit_photo_bucket_name, fileName);
console.log('已删除Markdown文件(photo bucket):', fileName);
}
}
}
},
// 删除 Markdown 文件URL
async removeMdFileUrl() {
this.$confirm('确认删除Markdown文件地址')
.then(async () => {
// 调用独立的删除函数
await this.removeMdFileFromMinio(this.formData.md_file_url);
await this.updateAntiqueMdFile("");
this.mdContent = "";
this.$message.success('删除Markdown文件地址成功');
}).catch(() => {});
},
// 更新数据库中的 md_file_url
async updateAntiqueMdFile(md_file_url) {
let antique_data = {
mesum_id: this.mesumSelectedId,
md_file_url: md_file_url
};
const response = await fetch(`${BASE_API_URL}/mesum/antique/update/${this.formData.mesum_id}/${this.formData.id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ragflow-NhZTY5Y2M4YWQ1MzExZWY4Zjc3MDI0Mm'
},
body: JSON.stringify(antique_data)
});
const result = await response.json();
const {data} = result;
if (data && data.update) {
this.formData.md_file_url = md_file_url;
this.triggerRefreshAntiques();
return true;
}
return false;
},
// 生成 Markdown TTS
async generateMdTTS() {
if (!this.mdContent || this.mdContent.length === 0) {
this.$message.warning('Markdown内容为空无法生成语音');
return;
}
this.downloadFilename_md = 'md_tts_' + window.pinyin.getFullChars(this.formData.label) + '.mp3';
try {
this.isTTSGenerating = true;
this.preview.audioSrc_md = null;
this.preview.downReady_md = false;
const wsUrl = `${WEIXIN_WS_URL}/tts/chats/${chatId}/tts/x-tts-type-is-TextToTts`;
const ws = new WebSocket(wsUrl);
let audioChunks = [];
ws.onopen = () => {
ws.send(JSON.stringify({
service_type: "TextToTts",
text: this.mdContent,
params: {
tts_stream_format: 'mp3',
tts_sample_rate: 22050,
model_name: this.voiceSelected_md,
delay_gen_audio: true
}
}));
};
ws.onmessage = async (event) => {
if (typeof event.data === 'string') {
try {
const message = JSON.parse(event.data);
if (message.error) {
this.showError(message.error);
} else if (message.status === "completed") {
ws.close();
}
} catch (e) {
console.log("Non-JSON message:", event.data);
}
} else {
audioChunks.push(event.data);
console.log("收到音频数据", event.data.size);
}
};
ws.onclose = () => {
this.isTTSGenerating = false;
if (audioChunks.length > 0) {
const blob = new Blob(audioChunks, {type: 'audio/mpeg'});
const url = URL.createObjectURL(blob);
if (this.preview.audioSrc_md) {
URL.revokeObjectURL(this.preview.audioSrc_md);
}
this.preview.audioSrc_md = url;
this.preview.tempBlob_md = blob;
this.preview.downReady_md = true;
this.preview.visible_md = false;
}
};
ws.onerror = (error) => {
this.isTTSGenerating = false;
this.showError("WebSocket连接错误: " + error.message);
};
} catch (error) {
this.isTTSGenerating = false;
this.showError(error.message);
}
},
// 下载 Markdown TTS
downloadMdTTS() {
this.downloadFile(this.preview.audioSrc_md, this.downloadFilename_md);
},
downloadFile(content, filename) {
let blob = this.preview.tempBlob_md;
this.downloadStateMd = "download";
if (!blob) {
this.showError("没有可下载的音频");
return;
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
document.body.removeChild(a);
this.preview.visible_md = true;
this.downloadStateMd = "finished";
this.resetDownloadState();
},
// 音色选择变化
// eslint-disable-next-line no-unused-vars
voiceSelectedChangeMd(value) {
this.preview.visible_md = false;
this.preview.audioSrc_md = "";
},
// 处理上传文件变化
handleUploadChangeMd(file) {
console.log(file);
this.uploadFile_md = file.raw;
},
// 提交 TTS 上传
submitMdTTSUpload() {
const formData = new FormData();
formData.append('file', this.uploadFile_md);
formData.append('bucket', exhibit_tts_bucket_name);
formData.append('file_name', `${this.photo_prefix}/${this.uploadFileName_md}`);
fetch(`${BASE_API_URL}/minio/put`, {
method: 'POST',
body: formData,
headers: {
"Authorization": "Bearer ragflow-NhZTY5Y2M4YWQ1MzExZWY4Zjc3MDI0Mm"
}
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(res => {
const {data} = res;
if (data?.put) {
this.updateAntiqueMdTts(data?.url);
this.$message.success('上传成功');
}
})
.catch(error => {
console.error('上传失败:', error);
this.$message.error('上传失败');
});
},
// 删除 Markdown TTS URL
async removeMdTtsUrl() {
this.$confirm('确认删除Markdown TTS音频地址')
.then(async () => {
if (this.formData.md_tts_url && this.formData.md_tts_url !== '') {
const urlParts = this.formData.md_tts_url.split(`${exhibit_tts_bucket_name}/`);
if (urlParts.length > 1) {
const fileName = urlParts[1];
if (fileName && fileName !== "") {
await this.removeMinioObject(exhibit_tts_bucket_name, fileName);
}
}
}
if (await this.updateAntiqueMdTts("")) {
this.$message('删除音频地址成功');
} else {
this.$message('删除音频地址失败');
}
}).catch(() => {
});
},
// 更新数据库中的 md_tts_url
async updateAntiqueMdTts(tts_url) {
let antique_data = {
mesum_id: this.mesumSelectedId,
md_tts_url: tts_url
};
const response = await fetch(`${BASE_API_URL}/mesum/antique/update/${this.formData.mesum_id}/${this.formData.id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ragflow-NhZTY5Y2M4YWQ1MzExZWY4Zjc3MDI0Mm'
},
body: JSON.stringify(antique_data)
});
const result = await response.json();
const {data} = result;
if (data && data.update) {
this.formData.md_tts_url = tts_url;
this.triggerRefreshAntiques();
return true;
}
return false;
},
// 删除 MinIO 对象
async removeMinioObject(bucket, fileName) {
fetch(`${BASE_API_URL}/minio/rm`, {
method: 'POST',
body: JSON.stringify({
bucket: bucket,
file_name: fileName
}),
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer ragflow-NhZTY5Y2M4YWQ1MzExZWY4Zjc3MDI0Mm"
}
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(res => {
const {data} = res;
if (data?.rm) {
console.log('删除MinIO对象成功');
}
})
.catch(error => {
console.error('删除minio object 失败:', error);
});
},
resetDownloadState() {
setTimeout(() => {
this.downloadStateMd = 'idle';
}, 3000);
},
showError(message) {
this.result.error = message;
this.result.success = false;
setTimeout(() => this.result.error = null, 5000);
},
handleDialogClose(done) {
const audioMd = this.$refs.audioPlayerMd;
if (audioMd) {
audioMd.pause();
}
done();
this.closeDialog();
},
closeDialog() {
this.dialogVisible = false;
this.$emit("update:close", true);
},
triggerRefreshAntiques() {
this.$emit("update:refresh", this.formData.id);
}
},
watch: {
visible: {
handler(newVal) {
this.dialogVisible = newVal;
if (newVal) {
this.formData = {...this.formData, ...this.initData};
this.mdContent = "";
this.mdFile = null;
this.downloadStateMd = 'idle';
this.downloadFilename_md = 'md_tts_' + window.pinyin.getFullChars(this.formData.label || 'default') + '.mp3';
}
console.log("watch visible", newVal, this.initData);
},
immediate: true
},
mesumSelectedId: {
handler(newVal) {
console.log("watch mesumSelectedId", newVal);
if (newVal == 2) {
this.voiceSelected_md = "sambert-zhichu-v1@Tongyi-Qianwen";
} else if (newVal == 1) {
this.voiceSelected_md = "cosyvoice-v1/longyuan@Tongyi-Qianwen";
} else {
this.voiceSelected_md = "sambert-zhichu-v1@Tongyi-Qianwen";
}
},
immediate: true
}
},
computed: {
uploadFileName_md() {
if (this.uploadFile_md && this.uploadFile_md.name)
return this.uploadFile_md.name;
else
return "";
},
downloadPromptMd() {
if (this.downloadStateMd === 'idle') return "";
if (this.downloadStateMd === 'download') return "下载中...";
if (this.downloadStateMd === 'save') return "存储中...";
if (this.downloadStateMd === 'finished') return "完成";
return "";
}
}
}
</script>
<style scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
}
.el-dialog__header {
background: #f0f9eb !important;
padding: 2px 2px !important;
border-bottom: 1px solid #ebeef5;
}
.el-dialog__title {
margin: 0;
padding: 0;
color: #67C23A !important;
font-size: 16px !important;
}
.el-dialog__headerbtn {
margin-top: 3px !important;
top: 2px !important;
}
.form-group {
padding: 5px;
margin-bottom: 5px;
border-bottom: 2px solid #eee;
}
label {
display: block;
margin-bottom: 5px;
}
input, select, textarea {
width: 100%;
padding: 8px;
}
button {
padding: 10px 20px;
margin: 5px;
cursor: pointer;
}
.audio-player {
margin: 20px 0;
height: 40px
}
.error-message {
color: red;
margin-top: 10px;
}
.success-message {
color: green;
margin-top: 10px;
}
.upload-section {
margin-top: 20px;
border-top: 2px solid #eee;
padding-top: 20px;
height: 10vh
}
</style>