update
This commit is contained in:
@@ -1,54 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue';
|
||||
import { storeToRefs } from 'pinia'; // 导入 storeToRefs
|
||||
import { useI18n } from 'vue-i18n'; // 引入 useI18n
|
||||
import { useConnectionsStore } from '../stores/connections.store';
|
||||
import { ref, reactive, watch, computed } from 'vue'; // 引入 watch 和 computed
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useConnectionsStore, ConnectionInfo } from '../stores/connections.store'; // 引入 ConnectionInfo
|
||||
|
||||
// 定义组件发出的事件
|
||||
const emit = defineEmits(['close', 'connection-added']);
|
||||
// 定义组件发出的事件 (添加 connection-updated)
|
||||
const emit = defineEmits(['close', 'connection-added', 'connection-updated']);
|
||||
|
||||
const { t } = useI18n(); // 获取 t 函数
|
||||
// 定义 Props
|
||||
const props = defineProps<{
|
||||
connectionToEdit: ConnectionInfo | null; // 接收要编辑的连接对象
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionsStore = useConnectionsStore();
|
||||
const { isLoading, error } = storeToRefs(connectionsStore); // 获取加载和错误状态
|
||||
const { isLoading, error: storeError } = storeToRefs(connectionsStore); // 重命名 error 避免冲突
|
||||
|
||||
// 表单数据模型
|
||||
const formData = reactive({
|
||||
const initialFormData = {
|
||||
name: '',
|
||||
host: '',
|
||||
port: 22,
|
||||
username: '',
|
||||
auth_method: 'password' as 'password' | 'key',
|
||||
password: '',
|
||||
});
|
||||
private_key: '',
|
||||
passphrase: '',
|
||||
};
|
||||
const formData = reactive({ ...initialFormData });
|
||||
|
||||
const formError = ref<string | null>(null); // 表单级别的错误信息
|
||||
|
||||
// 计算属性判断是否为编辑模式
|
||||
const isEditMode = computed(() => !!props.connectionToEdit);
|
||||
|
||||
// 计算属性动态设置表单标题
|
||||
const formTitle = computed(() => {
|
||||
return isEditMode.value ? t('connections.form.titleEdit') : t('connections.form.title');
|
||||
});
|
||||
|
||||
// 计算属性动态设置提交按钮文本
|
||||
const submitButtonText = computed(() => {
|
||||
if (isLoading.value) {
|
||||
return isEditMode.value ? t('connections.form.saving') : t('connections.form.adding');
|
||||
}
|
||||
return isEditMode.value ? t('connections.form.confirmEdit') : t('connections.form.confirm');
|
||||
});
|
||||
|
||||
// 监听 prop 变化以填充或重置表单
|
||||
watch(() => props.connectionToEdit, (newVal) => {
|
||||
formError.value = null; // 清除错误
|
||||
if (newVal) {
|
||||
// 编辑模式:填充表单,但不填充敏感信息
|
||||
formData.name = newVal.name;
|
||||
formData.host = newVal.host;
|
||||
formData.port = newVal.port;
|
||||
formData.username = newVal.username;
|
||||
formData.auth_method = newVal.auth_method;
|
||||
// 清空敏感字段,要求用户重新输入以更新
|
||||
formData.password = '';
|
||||
formData.private_key = '';
|
||||
formData.passphrase = '';
|
||||
} else {
|
||||
// 添加模式:重置表单
|
||||
Object.assign(formData, initialFormData);
|
||||
}
|
||||
}, { immediate: true }); // immediate: true 确保初始加载时也执行
|
||||
|
||||
// 处理表单提交
|
||||
const handleSubmit = async () => {
|
||||
formError.value = null; // 清除之前的错误
|
||||
connectionsStore.error = null; // 清除 store 中的旧错误
|
||||
|
||||
// 基础前端验证 (可以添加更复杂的验证)
|
||||
if (!formData.name || !formData.host || !formData.username || !formData.password) {
|
||||
formError.value = t('connections.form.errorRequired');
|
||||
// 基础前端验证 (保持不变)
|
||||
if (!formData.name || !formData.host || !formData.username) {
|
||||
formError.value = t('connections.form.errorRequiredFields'); // 更通用的错误消息
|
||||
return;
|
||||
}
|
||||
if (formData.port <= 0 || formData.port > 65535) {
|
||||
formError.value = t('connections.form.errorPort');
|
||||
return;
|
||||
}
|
||||
// 根据认证方式验证特定字段
|
||||
if (formData.auth_method === 'password' && !formData.password) {
|
||||
formError.value = t('connections.form.errorPasswordRequired');
|
||||
return;
|
||||
}
|
||||
if (formData.auth_method === 'key' && !formData.private_key) {
|
||||
formError.value = t('connections.form.errorPrivateKeyRequired');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await connectionsStore.addConnection({
|
||||
// 构建要发送的数据 (区分添加和编辑)
|
||||
const dataToSend: any = {
|
||||
name: formData.name,
|
||||
host: formData.host,
|
||||
port: formData.port,
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
});
|
||||
auth_method: formData.auth_method,
|
||||
};
|
||||
|
||||
if (success) {
|
||||
emit('connection-added'); // 通知父组件添加成功
|
||||
// 只有当用户输入了新的密码/密钥时才包含它们
|
||||
if (formData.auth_method === 'password' && formData.password) {
|
||||
dataToSend.password = formData.password;
|
||||
} else if (formData.auth_method === 'key') {
|
||||
if (formData.private_key) { // 只有输入了新私钥才发送
|
||||
dataToSend.private_key = formData.private_key;
|
||||
}
|
||||
if (formData.passphrase) { // 只有输入了新密码短语才发送
|
||||
dataToSend.passphrase = formData.passphrase;
|
||||
} else if (isEditMode.value && formData.private_key && !formData.passphrase) {
|
||||
// 如果是编辑模式,输入了新私钥但清空了密码短语,需要显式发送空字符串或 null
|
||||
// 取决于后端如何处理清空密码短语。假设发送空字符串。
|
||||
dataToSend.passphrase = '';
|
||||
}
|
||||
}
|
||||
|
||||
let success = false;
|
||||
if (isEditMode.value && props.connectionToEdit) {
|
||||
// 调用更新 action
|
||||
success = await connectionsStore.updateConnection(props.connectionToEdit.id, dataToSend);
|
||||
if (success) {
|
||||
emit('connection-updated'); // 发出更新成功事件
|
||||
} else {
|
||||
formError.value = t('connections.form.errorUpdate', { error: connectionsStore.error || '未知错误' });
|
||||
}
|
||||
} else {
|
||||
// 如果 store action 返回 false,则显示 store 中的错误信息
|
||||
formError.value = t('connections.form.errorAdd', { error: connectionsStore.error || '未知错误' });
|
||||
// 调用添加 action
|
||||
success = await connectionsStore.addConnection(dataToSend);
|
||||
if (success) {
|
||||
emit('connection-added'); // 发出添加成功事件
|
||||
} else {
|
||||
formError.value = t('connections.form.errorAdd', { error: connectionsStore.error || '未知错误' });
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -56,7 +140,7 @@ const handleSubmit = async () => {
|
||||
<template>
|
||||
<div class="add-connection-form-overlay">
|
||||
<div class="add-connection-form">
|
||||
<h3>{{ t('connections.form.title') }}</h3>
|
||||
<h3>{{ formTitle }}</h3> <!-- 使用计算属性 -->
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="form-group">
|
||||
<label for="conn-name">{{ t('connections.form.name') }}</label>
|
||||
@@ -74,19 +158,45 @@ const handleSubmit = async () => {
|
||||
<label for="conn-username">{{ t('connections.form.username') }}</label>
|
||||
<input type="text" id="conn-username" v-model="formData.username" required />
|
||||
</div>
|
||||
|
||||
<!-- 认证方式选择 -->
|
||||
<div class="form-group">
|
||||
<label for="conn-password">{{ t('connections.form.password') }}</label>
|
||||
<input type="password" id="conn-password" v-model="formData.password" required />
|
||||
<!-- 提示:MVP 仅支持密码认证 -->
|
||||
<label for="conn-auth-method">{{ t('connections.form.authMethod') }}</label>
|
||||
<select id="conn-auth-method" v-model="formData.auth_method">
|
||||
<option value="password">{{ t('connections.form.authMethodPassword') }}</option>
|
||||
<option value="key">{{ t('connections.form.authMethodKey') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="formError || error" class="error-message">
|
||||
{{ formError || error }} <!-- 保持显示具体错误 -->
|
||||
<!-- 密码输入 (条件渲染) -->
|
||||
<div class="form-group" v-if="formData.auth_method === 'password'">
|
||||
<label for="conn-password">{{ t('connections.form.password') }}</label>
|
||||
<input type="password" id="conn-password" v-model="formData.password" :required="formData.auth_method === 'password'" />
|
||||
</div>
|
||||
|
||||
<!-- 密钥输入 (条件渲染) -->
|
||||
<div v-if="formData.auth_method === 'key'">
|
||||
<div class="form-group">
|
||||
<label for="conn-private-key">{{ t('connections.form.privateKey') }}</label>
|
||||
<textarea id="conn-private-key" v-model="formData.private_key" rows="6" :required="formData.auth_method === 'key'"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="conn-passphrase">{{ t('connections.form.passphrase') }} ({{ t('connections.form.optional') }})</label>
|
||||
<input type="password" id="conn-passphrase" v-model="formData.passphrase" />
|
||||
</div>
|
||||
<div class="form-group" v-if="isEditMode && formData.auth_method === 'key'">
|
||||
<small>{{ t('connections.form.keyUpdateNote') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 显示 storeError 或 formError -->
|
||||
<div v-if="formError || storeError" class="error-message">
|
||||
{{ formError || storeError }}
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" :disabled="isLoading">
|
||||
{{ isLoading ? t('connections.form.adding') : t('connections.form.confirm') }}
|
||||
{{ submitButtonText }} <!-- 使用计算属性 -->
|
||||
</button>
|
||||
<button type="button" @click="emit('close')" :disabled="isLoading">{{ t('connections.form.cancel') }}</button>
|
||||
</div>
|
||||
@@ -136,7 +246,9 @@ label {
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="password"] {
|
||||
input[type="password"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ccc;
|
||||
|
||||
@@ -11,6 +11,9 @@ const connectionsStore = useConnectionsStore();
|
||||
// 使用 storeToRefs 来保持 state 属性的响应性
|
||||
const { connections, isLoading, error } = storeToRefs(connectionsStore);
|
||||
|
||||
// 定义组件发出的事件 (添加 edit-connection)
|
||||
const emit = defineEmits(['edit-connection']);
|
||||
|
||||
// 组件挂载时获取连接列表
|
||||
onMounted(() => {
|
||||
connectionsStore.fetchConnections();
|
||||
@@ -22,6 +25,22 @@ const formatTimestamp = (timestamp: number | null): string => {
|
||||
// TODO: 可以考虑使用更专业的日期格式化库 (如 date-fns 或 dayjs) 并结合 i18n locale
|
||||
return new Date(timestamp * 1000).toLocaleString(); // 乘以 1000 转换为毫秒
|
||||
};
|
||||
|
||||
// 新增:处理删除连接的方法
|
||||
const handleDelete = async (conn: ConnectionInfo) => {
|
||||
// 使用 i18n 获取确认消息
|
||||
const confirmMessage = t('connections.prompts.confirmDelete', { name: conn.name });
|
||||
if (window.confirm(confirmMessage)) {
|
||||
const success = await connectionsStore.deleteConnection(conn.id);
|
||||
if (!success) {
|
||||
// 如果删除失败,显示 store 中的错误信息 (或自定义错误)
|
||||
// 可以考虑使用更友好的提示方式,例如 toast 通知库
|
||||
alert(t('connections.errors.deleteFailed', { error: connectionsStore.error || '未知错误' }));
|
||||
}
|
||||
// 成功时列表会自动更新,无需额外操作
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -54,8 +73,8 @@ const formatTimestamp = (timestamp: number | null): string => {
|
||||
<td>{{ formatTimestamp(conn.last_connected_at) }}</td>
|
||||
<td>
|
||||
<button @click="connectToServer(conn.id)">{{ t('connections.actions.connect') }}</button>
|
||||
<button @click="">{{ t('connections.actions.edit') }}</button> <!-- TODO: 实现编辑逻辑 -->
|
||||
<button @click="">{{ t('connections.actions.delete') }}</button> <!-- TODO: 实现删除逻辑 -->
|
||||
<button @click="emit('edit-connection', conn)">{{ t('connections.actions.edit') }}</button>
|
||||
<button @click="handleDelete(conn)">{{ t('connections.actions.delete') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -57,6 +57,21 @@ const isDraggingOver = ref(false); // State for drag-over visual feedback
|
||||
const sortKey = ref<keyof FileListItem | 'type' | 'size' | 'mtime'>('filename'); // Default sort key
|
||||
const sortDirection = ref<'asc' | 'desc'>('asc'); // Default sort direction
|
||||
|
||||
// --- Column Resizing State ---
|
||||
const tableRef = ref<HTMLTableElement | null>(null);
|
||||
const colWidths = ref({ // Initial widths (adjust as needed)
|
||||
type: 50,
|
||||
name: 300,
|
||||
size: 100,
|
||||
permissions: 120,
|
||||
modified: 180,
|
||||
});
|
||||
const isResizing = ref(false);
|
||||
const resizingColumnIndex = ref(-1);
|
||||
const startX = ref(0);
|
||||
const startWidth = ref(0);
|
||||
|
||||
|
||||
// --- Editor State ---
|
||||
const isEditorVisible = ref(false);
|
||||
const editingFilePath = ref<string | null>(null);
|
||||
@@ -210,17 +225,21 @@ const showContextMenu = (event: MouseEvent, item?: FileListItem) => {
|
||||
];
|
||||
} else if (targetItem && targetItem.filename !== '..') {
|
||||
menu = [
|
||||
{ label: t('fileManager.actions.rename'), action: () => handleRenameClick(targetItem) },
|
||||
{ label: t('fileManager.actions.changePermissions'), action: () => handleChangePermissionsClick(targetItem) },
|
||||
{ label: t('fileManager.actions.delete'), action: handleDeleteClick },
|
||||
{ label: t('fileManager.actions.newFolder'), action: handleNewFolderClick },
|
||||
{ label: t('fileManager.actions.newFile'), action: handleNewFileClick }, // 添加新建文件选项
|
||||
{ label: t('fileManager.actions.upload'), action: triggerFileUpload },
|
||||
{ label: t('fileManager.actions.refresh'), action: () => loadDirectory(currentPath.value) },
|
||||
];
|
||||
if (targetItem.attrs.isFile) {
|
||||
menu.splice(1, 0, { label: t('fileManager.actions.download', { name: targetItem.filename }), action: () => triggerDownload(targetItem) });
|
||||
}
|
||||
menu.push({ label: t('fileManager.actions.refresh'), action: () => loadDirectory(currentPath.value) });
|
||||
if (targetItem.attrs.isFile) {
|
||||
menu.splice(1, 0, { label: t('fileManager.actions.download', { name: targetItem.filename }), action: () => triggerDownload(targetItem) });
|
||||
}
|
||||
// Add Delete option for single item
|
||||
menu.push({ label: t('fileManager.actions.delete'), action: handleDeleteClick });
|
||||
// Removed duplicate refresh: menu.push({ label: t('fileManager.actions.refresh'), action: () => loadDirectory(currentPath.value) });
|
||||
} else if (!targetItem) {
|
||||
menu = [
|
||||
{ label: t('fileManager.actions.newFolder'), action: handleNewFolderClick },
|
||||
{ label: t('fileManager.actions.newFile'), action: handleNewFileClick }, // 添加新建文件选项
|
||||
{ label: t('fileManager.actions.upload'), action: triggerFileUpload },
|
||||
{ label: t('fileManager.actions.refresh'), action: () => loadDirectory(currentPath.value) },
|
||||
];
|
||||
@@ -403,15 +422,26 @@ const handleWebSocketMessage = (event: MessageEvent) => {
|
||||
editingFileContent.value = `// ${editorError.value}`; // Show error in editor
|
||||
}
|
||||
// --- Handle Editor Save Status ---
|
||||
else if (type === 'sftp:writefile:success' && path === editingFilePath.value) {
|
||||
isSaving.value = false;
|
||||
saveStatus.value = 'success';
|
||||
saveError.value = null;
|
||||
// Optionally close editor on successful save, or just show status
|
||||
// closeEditor();
|
||||
// Reset status after a short delay
|
||||
setTimeout(() => { if (saveStatus.value === 'success') saveStatus.value = 'idle'; }, 2000);
|
||||
} else if (type === 'sftp:writefile:error' && path === editingFilePath.value) {
|
||||
else if (type === 'sftp:writefile:success') { // Handle ALL successful writes
|
||||
// Extract parent directory
|
||||
const parentDir = path.substring(0, path.lastIndexOf('/')) || '/';
|
||||
|
||||
// Refresh if the write occurred in the current directory
|
||||
if (parentDir === currentPath.value) {
|
||||
loadDirectory(currentPath.value);
|
||||
}
|
||||
|
||||
// Update editor status ONLY if the saved file is the one being edited
|
||||
if (path === editingFilePath.value) {
|
||||
isSaving.value = false;
|
||||
saveStatus.value = 'success';
|
||||
saveError.value = null;
|
||||
// Optionally close editor on successful save, or just show status
|
||||
// closeEditor();
|
||||
// Reset status after a short delay
|
||||
setTimeout(() => { if (saveStatus.value === 'success') saveStatus.value = 'idle'; }, 2000);
|
||||
}
|
||||
} else if (type === 'sftp:writefile:error' && path === editingFilePath.value) { // Error only relevant if editing this file
|
||||
isSaving.value = false;
|
||||
saveStatus.value = 'error';
|
||||
saveError.value = `${t('fileManager.errors.saveFailed')}: ${payload}`;
|
||||
@@ -633,9 +663,38 @@ const handleNewFolderClick = () => {
|
||||
if (folderName) {
|
||||
const newFolderPath = joinPath(currentPath.value, folderName);
|
||||
props.ws.send(JSON.stringify({ type: 'sftp:mkdir', payload: { path: newFolderPath } }));
|
||||
// 移除立即刷新,依赖 sftp:mkdir:success 消息
|
||||
// loadDirectory(currentPath.value);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理新建文件点击事件
|
||||
const handleNewFileClick = () => {
|
||||
if (!props.ws || props.ws.readyState !== WebSocket.OPEN) return;
|
||||
const fileName = prompt(t('fileManager.prompts.enterFileName'));
|
||||
if (fileName) {
|
||||
// 检查文件名是否已存在
|
||||
if (fileList.value.some(item => item.filename === fileName)) {
|
||||
alert(t('fileManager.errors.fileExists', { name: fileName }));
|
||||
return;
|
||||
}
|
||||
const newFilePath = joinPath(currentPath.value, fileName);
|
||||
// 发送创建空文件的请求到后端 (通过写入空内容)
|
||||
props.ws.send(JSON.stringify({
|
||||
type: 'sftp:writefile',
|
||||
payload: {
|
||||
path: newFilePath,
|
||||
content: '', // 发送空内容来创建文件
|
||||
encoding: 'utf8',
|
||||
}
|
||||
}));
|
||||
// 显式调用刷新,即使成功消息处理程序也会刷新
|
||||
loadDirectory(currentPath.value); // 确保在发送请求后立即尝试刷新
|
||||
// 成功或失败的消息会触发 sftp:writefile:success/error,进而刷新目录
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- Sorting Logic ---
|
||||
const sortedFileList = computed(() => {
|
||||
const list = [...fileList.value]; // Create a shallow copy to avoid mutating original
|
||||
@@ -719,6 +778,61 @@ onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', hideContextMenu, { capture: true });
|
||||
});
|
||||
|
||||
// --- Column Resizing Logic ---
|
||||
const getColumnKeyByIndex = (index: number): keyof typeof colWidths.value | null => {
|
||||
const keys = Object.keys(colWidths.value) as Array<keyof typeof colWidths.value>;
|
||||
return keys[index] ?? null;
|
||||
};
|
||||
|
||||
const startResize = (event: MouseEvent, index: number) => {
|
||||
event.preventDefault(); // Prevent text selection during drag
|
||||
isResizing.value = true;
|
||||
resizingColumnIndex.value = index;
|
||||
startX.value = event.clientX;
|
||||
const colKey = getColumnKeyByIndex(index);
|
||||
if (colKey) {
|
||||
startWidth.value = colWidths.value[colKey];
|
||||
} else {
|
||||
// Fallback or error handling if index is out of bounds
|
||||
const thElement = (event.target as HTMLElement).closest('th');
|
||||
startWidth.value = thElement?.offsetWidth ?? 100; // Estimate if key not found
|
||||
}
|
||||
|
||||
|
||||
document.addEventListener('mousemove', handleResize);
|
||||
document.addEventListener('mouseup', stopResize);
|
||||
document.body.style.cursor = 'col-resize'; // Change cursor globally
|
||||
document.body.style.userSelect = 'none'; // Prevent text selection globally
|
||||
};
|
||||
|
||||
const handleResize = (event: MouseEvent) => {
|
||||
if (!isResizing.value || resizingColumnIndex.value < 0) return;
|
||||
|
||||
const currentX = event.clientX;
|
||||
const diffX = currentX - startX.value;
|
||||
const newWidth = Math.max(30, startWidth.value + diffX); // Minimum width 30px
|
||||
|
||||
const colKey = getColumnKeyByIndex(resizingColumnIndex.value);
|
||||
if (colKey) {
|
||||
colWidths.value[colKey] = newWidth;
|
||||
}
|
||||
// Note: Direct manipulation of <col> width via style might be needed
|
||||
// if reactive updates to :style don't work reliably with table-layout:fixed.
|
||||
// Let's try with reactive refs first.
|
||||
};
|
||||
|
||||
const stopResize = () => {
|
||||
if (isResizing.value) {
|
||||
isResizing.value = false;
|
||||
resizingColumnIndex.value = -1;
|
||||
document.removeEventListener('mousemove', handleResize);
|
||||
document.removeEventListener('mouseup', stopResize);
|
||||
document.body.style.cursor = ''; // Reset cursor
|
||||
document.body.style.userSelect = ''; // Reset text selection
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -734,6 +848,7 @@ onBeforeUnmount(() => {
|
||||
<input type="file" ref="fileInputRef" @change="handleFileSelected" multiple style="display: none;" />
|
||||
<button @click="triggerFileUpload" :disabled="isLoading || !isConnected" :title="t('fileManager.actions.uploadFile')">📤 {{ t('fileManager.actions.upload') }}</button>
|
||||
<button @click="handleNewFolderClick" :disabled="isLoading || !isConnected" :title="t('fileManager.actions.newFolder')">➕ {{ t('fileManager.actions.newFolder') }}</button>
|
||||
<button @click="handleNewFileClick" :disabled="isLoading || !isConnected" :title="t('fileManager.actions.newFile')">📄 {{ t('fileManager.actions.newFile') }}</button> <!-- 新建文件按钮 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -749,25 +864,40 @@ onBeforeUnmount(() => {
|
||||
<div v-if="isLoading && fileList.length === 0" class="loading">{{ t('fileManager.loading') }}</div>
|
||||
<div v-else-if="error" class="error">{{ t('fileManager.errors.generic') }}: {{ error }}</div>
|
||||
|
||||
<table v-if="sortedFileList.length > 0 || currentPath !== '/'" @contextmenu.prevent>
|
||||
<table v-if="sortedFileList.length > 0 || currentPath !== '/'" ref="tableRef" class="resizable-table" @contextmenu.prevent>
|
||||
<colgroup>
|
||||
<col :style="{ width: `${colWidths.type}px` }">
|
||||
<col :style="{ width: `${colWidths.name}px` }">
|
||||
<col :style="{ width: `${colWidths.size}px` }">
|
||||
<col :style="{ width: `${colWidths.permissions}px` }">
|
||||
<col :style="{ width: `${colWidths.modified}px` }">
|
||||
<!-- Add more cols if needed -->
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th @click="handleSort('type')" class="sortable">
|
||||
{{ t('fileManager.headers.type') }}
|
||||
<span v-if="sortKey === 'type'">{{ sortDirection === 'asc' ? '▲' : '▼' }}</span>
|
||||
<span class="resizer" @mousedown.prevent="startResize($event, 0)" @click.stop></span>
|
||||
</th>
|
||||
<th @click="handleSort('filename')" class="sortable">
|
||||
{{ t('fileManager.headers.name') }}
|
||||
<span v-if="sortKey === 'filename'">{{ sortDirection === 'asc' ? '▲' : '▼' }}</span>
|
||||
<span class="resizer" @mousedown.prevent="startResize($event, 1)" @click.stop></span>
|
||||
</th>
|
||||
<th @click="handleSort('size')" class="sortable">
|
||||
{{ t('fileManager.headers.size') }}
|
||||
<span v-if="sortKey === 'size'">{{ sortDirection === 'asc' ? '▲' : '▼' }}</span>
|
||||
<span class="resizer" @mousedown.prevent="startResize($event, 2)" @click.stop></span>
|
||||
</th>
|
||||
<th>{{ t('fileManager.headers.permissions') }}</th> <!-- Permissions not sortable for now -->
|
||||
<th @click="handleSort('mtime')" class="sortable">
|
||||
<th> <!-- Permissions not sortable for now -->
|
||||
{{ t('fileManager.headers.permissions') }}
|
||||
<span class="resizer" @mousedown.prevent="startResize($event, 3)" @click.stop></span>
|
||||
</th>
|
||||
<th @click="handleSort('mtime')" class="sortable"> <!-- Last column doesn't need a resizer -->
|
||||
{{ t('fileManager.headers.modified') }}
|
||||
<span v-if="sortKey === 'mtime'">{{ sortDirection === 'asc' ? '▲' : '▼' }}</span>
|
||||
<!-- No resizer on the last column -->
|
||||
</th>
|
||||
<!-- Removed Actions Header -->
|
||||
</tr>
|
||||
@@ -853,6 +983,7 @@ onBeforeUnmount(() => {
|
||||
:language="editingFileLanguage"
|
||||
theme="vs-dark"
|
||||
class="editor-instance"
|
||||
@request-save="handleSaveFile"
|
||||
/>
|
||||
<!-- Save button added above -->
|
||||
</div>
|
||||
@@ -898,14 +1029,28 @@ onBeforeUnmount(() => {
|
||||
pointer-events: none; /* Allow drop event to pass through */
|
||||
z-index: 2; /* Above table */
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
table.resizable-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed; /* Crucial for resizing */
|
||||
overflow: hidden; /* Prevent resizer overflow */
|
||||
}
|
||||
thead { background-color: #f8f8f8; position: sticky; top: 0; z-index: 1; }
|
||||
th, td { border: 1px solid #eee; padding: 0.4rem 0.6rem; text-align: left; white-space: nowrap; }
|
||||
th, td {
|
||||
border: 1px solid #eee;
|
||||
padding: 0.4rem 0.6rem;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden; /* Hide overflow text */
|
||||
text-overflow: ellipsis; /* Show ellipsis for overflow */
|
||||
}
|
||||
th {
|
||||
position: relative; /* Needed for absolute positioning of resizer */
|
||||
}
|
||||
th.sortable { cursor: pointer; }
|
||||
th.sortable:hover { background-color: #e9e9e9; }
|
||||
/* Set a smaller default width for the first column (Type) */
|
||||
th:first-child, td:first-child {
|
||||
width: 40px; /* Adjust as needed */
|
||||
/* Removed fixed width for first column, handled by colgroup */
|
||||
td:first-child {
|
||||
text-align: center; /* Center the icon */
|
||||
}
|
||||
tbody tr:hover { background-color: #f5f5f5; }
|
||||
@@ -919,6 +1064,22 @@ tbody tr.selected:hover { background-color: #b8daff; }
|
||||
.context-menu li:hover { background-color: #eee; }
|
||||
.context-menu li.disabled { color: #aaa; cursor: not-allowed; background-color: white; }
|
||||
|
||||
/* Resizer Handle Styles */
|
||||
.resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -3px; /* Position slightly outside the cell border */
|
||||
width: 6px; /* Hit area width */
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 2; /* Above cell content */
|
||||
/* background-color: rgba(0, 0, 255, 0.1); */ /* Optional: Make handle visible for debugging */
|
||||
}
|
||||
.resizer:hover {
|
||||
background-color: rgba(0, 100, 255, 0.2); /* Visual feedback on hover */
|
||||
}
|
||||
|
||||
|
||||
/* Editor Styles */
|
||||
.editor-overlay {
|
||||
position: absolute; /* Position over the file list */
|
||||
|
||||
@@ -26,8 +26,8 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
// Emits for v-model update
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
// Emits for v-model update and save request
|
||||
const emit = defineEmits(['update:modelValue', 'request-save']);
|
||||
|
||||
const editorContainer = ref<HTMLElement | null>(null);
|
||||
let editorInstance: monaco.editor.IStandaloneCodeEditor | null = null;
|
||||
@@ -55,6 +55,24 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add Ctrl+S / Cmd+S keybinding for saving
|
||||
editorInstance.addAction({
|
||||
id: 'save-file',
|
||||
label: 'Save File',
|
||||
keybindings: [
|
||||
monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS,
|
||||
],
|
||||
precondition: undefined, // Fix: Use undefined instead of null
|
||||
keybindingContext: undefined, // Fix: Use undefined instead of null
|
||||
contextMenuGroupId: 'navigation', // Optional: where to show in context menu
|
||||
contextMenuOrder: 1.5, // Optional: order in context menu
|
||||
run: () => {
|
||||
console.log('[MonacoEditor] Save action triggered (Ctrl+S / Cmd+S)');
|
||||
emit('request-save');
|
||||
},
|
||||
});
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<div class="status-monitor">
|
||||
<h4>服务器状态</h4>
|
||||
<div v-if="!statusData" class="loading-status">
|
||||
等待数据...
|
||||
</div>
|
||||
<div v-else class="status-grid">
|
||||
<div class="status-item cpu-model">
|
||||
<label>CPU 型号:</label>
|
||||
<span class="cpu-model-value" :title="statusData.cpuModel">{{ statusData.cpuModel ?? 'N/A' }}</span>
|
||||
</div>
|
||||
<!-- Added OS Name Display -->
|
||||
<div class="status-item os-name">
|
||||
<label>系统:</label>
|
||||
<span class="os-name-value" :title="statusData.osName">{{ statusData.osName ?? 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>CPU:</label>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" :style="{ width: `${statusData.cpuPercent ?? 0}%` }"></div>
|
||||
</div>
|
||||
<span>{{ statusData.cpuPercent?.toFixed(1) ?? 'N/A' }}%</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>内存:</label>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" :style="{ width: `${statusData.memPercent ?? 0}%` }"></div>
|
||||
</div>
|
||||
<span class="mem-disk-details">{{ memDisplay }}</span>
|
||||
</div>
|
||||
<!-- Removed v-if, Swap will always show -->
|
||||
<div class="status-item">
|
||||
<label>Swap:</label>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar swap-bar" :style="{ width: `${statusData.swapPercent ?? 0}%` }"></div>
|
||||
</div>
|
||||
<span class="mem-disk-details">{{ swapDisplay }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>磁盘 (/):</label>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" :style="{ width: `${statusData.diskPercent ?? 0}%` }"></div>
|
||||
</div>
|
||||
<span class="mem-disk-details">{{ diskDisplay }}</span>
|
||||
</div>
|
||||
<div class="status-item network-rate">
|
||||
<label>网络 ({{ statusData.netInterface || '...' }}):</label>
|
||||
<span class="rate down">⬇ {{ formatBytesPerSecond(statusData.netRxRate) }}</span>
|
||||
<span class="rate up">⬆ {{ formatBytesPerSecond(statusData.netTxRate) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="error" class="status-error">
|
||||
错误: {{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
// Interface matching the backend's ServerStatusDetails
|
||||
interface ServerStatus {
|
||||
cpuPercent?: number;
|
||||
memPercent?: number;
|
||||
memUsed?: number; // MB
|
||||
memTotal?: number; // MB
|
||||
swapPercent?: number;
|
||||
swapUsed?: number; // MB
|
||||
swapTotal?: number; // MB
|
||||
diskPercent?: number;
|
||||
diskUsed?: number; // KB
|
||||
diskTotal?: number; // KB
|
||||
cpuModel?: string;
|
||||
netRxRate?: number; // Bytes per second
|
||||
netTxRate?: number; // Bytes per second
|
||||
netInterface?: string;
|
||||
osName?: string; // Added OS Name
|
||||
}
|
||||
|
||||
// Props to receive status data from parent
|
||||
const props = defineProps<{
|
||||
statusData: ServerStatus | null;
|
||||
error?: string | null;
|
||||
}>();
|
||||
|
||||
// Helper function to format bytes into appropriate units (B, KB, MB, GB) /s
|
||||
const formatBytesPerSecond = (bytes?: number): string => {
|
||||
if (bytes === undefined || bytes === null || isNaN(bytes)) return 'N/A';
|
||||
if (bytes < 1024) return `${bytes} B/s`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB/s`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB/s`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB/s`;
|
||||
};
|
||||
|
||||
|
||||
// Helper function to format bytes (KB from backend) into GB
|
||||
const formatKbToGb = (kb?: number): string => {
|
||||
if (kb === undefined || kb === null) return 'N/A';
|
||||
if (kb === 0) return '0.0 GB';
|
||||
const gb = kb / 1024 / 1024;
|
||||
return `${gb.toFixed(1)} GB`;
|
||||
};
|
||||
|
||||
// Computed properties for display
|
||||
const memDisplay = computed(() => {
|
||||
const data = props.statusData;
|
||||
if (!data || data.memUsed === undefined || data.memTotal === undefined) return 'N/A';
|
||||
const percent = data.memPercent !== undefined ? `(${data.memPercent.toFixed(1)}%)` : '';
|
||||
// Ensure MB values are displayed without decimals if they are integers
|
||||
const usedMb = Number.isInteger(data.memUsed) ? data.memUsed : data.memUsed.toFixed(1);
|
||||
const totalMb = Number.isInteger(data.memTotal) ? data.memTotal : data.memTotal.toFixed(1);
|
||||
return `${usedMb} MB / ${totalMb} MB ${percent}`;
|
||||
});
|
||||
|
||||
const diskDisplay = computed(() => {
|
||||
const data = props.statusData;
|
||||
if (!data || data.diskUsed === undefined || data.diskTotal === undefined) return 'N/A';
|
||||
// Percentage represents used space
|
||||
const percent = data.diskPercent !== undefined ? `(${data.diskPercent.toFixed(1)}%)` : '';
|
||||
// Display Used / Total
|
||||
return `${formatKbToGb(data.diskUsed)} / ${formatKbToGb(data.diskTotal)} ${percent}`;
|
||||
});
|
||||
|
||||
const swapDisplay = computed(() => {
|
||||
const data = props.statusData;
|
||||
// Handle cases where swap might be undefined or 0
|
||||
const used = data?.swapUsed ?? 0;
|
||||
const total = data?.swapTotal ?? 0;
|
||||
const percentVal = data?.swapPercent ?? 0;
|
||||
|
||||
const percent = `(${percentVal.toFixed(1)}%)`;
|
||||
const usedMb = Number.isInteger(used) ? used : used.toFixed(1);
|
||||
const totalMb = Number.isInteger(total) ? total : total.toFixed(1);
|
||||
return `${usedMb} MB / ${totalMb} MB ${percent}`;
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-monitor {
|
||||
padding: 1rem;
|
||||
border-left: 1px solid #ccc;
|
||||
background-color: #f9f9f9;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.status-monitor h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 0.5rem;
|
||||
font-size: 1em;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.loading-status, .status-error {
|
||||
color: #888;
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.status-error {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: grid;
|
||||
/* Adjusted grid columns for better alignment */
|
||||
grid-template-columns: 65px 1fr auto; /* Label slightly wider */
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Specific style for CPU model row */
|
||||
.status-item.cpu-model {
|
||||
grid-template-columns: 65px 1fr; /* Label, Value */
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem; /* Add some space below CPU model */
|
||||
}
|
||||
.cpu-model-value {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
grid-column: 2 / 4; /* Span across the value and percentage columns */
|
||||
text-align: left;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Specific style for OS name row */
|
||||
.status-item.os-name {
|
||||
grid-template-columns: 65px 1fr; /* Label, Value */
|
||||
/* Ensure the item itself doesn't align right if the parent has text-align */
|
||||
text-align: left;
|
||||
}
|
||||
/* Increased specificity to override generic span rule */
|
||||
.status-item.os-name .os-name-value {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left; /* Explicitly left align text */
|
||||
justify-self: start; /* Align grid item to start */
|
||||
color: #333;
|
||||
min-width: auto; /* Override generic min-width */
|
||||
}
|
||||
|
||||
|
||||
.status-item label {
|
||||
font-weight: bold;
|
||||
color: #555;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
background-color: #e9ecef;
|
||||
border-radius: 0.25rem;
|
||||
height: 1rem; /* Adjust height */
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background-color: #007bff; /* Blue for CPU/Mem/Disk */
|
||||
height: 100%;
|
||||
transition: width 0.3s ease-in-out;
|
||||
text-align: center;
|
||||
color: white;
|
||||
font-size: 0.75em;
|
||||
line-height: 1rem; /* Match container height */
|
||||
}
|
||||
.progress-bar.swap-bar {
|
||||
background-color: #ffc107; /* Yellow for Swap */
|
||||
}
|
||||
|
||||
|
||||
.status-item span:not(.cpu-model-value) { /* Style for percentage spans */
|
||||
font-variant-numeric: tabular-nums; /* Keep numbers aligned */
|
||||
min-width: 45px; /* Ensure space for percentage */
|
||||
text-align: right;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.mem-disk-details {
|
||||
font-size: 0.9em; /* Slightly smaller font for details */
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Network Rate Styles */
|
||||
.status-item.network-rate {
|
||||
grid-template-columns: 65px auto auto; /* Label, Down Rate, Up Rate */
|
||||
margin-top: 0.5rem; /* Add space above network */
|
||||
}
|
||||
.network-rate .rate {
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
min-width: 80px; /* Adjust as needed */
|
||||
}
|
||||
.network-rate .rate.down {
|
||||
color: #28a745; /* Green for download */
|
||||
}
|
||||
.network-rate .rate.up {
|
||||
color: #fd7e14; /* Orange for upload */
|
||||
}
|
||||
|
||||
</style>
|
||||
Reference in New Issue
Block a user