Files
yys-editor/src/components/Toolbar.vue

543 lines
17 KiB
Vue

<template>
<div class="toolbar" :class="{ 'toolbar--embed': props.isEmbed }">
<div class="toolbar-actions">
<el-button icon="Upload" type="primary" @click="handleImport">{{ t('import') }}</el-button>
<el-button icon="Download" type="primary" @click="handleExport">{{ t('export') }}</el-button>
<el-button icon="View" type="success" @click="handlePreviewData">数据预览</el-button>
<el-button icon="Share" type="primary" @click="prepareCapture">{{ t('prepareCapture') }}</el-button>
<el-button icon="Setting" type="primary" @click="state.showWatermarkDialog = true">{{ t('setWatermark') }}</el-button>
<el-button v-if="!props.isEmbed" type="info" @click="loadExample">{{ t('loadExample') }}</el-button>
<el-button v-if="!props.isEmbed" type="info" @click="showUpdateLog">{{ t('updateLog') }}</el-button>
<el-button v-if="!props.isEmbed" type="warning" @click="showFeedbackForm">{{ t('feedback') }}</el-button>
<el-button type="danger" @click="handleResetWorkspace">重置工作区</el-button>
<el-button type="warning" plain @click="handleClearCanvas">清空画布</el-button>
</div>
<div class="toolbar-controls">
<el-switch
v-model="selectionEnabled"
size="small"
inline-prompt
active-text="框选开"
inactive-text="框选关"
/>
<el-switch
v-model="snapGridEnabled"
size="small"
inline-prompt
active-text="吸附开"
inactive-text="吸附关"
/>
<el-switch
v-model="snaplineEnabled"
size="small"
inline-prompt
active-text="对齐线开"
inactive-text="对齐线关"
/>
</div>
<!-- 更新日志对话框 -->
<el-dialog v-if="!props.isEmbed" v-model="state.showUpdateLogDialog" title="更新日志" width="60%">
<ul>
<li v-for="(log, index) in updateLogs" :key="index">
<strong>版本 {{ log.version }} - {{ log.date }}</strong>
<ul>
<li v-for="(change, idx) in log.changes" :key="idx">{{ change }}</li>
</ul>
</li>
</ul>
</el-dialog>
<!-- 问题反馈对话框 -->
<el-dialog v-if="!props.isEmbed" v-model="state.showFeedbackFormDialog" title="更新日志" width="60%">
<span style="font-size: 24px;">备注阴阳师</span>
<br/>
<img src="/assets/Other/Contact.png"
style="cursor: pointer; vertical-align: bottom; width: 200px; height: auto;"/>
</el-dialog>
<!-- 预览弹窗 -->
<el-dialog id="preview-container" v-model="state.previewVisible" width="80%" height="80%"
:before-close="handleClose">
<div style="max-height: 500px; overflow-y: auto;">
<img v-if="state.previewImage" :src="state.previewImage" alt="Preview" style="width: 100%; display: block;"/>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="state.previewVisible = false"> </el-button>
<el-button type="primary" @click="downloadImage"> </el-button>
</span>
</el-dialog>
<!-- 水印设置弹窗 -->
<el-dialog v-model="state.showWatermarkDialog" title="设置水印" width="30%">
<el-form>
<el-form-item label="水印文字">
<el-input v-model="watermark.text"></el-input>
</el-form-item>
<el-form-item label="字体大小">
<el-input-number v-model="watermark.fontSize" :min="10" :max="100"></el-input-number>
</el-form-item>
<el-form-item label="颜色">
<el-color-picker v-model="watermark.color"></el-color-picker>
</el-form-item>
<el-form-item label="水印行数">
<el-input-number v-model="watermark.rows" :min="1" :max="10"></el-input-number>
</el-form-item>
<el-form-item label="水印列数">
<el-input-number v-model="watermark.cols" :min="1" :max="10"></el-input-number>
</el-form-item>
<el-form-item label="角度">
<el-input-number v-model="watermark.angle" :min="-90" :max="90"></el-input-number>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="state.showWatermarkDialog = false">取消</el-button>
<el-button type="primary" @click="applyWatermarkSettings">确认</el-button>
</span>
</template>
</el-dialog>
<!-- 数据预览对话框 -->
<el-dialog v-model="state.showDataPreviewDialog" title="数据预览" width="70%">
<div style="max-height: 600px; overflow-y: auto;">
<pre style="background: #f5f5f5; padding: 16px; border-radius: 4px; font-size: 12px; line-height: 1.5;">{{ state.previewDataContent }}</pre>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="state.showDataPreviewDialog = false">关闭</el-button>
<el-button type="primary" @click="copyDataToClipboard">复制到剪贴板</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted } from 'vue';
import updateLogs from "../data/updateLog.json"
import { useFilesStore } from "@/ts/useStore";
import { ElMessageBox } from "element-plus";
import { useGlobalMessage } from "@/ts/useGlobalMessage";
import { getLogicFlowInstance } from "@/ts/useLogicFlow";
import { useCanvasSettings } from '@/ts/useCanvasSettings';
import { useSafeI18n } from '@/ts/useSafeI18n';
import type { Pinia } from 'pinia';
const props = withDefaults(defineProps<{
isEmbed?: boolean;
piniaInstance?: Pinia;
}>(), {
isEmbed: false
});
const filesStore = props.piniaInstance ? useFilesStore(props.piniaInstance) : useFilesStore();
const { showMessage } = useGlobalMessage();
const { selectionEnabled, snapGridEnabled, snaplineEnabled } = useCanvasSettings();
const { t } = useSafeI18n({
import: '导入',
export: '导出',
prepareCapture: '准备截图',
setWatermark: '设置水印',
loadExample: '加载样例',
updateLog: '更新日志',
feedback: '问题反馈'
});
// 定义响应式数据
const state = reactive({
previewImage: null, // 用于存储预览图像的数据URL
previewVisible: false, // 控制预览弹窗的显示状态
showWatermarkDialog: false, // 控制水印设置弹窗的显示状态,
showUpdateLogDialog: false, // 控制更新日志对话框的显示状态
showFeedbackFormDialog: false, // 控制反馈表单对话框的显示状态
showDataPreviewDialog: false, // 控制数据预览对话框的显示状态
previewDataContent: '', // 存储预览的数据内容
});
// 重新渲染 LogicFlow 画布的通用方法
const refreshLogicFlowCanvas = (message?: string) => {
setTimeout(() => {
const logicFlowInstance = getLogicFlowInstance();
if (logicFlowInstance) {
// 获取当前活动文件的数据
const currentFileData = filesStore.getTab(filesStore.activeFileId);
if (currentFileData) {
// 清空画布并重新渲染
logicFlowInstance.clearData();
// 注意:此处根据你的画布 API 传入 graphRawData 或整个文件数据
const data = (currentFileData as any).graphRawData || currentFileData;
logicFlowInstance.render(data);
console.log(message || 'LogicFlow 画布已重新渲染');
}
}
}, 100); // 延迟一点确保数据更新完成
};
const loadExample = () => {
ElMessageBox.confirm(
'加载样例会覆盖当前数据,是否覆盖?',
'提示',
{
confirmButtonText: '覆盖',
cancelButtonText: '取消',
type: 'warning',
}
).then(() => {
// 使用默认状态作为示例
const defaultState = {
fileList: [{
"label": "示例文件",
"name": "example",
"visible": true,
"type": "FLOW",
"groups": [
{
"shortDescription": "示例组",
"groupInfo": [{}, {}, {}, {}, {}],
"details": "这是一个示例文件"
}
],
"flowData": {
"nodes": [],
"edges": [],
"viewport": { "x": 0, "y": 0, "zoom": 1 }
}
}],
activeFile: "example"
};
filesStore.importData(defaultState);
refreshLogicFlowCanvas('LogicFlow 画布已重新渲染(示例数据)');
showMessage('success', '数据已恢复');
}).catch(() => {
showMessage('info', '选择了不恢复旧数据');
});
}
const CURRENT_APP_VERSION = updateLogs[0].version;
const showUpdateLog = () => {
state.showUpdateLogDialog = !state.showUpdateLogDialog;
};
onMounted(() => {
if (props.isEmbed) {
return;
}
const lastVersion = localStorage.getItem('appVersion');
if (lastVersion !== CURRENT_APP_VERSION) {
// 如果版本号不同,则显示更新日志对话框
state.showUpdateLogDialog = true;
// 更新本地存储中的版本号为当前版本
localStorage.setItem('appVersion', CURRENT_APP_VERSION);
}
});
const showFeedbackForm = () => {
state.showFeedbackFormDialog = !state.showFeedbackFormDialog;
};
const handleExport = () => {
// 导出前先更新当前数据,确保不丢失最新修改
filesStore.updateTab();
// 延迟一点确保更新完成后再导出
setTimeout(() => {
filesStore.exportData();
}, 2000);
};
const handlePreviewData = () => {
// 预览前先更新当前数据
filesStore.updateTab();
// 延迟一点确保更新完成后再预览
setTimeout(() => {
try {
const activeName = filesStore.fileList.find(f => f.id === filesStore.activeFileId)?.name || '';
const dataObj = {
schemaVersion: 1,
fileList: filesStore.fileList,
activeFileId: filesStore.activeFileId,
activeFile: activeName,
};
state.previewDataContent = JSON.stringify(dataObj, null, 2);
state.showDataPreviewDialog = true;
} catch (error) {
console.error('生成预览数据失败:', error);
showMessage('error', '数据预览失败');
}
}, 100);
};
const copyDataToClipboard = async () => {
try {
await navigator.clipboard.writeText(state.previewDataContent);
showMessage('success', '已复制到剪贴板');
} catch (error) {
console.error('复制失败:', error);
showMessage('error', '复制失败');
}
};
const handleImport = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (e) => {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const target = e.target as FileReader;
const data = JSON.parse(target.result as string);
filesStore.importData(data);
// refreshLogicFlowCanvas('LogicFlow 画布已重新渲染(导入数据)');
} catch (error) {
console.error('Failed to import file', error);
showMessage('error', '文件格式错误');
}
};
reader.readAsText(file);
}
};
input.click();
};
const handleResetWorkspace = () => {
ElMessageBox.confirm('确定重置当前工作区?该操作不可撤销', '提示', {
confirmButtonText: '重置',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
filesStore.resetWorkspace();
}).catch(() => {
// 用户取消
});
};
const handleClearCanvas = () => {
ElMessageBox.confirm('仅清空当前画布,不影响其他文件,确定继续?', '提示', {
confirmButtonText: '清空',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
const lfInstance = getLogicFlowInstance();
const activeId = filesStore.activeFileId;
const activeFile = filesStore.getTab(activeId);
if (lfInstance) {
lfInstance.clearData();
lfInstance.render({ nodes: [], edges: [] });
lfInstance.zoom(1, [0, 0]);
}
if (activeFile) {
activeFile.graphRawData = { nodes: [], edges: [] };
activeFile.transform = {
SCALE_X: 1,
SCALE_Y: 1,
TRANSLATE_X: 0,
TRANSLATE_Y: 0
};
filesStore.updateTab(activeId);
}
showMessage('success', '当前画布已清空');
}).catch(() => {
// 用户取消
});
};
const watermark = reactive({
text: localStorage.getItem('watermark.text') || '示例水印',
fontSize: Number(localStorage.getItem('watermark.fontSize')) || 30,
color: localStorage.getItem('watermark.color') || 'rgba(184, 184, 184, 0.3)',
angle: Number(localStorage.getItem('watermark.angle')) || -20,
rows: Number(localStorage.getItem('watermark.rows')) || 1,
cols: Number(localStorage.getItem('watermark.cols')) || 1,
});
const applyWatermarkSettings = () => {
// 保存水印设置到 localStorage
localStorage.setItem('watermark.text', watermark.text);
localStorage.setItem('watermark.fontSize', String(watermark.fontSize));
localStorage.setItem('watermark.color', watermark.color);
localStorage.setItem('watermark.angle', String(watermark.angle));
localStorage.setItem('watermark.rows', String(watermark.rows));
localStorage.setItem('watermark.cols', String(watermark.cols));
state.showWatermarkDialog = false;
};
const addWatermarkToImage = (base64: string) => {
const rows = Math.max(1, Number(watermark.rows) || 1);
const cols = Math.max(1, Number(watermark.cols) || 1);
const angle = (Number(watermark.angle) * Math.PI) / 180;
return new Promise<string>((resolve, reject) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = img.width;
canvas.height = img.height;
if (!ctx) {
reject(new Error('无法创建画布上下文'));
return;
}
ctx.drawImage(img, 0, 0);
ctx.font = `${watermark.fontSize}px sans-serif`;
ctx.fillStyle = watermark.color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const rowStep = canvas.height / rows;
const colStep = canvas.width / cols;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = (c + 0.5) * colStep;
const y = (r + 0.5) * rowStep;
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.fillText(watermark.text, 0, 0);
ctx.restore();
}
}
resolve(canvas.toDataURL('image/png'));
};
img.onerror = () => reject(new Error('快照加载失败'));
img.src = base64;
});
};
const captureLogicFlowSnapshot = async () => {
const logicFlowInstance = getLogicFlowInstance() as any;
if (!logicFlowInstance || typeof logicFlowInstance.getSnapshotBase64 !== 'function') {
showMessage('error', '未找到 LogicFlow 实例,无法截图');
return null;
}
const snapshotResult = await logicFlowInstance.getSnapshotBase64(
undefined,
undefined,
{
fileType: 'png',
backgroundColor: '#ffffff',
partial: false,
padding: 20,
},
);
const base64 = typeof snapshotResult === 'string' ? snapshotResult : snapshotResult?.data;
if (!base64) {
showMessage('error', '未获取到截图数据');
return null;
}
return addWatermarkToImage(base64);
};
const prepareCapture = async () => {
try {
const img = await captureLogicFlowSnapshot();
if (!img) return;
state.previewImage = img;
state.previewVisible = true;
} catch (e) {
showMessage('error', '截图失败: ' + (e?.message || e));
}
};
const downloadImage = () => {
if (state.previewImage) {
const link = document.createElement('a');
link.href = state.previewImage;
link.download = 'screenshot.png';
link.click();
state.previewVisible = false;
}
};
const handleClose = (done) => {
state.previewImage = null; // 清除预览图像
done(); // 关闭弹窗
};
</script>
<style scoped>
.toolbar {
position: fixed;
top: 0;
left: 0;
right: 0;
min-height: 48px;
background: #f8f8f8;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
align-items: center;
padding: 0 8px;
z-index: 100;
box-sizing: border-box;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
flex: 1;
min-width: 0;
white-space: nowrap;
}
.toolbar--embed {
position: relative;
top: auto;
left: auto;
right: auto;
height: auto;
padding: 6px 8px;
border-bottom: 1px solid #e4e7ed;
}
.toolbar-controls {
display: flex;
align-items: center;
gap: 12px;
margin-top: 0;
flex-shrink: 0;
}
.toolbar--embed .toolbar-actions {
flex-wrap: nowrap;
}
.title {
flex-grow: 1;
text-align: center;
font-size: 16px;
}
.left, .right {
flex-basis: 120px;
display: flex;
gap: 8px;
}
</style>