Files
museum_admin/src/components/MarkdownEditor.vue

886 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<template>
<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>