feat(ui): 重设计文件管理器书签与传输面板

新增书签作用域与连接关联,后端为 favorite_paths
补充 scope 和 connection_id 字段及查询写入支持

前端重构书签弹窗与编辑表单,支持本地/云端筛选、
作用域选择与多语言文案更新

文件管理器工具栏改为紧凑图标样式,上传入口合并为
下拉菜单,并新增底部传输面板统一展示上传任务

同时优化 SSH 终端运行态为显式状态机,并为短命令
补充最短可见时间,避免运行中标记闪烁难以感知
This commit is contained in:
yinjianm
2026-05-01 22:54:29 +08:00
parent 96d9950c6b
commit 2233e3fa4f
33 changed files with 1868 additions and 1541 deletions
@@ -23,24 +23,25 @@ const form = ref({
id: '',
path: '',
name: '',
scope: 'global' as 'global' | 'local',
});
const isEditMode = computed(() => !!props.pathData?.id);
const isLoading = ref(false);
const errorMessage = ref<string | null>(null);
watch(() => props.isVisible, (newValue) => {
if (newValue) {
errorMessage.value = null; // Reset error on open
errorMessage.value = null;
if (props.pathData) {
form.value = {
id: props.pathData.id,
path: props.pathData.path,
name: props.pathData.name || ''
form.value = {
id: props.pathData.id,
path: props.pathData.path,
name: props.pathData.name || '',
scope: (props.pathData.scope as 'global' | 'local') || 'global',
};
} else {
form.value = { id: '', path: '', name: '' };
form.value = { id: '', path: '', name: '', scope: 'global' };
}
}
}, { immediate: true });
@@ -50,7 +51,6 @@ const validateForm = (): boolean => {
errorMessage.value = t('favoritePaths.addEditForm.validation.pathRequired', 'Path is required.');
return false;
}
// Add other validation rules if needed
errorMessage.value = null;
return true;
};
@@ -65,12 +65,14 @@ const handleSubmit = async () => {
if (isEditMode.value && form.value.id) {
await favoritePathsStore.updateFavoritePath(form.value.id, {
path: form.value.path,
name: form.value.name || undefined, // Send undefined if empty to allow backend to handle
name: form.value.name || undefined,
scope: form.value.scope,
}, t);
} else {
await favoritePathsStore.addFavoritePath({
path: form.value.path,
name: form.value.name || undefined,
scope: form.value.scope,
}, t);
}
emit('saveSuccess');
@@ -78,37 +80,35 @@ const handleSubmit = async () => {
} catch (error: any) {
console.error('Error saving favorite path:', error);
errorMessage.value = error.message || t('favoritePaths.addEditForm.errors.genericSaveError', 'Failed to save favorite path.');
// Notification is usually handled by the store, but we can show a local error too.
} finally {
isLoading.value = false;
}
};
const closeModal = () => {
if (!isLoading.value) { // Prevent closing while loading
if (!isLoading.value) {
emit('close');
}
};
</script>
<template>
<div
v-if="isVisible"
<div
v-if="isVisible"
class="fixed inset-0 z-[60] flex items-center justify-center bg-[var(--overlay-bg-color)]"
@click.self="closeModal"
>
<div class="bg-background text-foreground shadow-xl rounded-lg w-full max-w-md flex flex-col overflow-hidden m-4 p-6">
<!-- Header -->
<h2 class="m-0 mb-6 text-center text-xl font-semibold">
{{ isEditMode ? t('favoritePaths.addEditForm.editTitle', 'Edit Favorite Path') : t('favoritePaths.addEditForm.addTitle', 'Add New Favorite Path') }}
{{ isEditMode ? t('favoritePaths.addEditForm.editTitle', '编辑书签') : t('favoritePaths.addEditForm.addTitle', '添加书签') }}
</h2>
<!-- Form Body -->
<form @submit.prevent="handleSubmit" class="space-y-4 flex-grow overflow-y-auto">
<div>
<label for="favPath-name" class="block text-sm font-medium text-text-secondary mb-1">
{{ t('favoritePaths.addEditForm.nameLabel', 'Name (Optional)') }}
{{ t('favoritePaths.addEditForm.nameLabel', '名称(可选)') }}
</label>
<input
id="favPath-name"
@@ -121,7 +121,7 @@ const closeModal = () => {
</div>
<div>
<label for="favPath-path" class="block text-sm font-medium text-text-secondary mb-1">
{{ t('favoritePaths.addEditForm.pathLabel', 'Path') }}
{{ t('favoritePaths.addEditForm.pathLabel', '路径') }}
<span class="text-danger ml-0.5">*</span>
</label>
<input
@@ -135,6 +135,41 @@ const closeModal = () => {
/>
</div>
<!-- Scope Selector -->
<div>
<label class="block text-sm font-medium text-text-secondary mb-1.5">
{{ t('favoritePaths.addEditForm.scopeLabel', '记录位置') }}
</label>
<div class="flex gap-2">
<button
type="button"
@click="form.scope = 'local'"
:class="[
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-sm border transition-colors',
form.scope === 'local'
? 'border-blue-500/50 bg-blue-500/10 text-blue-400'
: 'border-border bg-input text-text-secondary hover:border-border hover:bg-white/5'
]"
>
<i class="fas fa-server text-xs"></i>
{{ t('favoritePaths.scopeLocalLabel', '仅当前服务器') }}
</button>
<button
type="button"
@click="form.scope = 'global'"
:class="[
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-sm border transition-colors',
form.scope === 'global'
? 'border-emerald-500/50 bg-emerald-500/10 text-emerald-400'
: 'border-border bg-input text-text-secondary hover:border-border hover:bg-white/5'
]"
>
<i class="fas fa-cloud text-xs"></i>
{{ t('favoritePaths.scopeGlobalLabel', '全局共享') }}
</button>
</div>
</div>
<div v-if="errorMessage" class="text-danger text-sm p-2 bg-danger/10 rounded-md">
{{ errorMessage }}
</div>
@@ -142,30 +177,21 @@ const closeModal = () => {
<!-- Footer -->
<div class="flex justify-end mt-8 pt-4 border-t border-border/50">
<!-- Secondary/Cancel Button -->
<button
type="button"
@click="closeModal"
:disabled="isLoading"
class="py-2 px-5 rounded-lg text-sm font-medium transition-colors duration-150 bg-background border border-border/50 text-text-secondary hover:bg-border hover:text-foreground mr-3">
{{ t('common.cancel', 'Cancel') }}
{{ t('common.cancel', '取消') }}
</button>
<!-- Primary/Submit Button -->
<button
type="submit"
@click="handleSubmit"
:disabled="isLoading || !form.path.trim()"
class="py-2 px-5 rounded-lg text-sm font-semibold transition-colors duration-150 bg-primary text-white border-none shadow-md hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:bg-gray-400 disabled:opacity-70 disabled:cursor-not-allowed">
{{ isLoading ? t('common.saving', 'Saving...') : t('common.save', 'Save') }}
{{ isLoading ? t('common.saving', '保存中...') : t('common.save', '保存') }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
/* Styles are primarily Tailwind based */
.bg-background-hover:hover {
background-color: var(--color-bg-hover); /* Ensure this CSS variable is defined globally or in Tailwind config */
}
</style>
</template>
@@ -7,7 +7,7 @@ import AddEditFavoritePathForm from './AddEditFavoritePathForm.vue';
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents';
import { useConfirmDialog } from '../composables/useConfirmDialog';
const PADDING = 8; // px
const PADDING = 8;
const props = defineProps({
isVisible: {
@@ -34,28 +34,27 @@ const editingPathItem = ref<FavoritePathItem | null>(null);
const modalContentRef = ref<HTMLElement | null>(null);
const modalStyle = ref<Record<string, string>>({});
const scopeTabs = computed(() => [
{ key: 'all' as const, label: t('favoritePaths.scopeAll', '全部') },
{ key: 'local' as const, label: t('favoritePaths.scopeLocal', '本地') },
{ key: 'global' as const, label: t('favoritePaths.scopeGlobal', '云端') },
]);
const filteredPaths = computed(() => {
if (!searchTerm.value) {
return favoritePathsStore.favoritePaths;
}
const lowerSearchTerm = searchTerm.value.toLowerCase();
return favoritePathsStore.favoritePaths.filter(
(p) =>
p.path.toLowerCase().includes(lowerSearchTerm) ||
(p.name && p.name.toLowerCase().includes(lowerSearchTerm))
);
return favoritePathsStore.filteredFavoritePaths.filter(p => {
if (!searchTerm.value) return true;
const lowerSearchTerm = searchTerm.value.toLowerCase();
return p.path.toLowerCase().includes(lowerSearchTerm) ||
(p.name && p.name.toLowerCase().includes(lowerSearchTerm));
});
});
// Computed property for sort button icon and title
const currentSortBy = computed(() => favoritePathsStore.currentSortBy);
const sortButtonIcon = computed(() => {
return currentSortBy.value === 'name' ? 'fas fa-sort-alpha-down' : 'fas fa-clock';
});
const toggleSort = () => {
const newSortBy = currentSortBy.value === 'name' ? 'last_used_at' : 'name';
favoritePathsStore.setSortBy(newSortBy);
@@ -63,11 +62,9 @@ const toggleSort = () => {
const handleItemClick = async (pathItem: FavoritePathItem) => {
try {
// Mark path as used before navigating
await favoritePathsStore.markPathAsUsed(pathItem.id, t);
} catch (error) {
console.error('Failed to mark path as used:', error);
// Optionally, inform the user about the failure, though navigation will still proceed.
}
emit('navigateToPath', pathItem.path);
closeModal();
@@ -109,7 +106,7 @@ const handleSendToTerminal = (pathItem: FavoritePathItem) => {
} else {
console.warn('[FavoritePathsModal] No active session with a terminal manager found to send path to.');
}
closeModal();
closeModal();
};
const closeModal = () => {
@@ -125,58 +122,47 @@ const updatePosition = () => {
const modalWidth = modalContentRef.value.offsetWidth;
const modalHeight = modalContentRef.value.offsetHeight;
// If dimensions are zero when modal is supposed to be visible,
// it might mean content affecting size isn't ready. Retry once.
if (modalWidth === 0 && modalHeight === 0 && props.isVisible) {
nextTick(updatePosition);
return;
}
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let top = triggerRect.bottom + 2; // Default position below trigger, with a small 2px gap
let top = triggerRect.bottom + 2;
let left = triggerRect.left;
// Check for bottom overflow
if (top + modalHeight + PADDING > viewportHeight) {
// Try to position above the trigger
top = triggerRect.top - modalHeight - 2; // Position above trigger, with a small 2px gap
top = triggerRect.top - modalHeight - 2;
}
// If positioning above also causes top overflow (e.g., trigger is near the top and modal is tall)
if (top < PADDING) {
top = PADDING; // Align to viewport top with padding
// Note: If modalHeight is still greater than viewportHeight - 2*PADDING,
// it will overflow downwards. The `max-h-80` class on the modal
// should generally prevent the modal itself from being excessively tall.
top = PADDING;
}
// Check for right overflow
if (left + modalWidth + PADDING > viewportWidth) {
left = viewportWidth - modalWidth - PADDING; // Align to viewport right edge
left = viewportWidth - modalWidth - PADDING;
}
// Check for left overflow (less likely with initial left alignment to trigger, but good for robustness)
if (left < PADDING) {
left = PADDING; // Align to viewport left edge
left = PADDING;
}
modalStyle.value = {
position: 'fixed', // Position relative to the viewport
position: 'fixed',
top: `${top}px`,
left: `${left}px`,
};
};
// --- Click Outside Logic ---
const handleClickOutside = (event: MouseEvent) => {
if (props.triggerElement && props.triggerElement.contains(event.target as Node)) {
return;
}
if (modalContentRef.value && !modalContentRef.value.contains(event.target as Node)) {
if (!showAddEditModal.value) {
if (!showAddEditModal.value) {
closeModal();
}
}
@@ -186,21 +172,21 @@ watch(() => props.isVisible, (newValue: boolean) => {
if (newValue) {
searchTerm.value = '';
document.addEventListener('mousedown', handleClickOutside);
nextTick(() => { // Ensure DOM is ready for measurements
updatePosition(); // Calculate initial position
window.addEventListener('resize', updatePosition); // Adjust position on window resize
nextTick(() => {
updatePosition();
window.addEventListener('resize', updatePosition);
});
} else {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('resize', updatePosition); // Clean up resize listener
window.removeEventListener('resize', updatePosition);
}
});
onMounted(() => {
if (props.isVisible) {
searchTerm.value = '';
searchTerm.value = '';
document.addEventListener('mousedown', handleClickOutside);
nextTick(() => {
nextTick(() => {
updatePosition();
window.addEventListener('resize', updatePosition);
});
@@ -209,94 +195,119 @@ onMounted(() => {
onBeforeUnmount(() => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('resize', updatePosition); // Ensure resize listener is cleaned up
window.removeEventListener('resize', updatePosition);
});
</script>
<template>
<!-- New single root element -->
<div>
<!-- Favorite Paths Dropdown -->
<div
v-if="isVisible"
ref="modalContentRef"
:style="modalStyle"
class="z-50 w-72 md:w-80 rounded-md bg-background shadow-lg border border-border/50 max-h-80 flex flex-col overflow-hidden"
>
<!-- Toolbar: Search and Add Button -->
<div class="p-2 flex-shrink-0 flex items-center gap-2">
<div class="relative flex-grow">
<input
type="text"
v-model="searchTerm"
:placeholder="t('favoritePaths.searchPlaceholder', 'Search by name or path...')"
class="w-full bg-input border border-border rounded-md pl-2.5 pr-2 py-1.5 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-colors"
/>
<!-- Header -->
<div class="flex items-center justify-between px-3 py-2 border-b border-border/40 flex-shrink-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-foreground">{{ t('favoritePaths.title', '书签列表') }}</span>
<span class="text-[10px] bg-primary/20 text-primary px-1.5 rounded-full">{{ favoritePathsStore.favoritePaths.length }}</span>
</div>
<div class="flex items-center gap-1">
<button
@click="toggleSort"
class="flex items-center justify-center w-6 h-6 text-text-secondary rounded hover:text-primary hover:bg-white/5 transition-colors"
:title="currentSortBy === 'name' ? t('favoritePaths.sortByUsage', '按使用排序') : t('favoritePaths.sortByName', '按名称排序')"
>
<i :class="sortButtonIcon" class="text-[11px]"></i>
</button>
<button
@click="openAddModal"
class="flex items-center justify-center w-6 h-6 text-primary rounded hover:bg-primary/10 transition-colors"
:title="t('favoritePaths.addNew', '添加书签')"
>
<i class="fas fa-plus text-[11px]"></i>
</button>
</div>
</div>
<!-- Scope Tabs -->
<div class="flex items-center gap-0.5 px-2 py-1 border-b border-border/30 flex-shrink-0">
<button
@click="toggleSort"
class="flex items-center justify-center w-8 h-8 bg-background border border-border text-text-secondary rounded-lg text-sm cursor-pointer shadow-sm transition-colors duration-200 ease-in-out hover:bg-primary/10 hover:text-primary focus:outline-none flex-shrink-0"
v-for="tab in scopeTabs"
:key="tab.key"
@click="favoritePathsStore.setActiveScope(tab.key)"
:class="[
'px-2 py-0.5 text-[11px] rounded transition-colors',
favoritePathsStore.activeScope === tab.key
? 'bg-primary/15 text-primary font-medium'
: 'text-text-secondary hover:text-foreground hover:bg-white/5'
]"
>
<i :class="sortButtonIcon"></i>
</button>
<button
@click="openAddModal"
class="flex items-center justify-center w-8 h-8 bg-primary text-white border-none rounded-lg text-sm font-semibold cursor-pointer shadow-md transition-colors duration-200 ease-in-out hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary flex-shrink-0"
:title="t('favoritePaths.addNew', 'Add new favorite path')"
>
<i class="fas fa-plus text-base"></i>
{{ tab.label }}
</button>
</div>
<!-- Search -->
<div class="px-2 py-1.5 flex-shrink-0">
<input
type="text"
v-model="searchTerm"
:placeholder="t('favoritePaths.searchPlaceholder', '搜索书签...')"
class="w-full bg-input border border-border rounded px-2 py-1 text-xs outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-colors"
/>
</div>
<!-- Path List -->
<div class="overflow-y-auto flex-grow p-1 text-sm">
<div class="overflow-y-auto flex-grow px-1 pb-1 text-xs">
<div v-if="favoritePathsStore.isLoading && filteredPaths.length === 0" class="p-3 text-center text-text-secondary">
<i class="fas fa-spinner fa-spin mr-1"></i>
{{ t('favoritePaths.loading', 'Loading favorites...') }}
</div>
<div v-else-if="!favoritePathsStore.isLoading && filteredPaths.length === 0" class="p-3 text-center text-text-secondary">
<i class="fas fa-star-half-alt mr-1"></i>
{{ searchTerm ? t('favoritePaths.noResults', 'No matching favorites found.') : t('favoritePaths.noFavorites', 'No favorite paths yet. Add one!') }}
<div v-else-if="!favoritePathsStore.isLoading && filteredPaths.length === 0" class="py-6 text-center text-text-secondary">
<i class="fas fa-bookmark text-lg mb-1 block opacity-40"></i>
{{ searchTerm ? t('favoritePaths.noResults', '未找到匹配的书签') : t('favoritePaths.noFavorites', '暂无书签') }}
</div>
<ul v-else-if="filteredPaths.length > 0" class="list-none m-0 p-0">
<li
<div v-else class="space-y-0.5">
<div
v-for="favPath in filteredPaths"
:key="favPath.id"
class="p-2 hover:bg-primary/10 cursor-pointer group flex items-center justify-between rounded-md transition-colors duration-150"
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white/5 cursor-pointer group transition-colors"
@click="handleItemClick(favPath)"
:title="favPath.path"
>
<div class="flex-grow overflow-hidden mr-2">
<p class="font-medium truncate text-foreground">
{{ favPath.name || favPath.path }}
</p>
<p v-if="favPath.name" class="text-xs text-text-secondary truncate">
{{ favPath.path }}
</p>
<i class="fas fa-bookmark text-[10px] text-primary/60 flex-shrink-0"></i>
<div class="flex-1 overflow-hidden min-w-0">
<div class="flex items-center gap-1.5">
<span class="truncate text-foreground text-[13px]">{{ favPath.name || favPath.path }}</span>
<span v-if="favPath.scope === 'local'" class="text-[9px] px-1 rounded bg-blue-500/15 text-blue-400 flex-shrink-0">{{ t('favoritePaths.scopeLocal', '本地') }}</span>
<span v-else class="text-[9px] px-1 rounded bg-emerald-500/15 text-emerald-400 flex-shrink-0">{{ t('favoritePaths.scopeGlobal', '云端') }}</span>
</div>
<p v-if="favPath.name" class="text-[11px] text-text-secondary/70 truncate mt-0.5">{{ favPath.path }}</p>
</div>
<div class="flex-shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity duration-150">
<div class="flex-shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
@click.stop="handleSendToTerminal(favPath)"
class="p-1.5 rounded text-text-secondary hover:text-primary hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
:title="t('favoritePaths.sendToTerminal', 'Send to Terminal')">
<i class="fas fa-terminal text-xs"></i>
class="p-1 rounded text-text-secondary hover:text-primary hover:bg-white/10 transition-colors"
:title="t('favoritePaths.sendToTerminal', '发送到终端')">
<i class="fas fa-terminal text-[10px]"></i>
</button>
<button
@click.stop="openEditModal(favPath)"
class="p-1.5 rounded text-text-secondary hover:text-primary hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
class="p-1 rounded text-text-secondary hover:text-primary hover:bg-white/10 transition-colors"
:title="t('common.edit')">
<i class="fas fa-pencil-alt text-xs"></i>
<i class="fas fa-pencil-alt text-[10px]"></i>
</button>
<button
@click.stop="handleDelete(favPath)"
class="p-1.5 rounded text-text-secondary hover:text-error hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
class="p-1 rounded text-text-secondary hover:text-red-400 hover:bg-white/10 transition-colors"
:title="t('common.delete')">
<i class="fas fa-trash-alt text-xs"></i>
<i class="fas fa-trash-alt text-[10px]"></i>
</button>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
@@ -308,7 +319,5 @@ onBeforeUnmount(() => {
@close="showAddEditModal = false"
@save-success="() => { favoritePathsStore.fetchFavoritePaths(t); showAddEditModal = false; }"
/>
</div>
</template>
</div>
</template>
@@ -15,6 +15,7 @@ import { useFileManagerDragAndDrop } from '../composables/file-manager/useFileMa
import { useFileManagerKeyboardNavigation } from '../composables/file-manager/useFileManagerKeyboardNavigation';
import { createFolderArchive, type FolderArchiveSource } from '../composables/file-manager/useFolderArchiveUpload';
import FileUploadPopup from './FileUploadPopup.vue';
import TransferPanel from './TransferPanel.vue';
import FileManagerContextMenu from './FileManagerContextMenu.vue';
import FileManagerActionModal from './FileManagerActionModal.vue';
import type { FileListItem } from '../types/sftp.types';
@@ -150,6 +151,8 @@ const editablePath = ref('');
const fileListContainerRef = ref<HTMLDivElement | null>(null); // 文件列表容器引用
const dropOverlayRef = ref<HTMLDivElement | null>(null); // +++ 拖拽蒙版引用 +++
const isFolderUploadBusy = ref(false);
const uploadMenuOpen = ref(false);
const showTransferPanel = ref(false);
// +++ Favorite Paths Modal State +++
const showFavoritePathsModal = ref(false);
@@ -1741,6 +1744,7 @@ onMounted(() => {
};
unregisterPathFocusAction = focusSwitcherStore.registerFocusAction('fileManagerPathInput', focusPathActionWrapper);
document.addEventListener('click', handleClickOutsidePathInput);
document.addEventListener('click', handleClickOutsideUploadMenu);
});
onBeforeUnmount(() => {
@@ -1758,6 +1762,7 @@ onBeforeUnmount(() => {
}
unregisterPathFocusAction = null;
document.removeEventListener('click', handleClickOutsidePathInput);
document.removeEventListener('click', handleClickOutsideUploadMenu);
sessionStore.removeSftpManager(props.sessionId, props.instanceId);
});
@@ -2000,6 +2005,12 @@ const handleClickOutsidePathInput = (event: MouseEvent) => {
}
};
const handleClickOutsideUploadMenu = (event: MouseEvent) => {
if (uploadMenuOpen.value) {
uploadMenuOpen.value = false;
}
};
// --- 搜索框激活/取消逻辑 ---
const activateSearch = () => {
@@ -2368,7 +2379,7 @@ watch(
</div>
</div> <!-- End Wrapper -->
<!-- Main Actions Bar -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="flex items-center gap-0.5 flex-shrink-0">
<input type="file" ref="fileInputRef" @change="handleFileSelected" multiple class="hidden" />
<input type="file" ref="folderInputRef" @change="handleFolderSelected" webkitdirectory directory multiple class="hidden" />
<!-- 打开编辑器按钮 -->
@@ -2377,62 +2388,63 @@ watch(
@click="openPopupEditor"
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value"
:title="t('fileManager.actions.openEditor', 'Open Popup Editor')"
class="flex items-center gap-1 px-2.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-header hover:enabled:border-primary hover:enabled:text-primary"
:class="{ 'px-1.5': props.isMobile }"
class="flex items-center justify-center w-7 h-7 text-text-secondary rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-black/10 hover:enabled:text-foreground"
>
<i class="far fa-edit text-sm"></i> <!-- 使用编辑图标 -->
<span v-if="!props.isMobile">{{ t('fileManager.actions.openEditor', 'Open Editor') }}</span> <!-- 添加 i18n key -->
</button>
<!-- 上传按钮 -->
<button
@click="triggerFileUpload"
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value"
:title="t('fileManager.actions.uploadFile')"
class="flex items-center gap-1 px-2.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-header hover:enabled:border-primary hover:enabled:text-primary"
:class="{ 'px-1.5': props.isMobile }"
>
<i class="fas fa-upload text-sm"></i>
<span v-if="!props.isMobile">{{ t('fileManager.actions.uploadFile') }}</span>
</button>
<button
@click="triggerFolderUpload"
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value || isFolderUploadBusy"
:title="t('fileManager.actions.uploadFolder')"
class="flex items-center gap-1 px-2.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-header hover:enabled:border-primary hover:enabled:text-primary"
:class="{ 'px-1.5': props.isMobile }"
>
<i :class="isFolderUploadBusy ? 'fas fa-spinner fa-spin text-sm' : 'fas fa-folder-open text-sm'"></i>
<span v-if="!props.isMobile">{{ t('fileManager.actions.uploadFolder') }}</span>
<i class="far fa-edit text-sm"></i>
</button>
<!-- 上传下拉菜单 -->
<div class="relative">
<button
@click="uploadMenuOpen = !uploadMenuOpen"
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value"
:title="t('fileManager.actions.uploadFile')"
class="flex items-center justify-center w-7 h-7 text-text-secondary rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-black/10 hover:enabled:text-foreground"
>
<i class="fas fa-upload text-sm"></i>
</button>
<div v-if="uploadMenuOpen" class="absolute right-0 top-full mt-1 bg-background border border-border rounded-md shadow-lg z-50 py-1 min-w-[140px]">
<button
@click="triggerFileUpload(); uploadMenuOpen = false"
class="w-full text-left px-3 py-1.5 text-xs text-foreground hover:bg-primary/10 hover:text-primary transition-colors flex items-center gap-2"
>
<i class="fas fa-file-upload w-4 text-center"></i>
{{ t('fileManager.actions.uploadFile') }}
</button>
<button
@click="triggerFolderUpload(); uploadMenuOpen = false"
:disabled="isFolderUploadBusy"
class="w-full text-left px-3 py-1.5 text-xs text-foreground hover:bg-primary/10 hover:text-primary transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<i :class="isFolderUploadBusy ? 'fas fa-spinner fa-spin w-4 text-center' : 'fas fa-folder-open w-4 text-center'"></i>
{{ t('fileManager.actions.uploadFolder') }}
</button>
</div>
</div>
<button
@click="handleNewFolderContextMenuClick"
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value"
:title="t('fileManager.actions.newFolder')"
class="flex items-center gap-1 px-2.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-header hover:enabled:border-primary hover:enabled:text-primary"
:class="{ 'px-1.5': props.isMobile }"
class="flex items-center justify-center w-7 h-7 text-text-secondary rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-black/10 hover:enabled:text-foreground"
>
<i class="fas fa-folder-plus text-sm"></i>
<span v-if="!props.isMobile">{{ t('fileManager.actions.newFolder') }}</span>
</button>
<button
@click="handleNewFileContextMenuClick"
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value"
:title="t('fileManager.actions.newFile')"
class="flex items-center gap-1 px-2.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-header hover:enabled:border-primary hover:enabled:text-primary"
:class="{ 'px-1.5': props.isMobile }"
class="flex items-center justify-center w-7 h-7 text-text-secondary rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-black/10 hover:enabled:text-foreground"
>
<i class="far fa-file-alt text-sm"></i>
<span v-if="!props.isMobile">{{ t('fileManager.actions.newFile') }}</span>
</button>
<!-- 多选模式切换按钮 (仅移动端) -->
<button
v-if="props.isMobile"
@click="toggleMultiSelectMode"
:title="isMultiSelectMode ? t('fileManager.actions.exitMultiSelect', 'Exit Multi-Select Mode') : t('fileManager.actions.multiSelect', 'Enter Multi-Select Mode')"
class="flex items-center gap-1 px-1.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
class="flex items-center justify-center w-7 h-7 rounded text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
:class="{
'hover:bg-header hover:border-primary hover:text-primary': !isMultiSelectMode,
'bg-primary text-white border-primary': isMultiSelectMode
'text-text-secondary hover:bg-black/10 hover:text-foreground': !isMultiSelectMode,
'bg-primary text-white': isMultiSelectMode
}"
>
<i class="fas fa-check-square text-sm"></i>
@@ -2442,13 +2454,8 @@ watch(
<div class="flex flex-grow min-h-0 overflow-hidden border-t border-border/60">
<div class="flex-1 bg-header/20 flex flex-col min-h-0">
<div class="px-3 py-3 border-b border-border/60">
<div class="flex items-center justify-between gap-2">
<div>
<div class="text-[11px] uppercase tracking-[0.18em] text-text-secondary">{{ t('fileManager.explorer.title', '目录资源管理器') }}</div>
<div class="mt-1 text-xs text-text-secondary">1 {{ t('fileManager.explorer.rootCount', '个根目录') }}</div>
</div>
</div>
<div class="px-2 py-1.5 border-b border-border/60">
<div class="text-[11px] uppercase tracking-[0.15em] text-text-secondary font-medium">{{ t('fileManager.explorer.title', '目录资源管理器') }}</div>
</div>
<div
@@ -2473,24 +2480,24 @@ watch(
{{ t('fileManager.dropFilesHere', 'Drop files here to upload') }}
</div>
<div class="p-2 space-y-1">
<div class="px-1 py-0.5">
<div
v-for="row in explorerTreeRows"
:key="row.id"
:data-drop-path="row.path"
:data-is-directory="row.isDirectory"
:class="[
'group flex items-center gap-2 rounded-lg border px-2 py-1.5 transition-colors cursor-pointer',
'group flex items-center gap-1.5 px-1 py-[3px] transition-colors cursor-pointer rounded-sm',
showExternalDropOverlay && externalDropTargetPath === row.path
? 'border-primary bg-primary/15 text-foreground'
? 'bg-primary/15 text-foreground'
: '',
isExplorerRowActive(row)
? 'bg-primary text-white border-primary shadow-sm'
? 'bg-emerald-600/20 text-emerald-400'
: isExplorerRowRelated(row)
? 'border-primary/20 bg-primary/8 text-foreground'
: 'border-transparent text-text-secondary hover:bg-background hover:text-foreground'
? 'bg-primary/8 text-foreground'
: 'text-text-secondary hover:bg-white/5 hover:text-foreground'
]"
:style="{ paddingLeft: `${0.6 + row.depth * 0.85}rem` }"
:style="{ paddingLeft: `${0.25 + row.depth * 0.75}rem` }"
@click="handleExplorerSelect(row)"
@dblclick="handleExplorerOpen(row)"
@contextmenu.prevent.stop="handleExplorerContextMenu($event, row)"
@@ -2498,42 +2505,38 @@ watch(
<button
v-if="row.isDirectory"
@click.stop="handleExplorerToggle(row)"
class="w-4 h-4 flex items-center justify-center flex-shrink-0 text-[10px]"
class="w-3.5 h-3.5 flex items-center justify-center flex-shrink-0 text-[9px] opacity-60 hover:opacity-100"
>
<i :class="row.expanded ? 'fas fa-chevron-down' : 'fas fa-chevron-right'"></i>
</button>
<span v-else class="w-4 h-4 flex items-center justify-center flex-shrink-0 text-[9px] opacity-60">
<i class="fas fa-minus"></i>
</span>
<span v-else class="w-3.5 h-3.5 flex-shrink-0"></span>
<i
:class="[
row.isDirectory
? (row.isRoot ? 'fas fa-folder-tree' : 'fas fa-folder')
? (row.expanded ? 'fas fa-folder-open' : 'fas fa-folder')
: getFileIconClassBase(row.name),
'w-4 text-center flex-shrink-0',
isExplorerRowActive(row) ? 'text-white' : (row.isDirectory ? 'text-primary' : 'text-text-secondary')
'w-4 text-center flex-shrink-0 text-xs',
isExplorerRowActive(row)
? 'text-emerald-400'
: (row.isDirectory ? 'text-yellow-500' : 'text-text-secondary/70')
]"
></i>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium" :title="row.description || row.path">{{ row.name }}</div>
<div
v-if="row.isRoot || !row.isDirectory"
class="truncate text-[10px]"
:class="isExplorerRowActive(row) ? 'text-white/75' : 'text-text-secondary/80'"
>
{{ row.path }}
</div>
</div>
<span class="truncate text-[13px] leading-tight" :title="row.description || row.path">{{ row.name }}<span v-if="row.item?.attrs?.isSymbolicLink && row.description" class="text-text-secondary/60 ml-1 text-[11px]"> {{ row.description }}</span></span>
</div>
</div>
</div>
</div>
</div>
<!-- 使用 FileUploadPopup 组件 -->
<FileUploadPopup :uploads="uploads" @cancel-upload="cancelUpload" />
<!-- Transfer Panel -->
<TransferPanel
:uploads="uploads"
:visible="showTransferPanel"
@update:visible="showTransferPanel = $event"
@cancel-upload="cancelUpload"
/>
<FileManagerContextMenu
ref="contextMenuRef"
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
<template>
<div ref="chartHostRef" class="cpu-history-chart">
<div class="cpu-history-chart__header">
<div ref="chartHostRef" class="cpu-history-chart" :class="{ 'cpu-history-chart--compact': compact }">
<div v-if="!compact" class="cpu-history-chart__header">
<div>
<h6 class="cpu-history-chart__title">{{ t('statusMonitor.cpuUsageTitle') }}</h6>
</div>
@@ -47,6 +47,10 @@ const props = defineProps({
type: Array as PropType<readonly (number | null)[]>,
required: true,
},
compact: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
@@ -98,7 +102,7 @@ const cpuChartOptions = computed<ChartOptions<'line'>>(() => ({
display: false,
},
tooltip: {
enabled: true,
enabled: !props.compact,
mode: 'index',
intersect: false,
callbacks: {
@@ -121,6 +125,7 @@ const cpuChartOptions = computed<ChartOptions<'line'>>(() => ({
},
},
y: {
display: !props.compact,
beginAtZero: true,
min: 0,
max: 100,
@@ -254,4 +259,17 @@ onBeforeUnmount(() => {
flex-direction: column;
}
}
.cpu-history-chart--compact {
padding: 0;
border: none;
border-radius: 0;
background: transparent;
gap: 0;
grid-template-rows: minmax(0, 1fr);
}
.cpu-history-chart--compact .cpu-history-chart__canvas {
height: 100%;
}
</style>
@@ -1,6 +1,6 @@
<template>
<div ref="chartHostRef" class="network-history-chart">
<div class="network-history-chart__header">
<div ref="chartHostRef" class="network-history-chart" :class="{ 'network-history-chart--compact': compact }">
<div v-if="!compact" class="network-history-chart__header">
<div>
<h6 class="network-history-chart__title">
{{ t('statusMonitor.networkSpeedTitleUnit', { unit: networkRateUnitIsMB ? 'MB/s' : 'KB/s' }) }}
@@ -64,6 +64,10 @@ const props = defineProps({
type: Array as PropType<readonly (number | null)[]>,
required: true,
},
compact: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
@@ -167,7 +171,7 @@ const networkChartOptions = computed<ChartOptions<'line'>>(() => ({
display: false,
},
tooltip: {
enabled: true,
enabled: !props.compact,
mode: 'index',
intersect: false,
displayColors: true,
@@ -193,6 +197,7 @@ const networkChartOptions = computed<ChartOptions<'line'>>(() => ({
},
},
y: {
display: !props.compact,
beginAtZero: true,
min: 0,
max: suggestedYAxisMax.value,
@@ -348,4 +353,17 @@ onBeforeUnmount(() => {
justify-content: flex-start;
}
}
.network-history-chart--compact {
padding: 0;
border: none;
border-radius: 0;
background: transparent;
gap: 0;
grid-template-rows: minmax(0, 1fr);
}
.network-history-chart--compact .network-history-chart__canvas {
height: 100%;
}
</style>
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { UploadItem } from '../types/upload.types';
interface TransferTask {
taskId: string;
status: string;
sourceItemName?: string;
progress?: number;
type: 'upload' | 'download' | 'transfer';
}
const props = defineProps<{
uploads: Record<string, UploadItem>;
visible: boolean;
}>();
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void;
(e: 'cancel-upload', uploadId: string): void;
}>();
const { t } = useI18n();
const activeTab = ref<'all' | 'upload' | 'download'>('all');
const normalizedUploadTasks = computed<TransferTask[]>(() => {
return Object.values(props.uploads)
.filter(u => u.status !== 'success' && u.status !== 'cancelled')
.map(u => ({
taskId: u.id,
status: u.status,
sourceItemName: u.filename,
progress: u.progress,
type: 'upload' as const,
}));
});
const filteredTasks = computed(() => {
const all = normalizedUploadTasks.value;
if (activeTab.value === 'upload') return all.filter(t => t.type === 'upload');
if (activeTab.value === 'download') return all.filter(t => t.type === 'download');
return all;
});
const tabs = computed(() => [
{ key: 'all' as const, label: t('transferPanel.tabs.all', '全部'), count: normalizedUploadTasks.value.length },
{ key: 'upload' as const, label: t('transferPanel.tabs.upload', '上传'), count: normalizedUploadTasks.value.filter(t => t.type === 'upload').length },
{ key: 'download' as const, label: t('transferPanel.tabs.download', '下载'), count: normalizedUploadTasks.value.filter(t => t.type === 'download').length },
]);
const statusLabel = (status: string) => {
return t(`fileManager.uploadStatus.${status}`, status);
};
const handleCancel = (taskId: string) => {
emit('cancel-upload', taskId);
};
const togglePanel = () => {
emit('update:visible', !props.visible);
};
</script>
<template>
<div class="border-t border-border/60 flex-shrink-0 bg-header/30">
<div
class="flex items-center justify-between px-2 py-1 cursor-pointer hover:bg-white/5 transition-colors"
@click="togglePanel"
>
<div class="flex items-center gap-2">
<i class="fas fa-exchange-alt text-xs text-text-secondary"></i>
<span class="text-[11px] uppercase tracking-wider text-text-secondary font-medium">{{ t('transferPanel.title', '传输') }}</span>
<span v-if="normalizedUploadTasks.length > 0" class="text-[10px] bg-primary/20 text-primary px-1.5 rounded-full">{{ normalizedUploadTasks.length }}</span>
</div>
<i :class="visible ? 'fas fa-chevron-down' : 'fas fa-chevron-up'" class="text-[10px] text-text-secondary"></i>
</div>
<div v-if="visible" class="max-h-[180px] flex flex-col">
<div class="flex items-center gap-0.5 px-2 py-1 border-t border-border/40">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
:class="[
'px-2 py-0.5 text-[11px] rounded transition-colors',
activeTab === tab.key
? 'bg-primary/15 text-primary font-medium'
: 'text-text-secondary hover:text-foreground hover:bg-white/5'
]"
>
{{ tab.label }}
<span v-if="tab.count > 0" class="ml-1 text-[10px] opacity-70">{{ tab.count }}</span>
</button>
</div>
<div class="flex-1 overflow-y-auto px-2 py-1">
<div v-if="filteredTasks.length === 0" class="text-center py-6 text-text-secondary text-xs">
<i class="fas fa-inbox text-lg mb-1 block opacity-40"></i>
{{ t('transferPanel.empty', '暂无传输任务') }}
</div>
<div v-else class="space-y-1">
<div
v-for="task in filteredTasks"
:key="task.taskId"
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-white/5 text-xs group"
>
<i :class="task.type === 'upload' ? 'fas fa-arrow-up text-emerald-400' : 'fas fa-arrow-down text-blue-400'" class="w-3 text-center flex-shrink-0 text-[10px]"></i>
<span class="truncate flex-1 text-foreground" :title="task.sourceItemName">{{ task.sourceItemName }}</span>
<span class="text-text-secondary text-[10px] flex-shrink-0">{{ statusLabel(task.status) }}</span>
<div v-if="task.progress !== undefined && task.progress < 100" class="w-12 h-1 bg-border rounded-full flex-shrink-0 overflow-hidden">
<div class="h-full bg-primary rounded-full transition-all" :style="{ width: task.progress + '%' }"></div>
</div>
<span v-if="task.progress !== undefined" class="text-[10px] text-text-secondary w-8 text-right flex-shrink-0">{{ task.progress }}%</span>
<button
v-if="['pending', 'uploading', 'compressing'].includes(task.status)"
@click.stop="handleCancel(task.taskId)"
class="opacity-0 group-hover:opacity-100 text-text-secondary hover:text-red-400 transition-all flex-shrink-0"
:title="t('fileManager.actions.cancel')"
>
<i class="fas fa-times text-[10px]"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</template>