This commit is contained in:
Baobhan Sith
2025-04-16 17:09:58 +08:00
parent 918b233535
commit 041168194b
4 changed files with 335 additions and 60 deletions
+5
View File
@@ -5,6 +5,8 @@ import { useAuthStore } from './stores/auth.store';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
// 导入通知显示组件 // 导入通知显示组件
import UINotificationDisplay from './components/UINotificationDisplay.vue'; import UINotificationDisplay from './components/UINotificationDisplay.vue';
// 导入文件编辑器弹窗组件
import FileEditorOverlay from './components/FileEditorOverlay.vue';
const { t } = useI18n(); const { t } = useI18n();
const authStore = useAuthStore(); const authStore = useAuthStore();
@@ -39,6 +41,9 @@ const handleLogout = () => {
<!-- 添加全局通知显示 --> <!-- 添加全局通知显示 -->
<UINotificationDisplay /> <UINotificationDisplay />
<!-- 添加全局文件编辑器弹窗 -->
<FileEditorOverlay />
<footer> <footer>
<!-- 使用 t 函数获取应用名称 --> <!-- 使用 t 函数获取应用名称 -->
<p>&copy; 2025 {{ t('appName') }}</p> <p>&copy; 2025 {{ t('appName') }}</p>
@@ -1,90 +1,114 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import MonacoEditor from './MonacoEditor.vue'; // 导入 Monaco Editor 组件 import MonacoEditor from './MonacoEditor.vue'; // 导入 Monaco Editor 组件
import type { SaveStatus } from '../types/sftp.types'; // 导入保存状态类型 import { useFileEditorStore } from '../stores/fileEditor.store'; // 导入新的 Store
const props = defineProps<{ // 不再需要 props 或 emits,状态和操作来自 Store
isVisible: boolean; // 控制可见性 // const props = defineProps<{...}>();
filePath: string | null; // 当前编辑文件路径 // const emit = defineEmits<{...}>();
language: string; // 编辑器语言
isLoading: boolean; // 是否正在加载文件内容
loadingError: string | null; // 加载错误信息
isSaving: boolean; // 是否正在保存
saveStatus: SaveStatus; // 保存状态
saveError: string | null; // 保存错误信息
modelValue: string; // 文件内容 (用于 v-model)
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void; // v-model 更新事件
(e: 'request-save'): void; // 请求保存事件
(e: 'close'): void; // 关闭编辑器事件
}>();
const { t } = useI18n(); const { t } = useI18n();
const fileEditorStore = useFileEditorStore();
// 从 Store 获取状态 (使用 storeToRefs 保持响应性)
const {
isVisible,
filePath,
fileLanguage, // 重命名为 language 以匹配 MonacoEditor prop
isLoading,
loadingError,
isSaving,
saveStatus,
saveError,
fileContent, // 直接使用 store 中的 ref 进行 v-model 绑定
} = storeToRefs(fileEditorStore);
// 从 Store 获取方法
const { saveFile, closeEditor, updateContent } = fileEditorStore;
// 计算属性,用于 v-model 绑定到 MonacoEditor // 计算属性,用于 v-model 绑定到 MonacoEditor
// 直接绑定 store 中的 fileContent ref
const editorContent = computed({ const editorContent = computed({
get: () => props.modelValue, get: () => fileContent.value,
set: (value) => emit('update:modelValue', value), set: (value) => updateContent(value), // 调用 store action 更新内容
}); });
// 保存和关闭操作直接调用 store actions
const handleSaveRequest = () => { const handleSaveRequest = () => {
emit('request-save'); saveFile();
}; };
const handleClose = () => { const handleClose = () => {
emit('close'); closeEditor();
}; };
</script> </script>
<template> <template>
<div v-if="isVisible" class="editor-overlay"> <!-- 使用 store 中的 isVisible 控制显示 -->
<div class="editor-header"> <!-- v-if 移到遮罩层上 -->
<span>{{ t('fileManager.editingFile') }}: {{ filePath }}</span> <div v-if="isVisible" class="editor-overlay-backdrop" @click.self="handleClose"> <!-- 点击背景关闭 -->
<!-- 添加弹窗容器 -->
<div class="editor-popup">
<div class="editor-header">
<!-- 使用 store 中的 filePath -->
<span>{{ t('fileManager.editingFile') }}: {{ filePath }}</span>
<div class="editor-actions"> <div class="editor-actions">
<!-- 保存状态显示 --> <!-- 使用 store 中的保存状态 -->
<span v-if="saveStatus === 'saving'" class="save-status saving">{{ t('fileManager.saving') }}...</span> <span v-if="saveStatus === 'saving'" class="save-status saving">{{ t('fileManager.saving') }}...</span>
<span v-if="saveStatus === 'success'" class="save-status success"> {{ t('fileManager.saveSuccess') }}</span> <span v-if="saveStatus === 'success'" class="save-status success"> {{ t('fileManager.saveSuccess') }}</span>
<span v-if="saveStatus === 'error'" class="save-status error"> {{ t('fileManager.saveError') }}: {{ saveError }}</span> <span v-if="saveStatus === 'error'" class="save-status error"> {{ t('fileManager.saveError') }}: {{ saveError }}</span>
<!-- 保存按钮 --> <!-- 保存按钮使用 store 状态和方法 -->
<button @click="handleSaveRequest" :disabled="isSaving || isLoading || !!loadingError" class="save-btn"> <button @click="handleSaveRequest" :disabled="isSaving || isLoading || !!loadingError" class="save-btn">
{{ isSaving ? t('fileManager.saving') : t('fileManager.actions.save') }} {{ isSaving ? t('fileManager.saving') : t('fileManager.actions.save') }}
</button> </button>
<!-- 关闭按钮 --> <!-- 关闭按钮使用 store 方法 -->
<button @click="handleClose" class="close-editor-btn"></button> <button @click="handleClose" class="close-editor-btn"></button>
</div> </div>
</div> </div>
<!-- 加载状态 --> <!-- 使用 store 中的加载状态 -->
<div v-if="isLoading" class="editor-loading">{{ t('fileManager.loadingFile') }}</div> <div v-if="isLoading" class="editor-loading">{{ t('fileManager.loadingFile') }}</div>
<!-- 加载错误 --> <!-- 使用 store 中的加载错误 -->
<div v-else-if="loadingError" class="editor-error">{{ loadingError }}</div> <div v-else-if="loadingError" class="editor-error">{{ loadingError }}</div>
<!-- Monaco 编辑器实例 --> <!-- Monaco 编辑器实例 -->
<MonacoEditor <MonacoEditor
v-else v-else
v-model="editorContent" v-model="editorContent"
:language="language" :language="fileLanguage"
theme="vs-dark" theme="vs-dark"
class="editor-instance" class="editor-instance"
@request-save="handleSaveRequest" @request-save="handleSaveRequest"
/> />
</div> </div> <!-- 关闭 editor-popup -->
</div> <!-- 关闭 editor-overlay-backdrop -->
</template> </template>
<style scoped> <style scoped>
/* 样式从 FileManager.vue 迁移并保持一致 */ /* 样式调整为居中弹窗样式 */
.editor-overlay { .editor-overlay-backdrop { /* 新增背景遮罩层 */
position: absolute; /* 相对于父容器定位 */ position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background-color: rgba(40, 40, 40, 0.95); /* 深色半透明背景 */ background-color: rgba(0, 0, 0, 0.6); /* 半透明黑色背景 */
z-index: 1000; /* 确保在文件列表之上,但在上传弹窗之下 */ z-index: 1000; /* 确保在最上层 */
display: flex;
justify-content: center;
align-items: center;
}
.editor-popup { /* 编辑器本身的容器 */
width: 60%; /* 设置宽度为 60% */
height: 80%; /* 设置一个合适的高度 */
background-color: #2d2d2d; /* 深色背景 */
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
color: #f0f0f0; color: #f0f0f0;
overflow: hidden; /* 防止内容溢出圆角 */
} }
.editor-header { .editor-header {
@@ -3,12 +3,13 @@ import { ref, computed, onMounted, onBeforeUnmount, nextTick, watchEffect, type
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; // 保留用于生成下载 URL (如果下载逻辑移动则可移除) import { useRoute } from 'vue-router'; // 保留用于生成下载 URL (如果下载逻辑移动则可移除)
// 导入 SFTP Actions 工厂函数和所需的类型 // 导入 SFTP Actions 工厂函数和所需的类型
import { createSftpActionsManager, type WebSocketDependencies } from '../composables/useSftpActions'; // 恢复 WebSocketDependencies import { createSftpActionsManager, type WebSocketDependencies } from '../composables/useSftpActions';
import { useFileUploader } from '../composables/useFileUploader'; import { useFileUploader } from '../composables/useFileUploader';
import { useFileEditor } from '../composables/useFileEditor'; // import { useFileEditor } from '../composables/useFileEditor'; // 移除旧的 composable 导入
import { useFileEditorStore } from '../stores/fileEditor.store'; // 导入新的 Store
// WebSocket composable 不再直接使用 // WebSocket composable 不再直接使用
import FileUploadPopup from './FileUploadPopup.vue'; import FileUploadPopup from './FileUploadPopup.vue';
import FileEditorOverlay from './FileEditorOverlay.vue'; // import FileEditorOverlay from './FileEditorOverlay.vue'; // 不再在此处渲染
// 从类型文件导入所需类型 // 从类型文件导入所需类型
import type { FileListItem } from '../types/sftp.types'; import type { FileListItem } from '../types/sftp.types';
// 从 websocket 类型文件导入所需类型 // 从 websocket 类型文件导入所需类型
@@ -81,25 +82,28 @@ const {
// useFileUploader 内部创建自己的 ws 连接, 不需要 wsDeps // useFileUploader 内部创建自己的 ws 连接, 不需要 wsDeps
); );
// 实例化新的文件编辑器 Store
const fileEditorStore = useFileEditorStore();
// 文件编辑器模块 - Needs file operations from sftpManager // 文件编辑器模块 - Needs file operations from sftpManager
const { // const { // 移除旧的 composable 解构
isEditorVisible, // isEditorVisible,
editingFilePath, // editingFilePath,
editingFileLanguage, // editingFileLanguage,
isEditorLoading, // isEditorLoading,
editorError, // editorError,
isSaving, // isSaving,
saveStatus, // saveStatus,
saveError, // saveError,
editingFileContent, // editingFileContent,
openFile, // openFile,
saveFile, // saveFile,
closeEditor, // closeEditor,
// cleanup: cleanupEditor, // 假设 editor 也提供 cleanup // // cleanup: cleanupEditor, // 假设 editor 也提供 cleanup
} = useFileEditor( // } = useFileEditor( // 移除旧的 composable 调用
readFile, // 使用注入的 sftpManager 中的 readFile // readFile, // 使用注入的 sftpManager 中的 readFile
writeFile // Use writeFile from the injected sftpManager // writeFile // Use writeFile from the injected sftpManager
); // );
// --- UI 状态 Refs (Remain mostly the same) --- // --- UI 状态 Refs (Remain mostly the same) ---
const fileInputRef = ref<HTMLInputElement | null>(null); const fileInputRef = ref<HTMLInputElement | null>(null);
@@ -294,7 +298,8 @@ const handleItemClick = (event: MouseEvent, item: FileListItem) => { // item 已
loadDirectory(newPath); // Use loadDirectory from props loadDirectory(newPath); // Use loadDirectory from props
} else if (item.attrs.isFile) { } else if (item.attrs.isFile) {
const filePath = joinPath(currentPath.value, item.filename); // Use joinPath from props const filePath = joinPath(currentPath.value, item.filename); // Use joinPath from props
openFile(filePath); // Use openFile from useFileEditor // 调用全局 Store 的 openFile,并传入 sessionId
fileEditorStore.openFile(filePath, props.sessionId);
} }
} }
}; };
@@ -793,7 +798,8 @@ const clearError = () => {
</ul> </ul>
</div> </div>
<!-- 使用 FileEditorOverlay 组件 --> <!-- FileEditorOverlay 不再在此处渲染 -->
<!--
<FileEditorOverlay <FileEditorOverlay
:is-visible="isEditorVisible" :is-visible="isEditorVisible"
:file-path="editingFilePath" :file-path="editingFilePath"
@@ -807,6 +813,7 @@ const clearError = () => {
@request-save="saveFile" @request-save="saveFile"
@close="closeEditor" @close="closeEditor"
/> />
-->
</div> </div>
</template> </template>
@@ -0,0 +1,239 @@
import { ref, computed, readonly } from 'vue';
import { defineStore } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useSessionStore } from './session.store'; // 导入会话 Store
import type { EditorFileContent, SaveStatus } from '../types/sftp.types';
// 辅助函数:根据文件名获取语言 (从 useFileEditor.ts 迁移)
const getLanguageFromFilename = (filename: string): string => {
const extension = filename.split('.').pop()?.toLowerCase();
// (保持 switch case 不变)
switch (extension) {
case 'js': return 'javascript';
case 'ts': return 'typescript';
case 'json': return 'json';
case 'html': return 'html';
case 'css': return 'css';
case 'scss': return 'scss';
case 'less': return 'less';
case 'py': return 'python';
case 'java': return 'java';
case 'c': return 'c';
case 'cpp': return 'cpp';
case 'cs': return 'csharp';
case 'go': return 'go';
case 'php': return 'php';
case 'rb': return 'ruby';
case 'rs': return 'rust';
case 'sql': return 'sql';
case 'sh': return 'shell';
case 'yaml': case 'yml': return 'yaml';
case 'md': return 'markdown';
case 'xml': return 'xml';
case 'ini': return 'ini';
case 'bat': return 'bat';
case 'dockerfile': return 'dockerfile';
default: return 'plaintext';
}
};
export const useFileEditorStore = defineStore('fileEditor', () => {
const { t } = useI18n();
const sessionStore = useSessionStore();
// --- 编辑器状态 ---
const isVisible = ref(false);
const currentSessionId = ref<string | null>(null); // 需要知道文件属于哪个会话
const filePath = ref<string | null>(null);
const fileContent = ref<string>(''); // 用于 v-model 绑定
const fileLanguage = ref<string>('plaintext');
const fileEncoding = ref<'utf8' | 'base64'>('utf8'); // 文件内容的原始编码
const isLoading = ref<boolean>(false);
const loadingError = ref<string | null>(null);
const isSaving = ref<boolean>(false);
const saveStatus = ref<SaveStatus>('idle');
const saveError = ref<string | null>(null);
// --- 计算属性 ---
const editorProps = computed(() => ({
isVisible: isVisible.value,
filePath: filePath.value,
language: fileLanguage.value,
isLoading: isLoading.value,
loadingError: loadingError.value,
isSaving: isSaving.value,
saveStatus: saveStatus.value,
saveError: saveError.value,
// modelValue is handled separately via direct ref binding
}));
// --- 核心方法 ---
// 获取当前会话的 SFTP 管理器
const getSftpManager = (sessionId: string | null) => {
if (!sessionId) return null;
const session = sessionStore.sessions.get(sessionId);
return session?.sftpManager ?? null;
};
const openFile = async (targetFilePath: string, sessionId: string) => {
console.log(`[文件编辑器 Store] 尝试打开文件: ${targetFilePath} (会话: ${sessionId})`);
if (!targetFilePath || !sessionId) return;
// // 如果已经是同一个文件,则不重新加载(除非需要强制刷新)
// if (filePath.value === targetFilePath && isVisible.value) {
// console.log(`[文件编辑器 Store] 文件 ${targetFilePath} 已在编辑器中打开。`);
// return;
// }
const sftpManager = getSftpManager(sessionId);
if (!sftpManager) {
console.error(`[文件编辑器 Store] 无法找到会话 ${sessionId} 的 SFTP 管理器。`);
// 可以设置一个错误状态或通知用户
loadingError.value = t('fileManager.errors.sftpManagerNotFound');
isVisible.value = true; // 仍然显示编辑器以展示错误
return;
}
isVisible.value = true; // 显示编辑器区域
isLoading.value = true; // 显示加载状态
loadingError.value = null;
saveStatus.value = 'idle'; // 重置保存状态
saveError.value = null;
filePath.value = targetFilePath;
currentSessionId.value = sessionId; // 记录当前会话 ID
fileLanguage.value = getLanguageFromFilename(targetFilePath);
fileContent.value = ''; // 清空旧内容
try {
// 使用从 sessionStore 获取的 sftpManager 的 readFile 方法
const fileData = await sftpManager.readFile(targetFilePath);
console.log(`[文件编辑器 Store] 文件 ${targetFilePath} 读取成功。编码: ${fileData.encoding}`);
// 处理可能的 Base64 编码
if (fileData.encoding === 'base64') {
try {
fileContent.value = atob(fileData.content); // 解码
fileEncoding.value = 'base64'; // 记录原始编码
} catch (decodeError) {
console.error(`[文件编辑器 Store] Base64 解码错误 for ${targetFilePath}:`, decodeError);
loadingError.value = t('fileManager.errors.fileDecodeError');
fileContent.value = `// ${t('fileManager.errors.fileDecodeError')}\n${fileData.content}`; // 显示原始 Base64 作为后备
}
} else {
fileContent.value = fileData.content;
fileEncoding.value = 'utf8';
}
isLoading.value = false;
} catch (err: any) {
console.error(`[文件编辑器 Store] 读取文件 ${targetFilePath} 失败:`, err);
loadingError.value = `${t('fileManager.errors.readFileFailed')}: ${err.message || err}`;
fileContent.value = `// ${loadingError.value}`; // 在编辑器中显示错误
isLoading.value = false;
}
};
const saveFile = async () => {
if (!filePath.value || !currentSessionId.value || isSaving.value || isLoading.value || loadingError.value) {
console.warn('[文件编辑器 Store] 保存条件不满足,无法保存。', {
path: filePath.value,
sessionId: currentSessionId.value,
isSaving: isSaving.value,
isLoading: isLoading.value,
hasError: !!loadingError.value
});
return;
}
const sftpManager = getSftpManager(currentSessionId.value);
if (!sftpManager) {
console.error(`[文件编辑器 Store] 保存失败:无法找到会话 ${currentSessionId.value} 的 SFTP 管理器。`);
saveStatus.value = 'error';
saveError.value = t('fileManager.errors.sftpManagerNotFound');
return;
}
console.log(`[文件编辑器 Store] 开始保存文件: ${filePath.value} (会话: ${currentSessionId.value})`);
isSaving.value = true;
saveStatus.value = 'saving';
saveError.value = null;
const contentToSave = fileContent.value; // 获取当前编辑器内容
try {
// 使用从 sessionStore 获取的 sftpManager 的 writeFile 方法
await sftpManager.writeFile(filePath.value, contentToSave);
console.log(`[文件编辑器 Store] 文件 ${filePath.value} 保存成功。`);
isSaving.value = false;
saveStatus.value = 'success';
saveError.value = null;
// 成功提示短暂显示后消失
setTimeout(() => {
if (saveStatus.value === 'success') {
saveStatus.value = 'idle';
}
}, 2000);
} catch (err: any) {
console.error(`[文件编辑器 Store] 保存文件 ${filePath.value} 失败:`, err);
isSaving.value = false;
saveStatus.value = 'error';
saveError.value = `${t('fileManager.errors.saveFailed')}: ${err.message || err}`;
// 错误提示显示时间长一些
setTimeout(() => {
if (saveStatus.value === 'error') {
saveStatus.value = 'idle';
saveError.value = null;
}
}, 5000);
}
};
const closeEditor = () => {
console.log('[文件编辑器 Store] 关闭编辑器。');
isVisible.value = false;
filePath.value = null;
currentSessionId.value = null;
fileContent.value = '';
loadingError.value = null;
isLoading.value = false;
saveStatus.value = 'idle';
saveError.value = null;
isSaving.value = false;
};
// 提供一个方法来更新内容,主要用于 v-model
const updateContent = (newContent: string) => {
fileContent.value = newContent;
// 当用户编辑时,可以重置保存状态(如果需要)
if (saveStatus.value === 'success' || saveStatus.value === 'error') {
saveStatus.value = 'idle';
saveError.value = null;
}
};
return {
// 状态 (只读的 ref 或计算属性)
isVisible: readonly(isVisible),
filePath: readonly(filePath),
fileLanguage: readonly(fileLanguage),
isLoading: readonly(isLoading),
loadingError: readonly(loadingError),
isSaving: readonly(isSaving),
saveStatus: readonly(saveStatus),
saveError: readonly(saveError),
editorProps, // 提供一个包含多个只读状态的对象,方便绑定
// 可写状态 (用于 v-model)
fileContent, // 直接暴露 ref 用于 v-model
// 方法
openFile,
saveFile,
closeEditor,
updateContent, // 如果需要从外部更新内容
};
});