feat(ui): 重设计文件管理器书签与传输面板
新增书签作用域与连接关联,后端为 favorite_paths 补充 scope 和 connection_id 字段及查询写入支持 前端重构书签弹窗与编辑表单,支持本地/云端筛选、 作用域选择与多语言文案更新 文件管理器工具栏改为紧凑图标样式,上传入口合并为 下拉菜单,并新增底部传输面板统一展示上传任务 同时优化 SSH 终端运行态为显式状态机,并为短命令 补充最短可见时间,避免运行中标记闪烁难以感知
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user