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>
|
||||
@@ -4,6 +4,13 @@ import { sessions as globalSessionsRef } from '../stores/session/state';
|
||||
import type { Terminal } from 'xterm';
|
||||
import type { SearchAddon, ISearchOptions } from '@xterm/addon-search';
|
||||
import type { WebSocketMessage, MessagePayload } from '../types/websocket.types';
|
||||
import {
|
||||
SSH_COMMAND_RUNTIME_MIN_VISIBLE_MS,
|
||||
createSshCommandRuntimeSnapshot,
|
||||
isSshCommandRuntimeActive,
|
||||
type SshCommandRuntimePhase,
|
||||
type SshCommandRuntimeReason,
|
||||
} from '../stores/session/runtime';
|
||||
|
||||
export interface SshTerminalDependencies {
|
||||
sendMessage: (message: WebSocketMessage) => void;
|
||||
@@ -32,14 +39,41 @@ const stripTerminalControlSequences = (text: string): string =>
|
||||
|
||||
const getSessionState = (sessionId: string) => globalSessionsRef.value.get(sessionId);
|
||||
|
||||
const resetSessionCommandRuntime = (sessionId: string) => {
|
||||
const setSessionCommandRuntime = (
|
||||
sessionId: string,
|
||||
nextPhase: SshCommandRuntimePhase,
|
||||
reason: SshCommandRuntimeReason,
|
||||
timestamp: number,
|
||||
visibleUntil?: number,
|
||||
) => {
|
||||
const session = getSessionState(sessionId);
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRuntime = session.commandRuntime.value ?? createSshCommandRuntimeSnapshot();
|
||||
session.commandRuntime.value = {
|
||||
...currentRuntime,
|
||||
phase: nextPhase,
|
||||
reason,
|
||||
lastTransitionAt: timestamp,
|
||||
visibleUntil: visibleUntil ?? currentRuntime.visibleUntil,
|
||||
};
|
||||
};
|
||||
|
||||
const clearSessionCommandRuntime = (
|
||||
sessionId: string,
|
||||
reason: Extract<SshCommandRuntimeReason, 'prompt' | 'interrupt' | 'disconnect' | 'error' | 'connected' | 'input'>,
|
||||
nextPhase: Extract<SshCommandRuntimePhase, 'idle' | 'disconnected' | 'error'>,
|
||||
timestamp: number,
|
||||
) => {
|
||||
const session = getSessionState(sessionId);
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.isCommandRunning.value = false;
|
||||
session.terminalInputBuffer.value = '';
|
||||
setSessionCommandRuntime(sessionId, nextPhase, reason, timestamp, 0);
|
||||
};
|
||||
|
||||
const syncSessionCommandRuntimeFromInput = (sessionId: string, data: string) => {
|
||||
@@ -53,13 +87,14 @@ const syncSessionCommandRuntimeFromInput = (sessionId: string, data: string) =>
|
||||
return { submittedCommand: false, interrupted: false };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const currentPhase = session.commandRuntime.value.phase;
|
||||
let nextBuffer = session.terminalInputBuffer.value;
|
||||
let submittedCommand = false;
|
||||
let interrupted = false;
|
||||
|
||||
for (const char of normalizedData) {
|
||||
if (char === '\x03') {
|
||||
session.isCommandRunning.value = false;
|
||||
nextBuffer = '';
|
||||
interrupted = true;
|
||||
continue;
|
||||
@@ -67,7 +102,6 @@ const syncSessionCommandRuntimeFromInput = (sessionId: string, data: string) =>
|
||||
|
||||
if (char === '\r' || char === '\n') {
|
||||
if (nextBuffer.trim().length > 0) {
|
||||
session.isCommandRunning.value = true;
|
||||
submittedCommand = true;
|
||||
}
|
||||
nextBuffer = '';
|
||||
@@ -83,14 +117,36 @@ const syncSessionCommandRuntimeFromInput = (sessionId: string, data: string) =>
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nextBuffer.length === 0 && session.isCommandRunning.value) {
|
||||
session.isCommandRunning.value = false;
|
||||
}
|
||||
|
||||
nextBuffer += char;
|
||||
}
|
||||
|
||||
session.terminalInputBuffer.value = nextBuffer;
|
||||
|
||||
if (interrupted) {
|
||||
clearSessionCommandRuntime(sessionId, 'interrupt', 'idle', now);
|
||||
return { submittedCommand: false, interrupted: true };
|
||||
}
|
||||
|
||||
if (submittedCommand) {
|
||||
const nextPhase = isSshCommandRuntimeActive(currentPhase) ? 'running' : 'pending';
|
||||
setSessionCommandRuntime(
|
||||
sessionId,
|
||||
nextPhase,
|
||||
'submit',
|
||||
now,
|
||||
Math.max(session.commandRuntime.value.visibleUntil, now + SSH_COMMAND_RUNTIME_MIN_VISIBLE_MS),
|
||||
);
|
||||
return { submittedCommand: true, interrupted: false };
|
||||
}
|
||||
|
||||
if (nextBuffer.length > 0) {
|
||||
if (!isSshCommandRuntimeActive(currentPhase) && currentPhase !== 'disconnected' && currentPhase !== 'error') {
|
||||
setSessionCommandRuntime(sessionId, 'typing', 'input', now);
|
||||
}
|
||||
} else if (currentPhase === 'typing') {
|
||||
clearSessionCommandRuntime(sessionId, 'input', 'idle', now);
|
||||
}
|
||||
|
||||
return { submittedCommand, interrupted };
|
||||
};
|
||||
|
||||
@@ -139,6 +195,42 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
|
||||
const terminalOutputBuffer = ref<(string | Uint8Array)[]>([]);
|
||||
const isSshConnected = ref(false);
|
||||
const promptProbeBuffer = ref('');
|
||||
let runtimeResolutionTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const clearRuntimeResolutionTimer = () => {
|
||||
if (runtimeResolutionTimer !== null) {
|
||||
clearTimeout(runtimeResolutionTimer);
|
||||
runtimeResolutionTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveRuntimePhase = (
|
||||
nextPhase: Extract<SshCommandRuntimePhase, 'idle' | 'disconnected' | 'error'>,
|
||||
reason: Extract<SshCommandRuntimeReason, 'connected' | 'disconnect' | 'error'>,
|
||||
) => {
|
||||
clearRuntimeResolutionTimer();
|
||||
clearSessionCommandRuntime(sessionId, reason, nextPhase, Date.now());
|
||||
};
|
||||
|
||||
const schedulePromptResolution = () => {
|
||||
const session = getSessionState(sessionId);
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearRuntimeResolutionTimer();
|
||||
const now = Date.now();
|
||||
const delay = Math.max(0, session.commandRuntime.value.visibleUntil - now);
|
||||
if (delay === 0) {
|
||||
clearSessionCommandRuntime(sessionId, 'prompt', 'idle', now);
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeResolutionTimer = setTimeout(() => {
|
||||
runtimeResolutionTimer = null;
|
||||
clearSessionCommandRuntime(sessionId, 'prompt', 'idle', Date.now());
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const getTerminalText = (key: string, params?: Record<string, unknown>): string => {
|
||||
const translationKey = `workspace.terminal.${key}`;
|
||||
@@ -176,6 +268,10 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
|
||||
const runtimeUpdate = syncSessionCommandRuntimeFromInput(sessionId, data);
|
||||
if (runtimeUpdate.submittedCommand || runtimeUpdate.interrupted) {
|
||||
promptProbeBuffer.value = '';
|
||||
clearRuntimeResolutionTimer();
|
||||
} else if (runtimeResolutionTimer !== null && (getSessionState(sessionId)?.terminalInputBuffer.value.length ?? 0) > 0) {
|
||||
clearRuntimeResolutionTimer();
|
||||
setSessionCommandRuntime(sessionId, 'typing', 'input', Date.now(), 0);
|
||||
}
|
||||
sendMessage({ type: 'ssh:input', sessionId, payload: { data } });
|
||||
};
|
||||
@@ -222,12 +318,21 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
|
||||
terminalOutputBuffer.value.push(outputData);
|
||||
}
|
||||
|
||||
if (getSessionState(sessionId)?.isCommandRunning.value) {
|
||||
const session = getSessionState(sessionId);
|
||||
if (session && isSshCommandRuntimeActive(session.commandRuntime.value.phase)) {
|
||||
const promptProbeText = getPromptProbeText(outputData);
|
||||
if (promptProbeText) {
|
||||
clearRuntimeResolutionTimer();
|
||||
promptProbeBuffer.value = `${promptProbeBuffer.value}${promptProbeText}`.slice(-320);
|
||||
if (isPromptTail(promptProbeBuffer.value)) {
|
||||
resetSessionCommandRuntime(sessionId);
|
||||
schedulePromptResolution();
|
||||
promptProbeBuffer.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const strippedOutput = stripTerminalControlSequences(promptProbeText).trim();
|
||||
if (strippedOutput && session.commandRuntime.value.phase === 'pending') {
|
||||
setSessionCommandRuntime(sessionId, 'running', 'output', Date.now());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,6 +346,7 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] SSH 会话已连接。 Payload:`, payload, 'Full message:', message);
|
||||
isSshConnected.value = true;
|
||||
promptProbeBuffer.value = '';
|
||||
resolveRuntimePhase('idle', 'connected');
|
||||
terminalInstance.value?.focus();
|
||||
|
||||
if (terminalInstance.value) {
|
||||
@@ -271,7 +377,7 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] SSH 会话已断开:`, reason);
|
||||
isSshConnected.value = false;
|
||||
promptProbeBuffer.value = '';
|
||||
resetSessionCommandRuntime(sessionId);
|
||||
resolveRuntimePhase('disconnected', 'disconnect');
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('disconnectMsg', { reason })}\x1b[0m`);
|
||||
};
|
||||
|
||||
@@ -284,7 +390,7 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
|
||||
console.error(`[会话 ${sessionId}][SSH终端模块] SSH 错误:`, errorMsg);
|
||||
isSshConnected.value = false;
|
||||
promptProbeBuffer.value = '';
|
||||
resetSessionCommandRuntime(sessionId);
|
||||
resolveRuntimePhase('error', 'error');
|
||||
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('genericErrorMsg', { message: errorMsg })}\x1b[0m`);
|
||||
};
|
||||
|
||||
@@ -339,6 +445,7 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
|
||||
registerSshHandlers();
|
||||
|
||||
const cleanup = () => {
|
||||
clearRuntimeResolutionTimer();
|
||||
unregisterAllSshHandlers();
|
||||
terminalInstance.value = null;
|
||||
console.log(`[会话 ${sessionId}][SSH终端模块] 已清理。`);
|
||||
@@ -352,6 +459,10 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
|
||||
const runtimeUpdate = syncSessionCommandRuntimeFromInput(sessionId, data);
|
||||
if (runtimeUpdate.submittedCommand || runtimeUpdate.interrupted) {
|
||||
promptProbeBuffer.value = '';
|
||||
clearRuntimeResolutionTimer();
|
||||
} else if (runtimeResolutionTimer !== null && (getSessionState(sessionId)?.terminalInputBuffer.value.length ?? 0) > 0) {
|
||||
clearRuntimeResolutionTimer();
|
||||
setSessionCommandRuntime(sessionId, 'typing', 'input', Date.now(), 0);
|
||||
}
|
||||
sendMessage({ type: 'ssh:input', sessionId, payload: { data } });
|
||||
};
|
||||
|
||||
@@ -1861,6 +1861,15 @@
|
||||
"unknownTargetPath": "[Unknown Target Path]",
|
||||
"taskIdFallback": "Task ID: {taskId}"
|
||||
},
|
||||
"transferPanel": {
|
||||
"title": "Transfer",
|
||||
"tabs": {
|
||||
"all": "All",
|
||||
"upload": "Upload",
|
||||
"download": "Download"
|
||||
},
|
||||
"empty": "No transfer tasks"
|
||||
},
|
||||
"sendFilesModal": {
|
||||
"title": "Send Files",
|
||||
"searchConnectionsPlaceholder": "Search connections...",
|
||||
@@ -1885,34 +1894,45 @@
|
||||
"transferFailedError": "Failed to initiate transfer. Please try again."
|
||||
},
|
||||
"favoritePaths": {
|
||||
"title": "Bookmarks",
|
||||
"addEditForm": {
|
||||
"validation": {
|
||||
"pathRequired": "Path is required."
|
||||
},
|
||||
"editTitle": "Edit Favorite Path",
|
||||
"addTitle": "Add New Favorite Path",
|
||||
"editTitle": "Edit Bookmark",
|
||||
"addTitle": "Add Bookmark",
|
||||
"pathLabel": "Path",
|
||||
"pathPlaceholder": "/example/folder/path",
|
||||
"nameLabel": "Name (Optional)",
|
||||
"namePlaceholder": "My Documents",
|
||||
"scopeLabel": "Scope",
|
||||
"errors": {
|
||||
"genericSaveError": "Failed to save favorite path."
|
||||
"genericSaveError": "Failed to save bookmark."
|
||||
}
|
||||
},
|
||||
"confirmDelete": "Are you sure you want to delete \"{name}\"?",
|
||||
"searchPlaceholder": "Search by name or path...",
|
||||
"addNew": "Add new favorite path",
|
||||
"loading": "Loading favorites...",
|
||||
"noResults": "No matching favorites found.",
|
||||
"noFavorites": "No favorite paths yet. Add one!",
|
||||
"searchPlaceholder": "Search bookmarks...",
|
||||
"addNew": "Add Bookmark",
|
||||
"loading": "Loading bookmarks...",
|
||||
"noResults": "No matching bookmarks found.",
|
||||
"noFavorites": "No bookmarks yet",
|
||||
"sendToTerminal": "Send to Terminal",
|
||||
"sortByName": "Sort by name",
|
||||
"sortByUsage": "Sort by usage",
|
||||
"scopeAll": "All",
|
||||
"scopeLocal": "Local",
|
||||
"scopeGlobal": "Cloud",
|
||||
"scopeLocalLabel": "This server only",
|
||||
"scopeGlobalLabel": "Global shared",
|
||||
"notifications": {
|
||||
"fetchError": "Failed to load favorite paths.",
|
||||
"addSuccess": "Favorite path added successfully.",
|
||||
"addError": "Failed to add favorite path.",
|
||||
"updateSuccess": "Favorite path updated successfully.",
|
||||
"updateError": "Failed to update favorite path.",
|
||||
"deleteSuccess": "Favorite path deleted successfully.",
|
||||
"deleteError": "Failed to delete favorite path."
|
||||
"fetchError": "Failed to load bookmarks.",
|
||||
"addSuccess": "Bookmark added successfully.",
|
||||
"addError": "Failed to add bookmark.",
|
||||
"updateSuccess": "Bookmark updated successfully.",
|
||||
"updateError": "Failed to update bookmark.",
|
||||
"deleteSuccess": "Bookmark deleted successfully.",
|
||||
"deleteError": "Failed to delete bookmark.",
|
||||
"markAsUsedError": "Failed to update usage time."
|
||||
}
|
||||
},
|
||||
"pathHistory": {
|
||||
|
||||
@@ -1799,6 +1799,15 @@
|
||||
"unknownTargetPath": "[宛先パス不明]",
|
||||
"taskIdFallback": "タスクID: {taskId}"
|
||||
},
|
||||
"transferPanel": {
|
||||
"title": "転送",
|
||||
"tabs": {
|
||||
"all": "すべて",
|
||||
"upload": "アップロード",
|
||||
"download": "ダウンロード"
|
||||
},
|
||||
"empty": "転送タスクはありません"
|
||||
},
|
||||
"sendFilesModal": {
|
||||
"title": "ファイル送信",
|
||||
"searchConnectionsPlaceholder": "接続を検索...",
|
||||
@@ -1823,34 +1832,45 @@
|
||||
"transferFailedError": "転送の開始に失敗しました。もう一度お試しください。"
|
||||
},
|
||||
"favoritePaths": {
|
||||
"title": "ブックマーク",
|
||||
"addEditForm": {
|
||||
"validation": {
|
||||
"pathRequired": "Path is required."
|
||||
"pathRequired": "パスは必須です。"
|
||||
},
|
||||
"editTitle": "Edit Favorite Path",
|
||||
"addTitle": "Add New Favorite Path",
|
||||
"pathLabel": "Path",
|
||||
"editTitle": "ブックマークを編集",
|
||||
"addTitle": "ブックマークを追加",
|
||||
"pathLabel": "パス",
|
||||
"pathPlaceholder": "/example/folder/path",
|
||||
"nameLabel": "Name (Optional)",
|
||||
"namePlaceholder": "My Documents",
|
||||
"nameLabel": "名前(任意)",
|
||||
"namePlaceholder": "マイドキュメント",
|
||||
"scopeLabel": "保存場所",
|
||||
"errors": {
|
||||
"genericSaveError": "Failed to save favorite path."
|
||||
"genericSaveError": "ブックマークの保存に失敗しました。"
|
||||
}
|
||||
},
|
||||
"confirmDelete": "Are you sure you want to delete \"{name}\"?",
|
||||
"searchPlaceholder": "Search by name or path...",
|
||||
"addNew": "Add new favorite path",
|
||||
"loading": "Loading favorites...",
|
||||
"noResults": "No matching favorites found.",
|
||||
"noFavorites": "No favorite paths yet. Add one!",
|
||||
"confirmDelete": "「{name}」を削除してもよろしいですか?",
|
||||
"searchPlaceholder": "ブックマークを検索...",
|
||||
"addNew": "ブックマークを追加",
|
||||
"loading": "ブックマークを読み込み中...",
|
||||
"noResults": "一致するブックマークが見つかりません。",
|
||||
"noFavorites": "ブックマークはありません",
|
||||
"sendToTerminal": "ターミナルに送信",
|
||||
"sortByName": "名前順",
|
||||
"sortByUsage": "使用順",
|
||||
"scopeAll": "すべて",
|
||||
"scopeLocal": "ローカル",
|
||||
"scopeGlobal": "クラウド",
|
||||
"scopeLocalLabel": "このサーバーのみ",
|
||||
"scopeGlobalLabel": "グローバル共有",
|
||||
"notifications": {
|
||||
"fetchError": "Failed to load favorite paths.",
|
||||
"addSuccess": "Favorite path added successfully.",
|
||||
"addError": "Failed to add favorite path.",
|
||||
"updateSuccess": "Favorite path updated successfully.",
|
||||
"updateError": "Failed to update favorite path.",
|
||||
"deleteSuccess": "Favorite path deleted successfully.",
|
||||
"deleteError": "Failed to delete favorite path."
|
||||
"fetchError": "ブックマークの読み込みに失敗しました。",
|
||||
"addSuccess": "ブックマークを追加しました。",
|
||||
"addError": "ブックマークの追加に失敗しました。",
|
||||
"updateSuccess": "ブックマークを更新しました。",
|
||||
"updateError": "ブックマークの更新に失敗しました。",
|
||||
"deleteSuccess": "ブックマークを削除しました。",
|
||||
"deleteError": "ブックマークの削除に失敗しました。",
|
||||
"markAsUsedError": "使用時間の更新に失敗しました。"
|
||||
}
|
||||
},
|
||||
"pathHistory": {
|
||||
|
||||
@@ -1834,6 +1834,15 @@
|
||||
"unknownTargetPath": "[目标路径未知]",
|
||||
"taskIdFallback": "任务ID: {taskId}"
|
||||
},
|
||||
"transferPanel": {
|
||||
"title": "传输",
|
||||
"tabs": {
|
||||
"all": "全部",
|
||||
"upload": "上传",
|
||||
"download": "下载"
|
||||
},
|
||||
"empty": "暂无传输任务"
|
||||
},
|
||||
"sendFilesModal": {
|
||||
"title": "发送文件",
|
||||
"searchConnectionsPlaceholder": "搜索连接...",
|
||||
@@ -1890,34 +1899,45 @@
|
||||
}
|
||||
},
|
||||
"favoritePaths": {
|
||||
"title": "书签列表",
|
||||
"addEditForm": {
|
||||
"validation": {
|
||||
"pathRequired": "路径不能为空。"
|
||||
},
|
||||
"editTitle": "编辑收藏路径",
|
||||
"addTitle": "添加新收藏路径",
|
||||
"editTitle": "编辑书签",
|
||||
"addTitle": "添加书签",
|
||||
"pathLabel": "路径",
|
||||
"pathPlaceholder": "/example/folder/path",
|
||||
"nameLabel": "名称 (可选)",
|
||||
"nameLabel": "名称(可选)",
|
||||
"namePlaceholder": "我的文档",
|
||||
"scopeLabel": "记录位置",
|
||||
"errors": {
|
||||
"genericSaveError": "保存收藏路径失败。"
|
||||
"genericSaveError": "保存书签失败。"
|
||||
}
|
||||
},
|
||||
"confirmDelete": "您确定要删除 \"{name}\" 吗?",
|
||||
"searchPlaceholder": "按名称或路径搜索...",
|
||||
"addNew": "添加新收藏路径",
|
||||
"loading": "正在加载收藏...",
|
||||
"noResults": "未找到匹配的收藏。",
|
||||
"noFavorites": "还没有收藏路径,快添加一个吧!",
|
||||
"searchPlaceholder": "搜索书签...",
|
||||
"addNew": "添加书签",
|
||||
"loading": "正在加载书签...",
|
||||
"noResults": "未找到匹配的书签。",
|
||||
"noFavorites": "暂无书签",
|
||||
"sendToTerminal": "发送到终端",
|
||||
"sortByName": "按名称排序",
|
||||
"sortByUsage": "按使用排序",
|
||||
"scopeAll": "全部",
|
||||
"scopeLocal": "本地",
|
||||
"scopeGlobal": "云端",
|
||||
"scopeLocalLabel": "仅当前服务器",
|
||||
"scopeGlobalLabel": "全局共享",
|
||||
"notifications": {
|
||||
"fetchError": "加载收藏路径失败。",
|
||||
"addSuccess": "收藏路径添加成功。",
|
||||
"addError": "添加收藏路径失败。",
|
||||
"updateSuccess": "收藏路径更新成功。",
|
||||
"updateError": "更新收藏路径失败。",
|
||||
"deleteSuccess": "收藏路径删除成功。",
|
||||
"deleteError": "删除收藏路径失败。"
|
||||
"fetchError": "加载书签失败。",
|
||||
"addSuccess": "书签添加成功。",
|
||||
"addError": "添加书签失败。",
|
||||
"updateSuccess": "书签更新成功。",
|
||||
"updateError": "更新书签失败。",
|
||||
"deleteSuccess": "书签删除成功。",
|
||||
"deleteError": "删除书签失败。",
|
||||
"markAsUsedError": "更新使用时间失败。"
|
||||
}
|
||||
},
|
||||
"pathHistory": {
|
||||
|
||||
@@ -9,8 +9,9 @@ export interface FavoritePathItem {
|
||||
id: string;
|
||||
path: string;
|
||||
name?: string;
|
||||
last_used_at?: number | null; // Added last_used_at
|
||||
// Add other relevant fields from the API if any
|
||||
scope?: string;
|
||||
connection_id?: number | null;
|
||||
last_used_at?: number | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
@@ -20,7 +21,8 @@ export interface FavoritePathsState {
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
searchTerm: string;
|
||||
currentSortBy: FavoritePathSortType;
|
||||
currentSortBy: FavoritePathSortType;
|
||||
activeScope: 'all' | 'local' | 'global';
|
||||
isInitialized: boolean;
|
||||
}
|
||||
|
||||
@@ -33,18 +35,24 @@ export const useFavoritePathsStore = defineStore('favoritePaths', {
|
||||
error: null,
|
||||
searchTerm: '',
|
||||
currentSortBy: savedSortBy || 'name',
|
||||
activeScope: 'all',
|
||||
isInitialized: false,
|
||||
};
|
||||
},
|
||||
getters: {
|
||||
// The filteredFavoritePaths getter will now operate on the already sorted list
|
||||
filteredFavoritePaths(state): FavoritePathItem[] {
|
||||
let paths = state.favoritePaths;
|
||||
if (state.activeScope === 'local') {
|
||||
paths = paths.filter(fav => fav.scope === 'local');
|
||||
} else if (state.activeScope === 'global') {
|
||||
paths = paths.filter(fav => !fav.scope || fav.scope === 'global');
|
||||
}
|
||||
if (!state.searchTerm) {
|
||||
return state.favoritePaths;
|
||||
return paths;
|
||||
}
|
||||
const lowerCaseSearchTerm = state.searchTerm.toLowerCase();
|
||||
// Note: state.favoritePaths is now always sorted by this.currentSortBy
|
||||
return state.favoritePaths.filter(fav =>
|
||||
return paths.filter(fav =>
|
||||
fav.path.toLowerCase().includes(lowerCaseSearchTerm) ||
|
||||
(fav.name && fav.name.toLowerCase().includes(lowerCaseSearchTerm))
|
||||
);
|
||||
@@ -101,7 +109,10 @@ export const useFavoritePathsStore = defineStore('favoritePaths', {
|
||||
setSortBy(sortBy: FavoritePathSortType) {
|
||||
this.currentSortBy = sortBy;
|
||||
localStorage.setItem('favoritePathSortBy', sortBy);
|
||||
this._sortFavoritePaths(); // Re-sort locally
|
||||
this._sortFavoritePaths();
|
||||
},
|
||||
setActiveScope(scope: 'all' | 'local' | 'global') {
|
||||
this.activeScope = scope;
|
||||
},
|
||||
async markPathAsUsed(pathId: string, t: (key: string, defaultMessage: string) => string) {
|
||||
const notificationsStore = useUiNotificationsStore();
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createSshTerminalManager, type SshTerminalDependencies } from '../../..
|
||||
import { createStatusMonitorManager, type StatusMonitorDependencies } from '../../../composables/useStatusMonitor';
|
||||
import { createDockerManager, type DockerManagerDependencies } from '../../../composables/useDockerManager';
|
||||
import { registerSshSuspendHandlers } from './sshSuspendActions';
|
||||
import { createSshCommandRuntimeSnapshot } from '../runtime';
|
||||
|
||||
const SESSION_ORDER_STORAGE_KEY = 'sessionOrder';
|
||||
|
||||
@@ -148,7 +149,7 @@ export const openNewSession = (
|
||||
editorTabs: ref([]),
|
||||
activeEditorTabId: ref(null),
|
||||
commandInputContent: ref(''),
|
||||
isCommandRunning: ref(false),
|
||||
commandRuntime: ref(createSshCommandRuntimeSnapshot()),
|
||||
terminalInputBuffer: ref(''),
|
||||
isMarkedForSuspend: false,
|
||||
createdAt: Date.now(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { sessions, activeSessionId } from './state';
|
||||
import type { SessionState, SessionTabInfoWithStatus } from './types';
|
||||
import { isSshCommandRuntimeActive } from './runtime';
|
||||
|
||||
export const sessionTabs = computed(() => {
|
||||
return Array.from(sessions.value.values()).map((session) => ({
|
||||
@@ -54,7 +55,8 @@ export const sessionTabsWithStatus = computed((): SessionTabInfoWithStatus[] =>
|
||||
terminalIndex: session.terminalIndex,
|
||||
status: session.wsManager.connectionStatus.value,
|
||||
isMarkedForSuspend: session.isMarkedForSuspend,
|
||||
isCommandRunning: session.isCommandRunning.value,
|
||||
commandRuntimePhase: session.commandRuntime.value.phase,
|
||||
isCommandRunning: isSshCommandRuntimeActive(session.commandRuntime.value.phase),
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
export const SSH_COMMAND_RUNTIME_MIN_VISIBLE_MS = 350;
|
||||
|
||||
export type SshCommandRuntimePhase =
|
||||
| 'idle'
|
||||
| 'typing'
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'disconnected'
|
||||
| 'error';
|
||||
|
||||
export type SshCommandRuntimeReason =
|
||||
| 'init'
|
||||
| 'connected'
|
||||
| 'input'
|
||||
| 'submit'
|
||||
| 'output'
|
||||
| 'prompt'
|
||||
| 'interrupt'
|
||||
| 'disconnect'
|
||||
| 'error';
|
||||
|
||||
export interface SshCommandRuntimeSnapshot {
|
||||
phase: SshCommandRuntimePhase;
|
||||
reason: SshCommandRuntimeReason;
|
||||
lastTransitionAt: number;
|
||||
visibleUntil: number;
|
||||
}
|
||||
|
||||
export const createSshCommandRuntimeSnapshot = (
|
||||
overrides: Partial<SshCommandRuntimeSnapshot> = {},
|
||||
): SshCommandRuntimeSnapshot => ({
|
||||
phase: 'idle',
|
||||
reason: 'init',
|
||||
lastTransitionAt: 0,
|
||||
visibleUntil: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const isSshCommandRuntimeActive = (phase: SshCommandRuntimePhase): boolean =>
|
||||
phase === 'pending' || phase === 'running';
|
||||
@@ -6,6 +6,7 @@ import type { createWebSocketConnectionManager } from '../../composables/useWebS
|
||||
import type { createSftpActionsManager } from '../../composables/useSftpActions';
|
||||
import type { createSshTerminalManager } from '../../composables/useSshTerminal';
|
||||
import type { createStatusMonitorManager } from '../../composables/useStatusMonitor';
|
||||
import type { SshCommandRuntimePhase, SshCommandRuntimeSnapshot } from './runtime';
|
||||
|
||||
export type WsManagerInstance = ReturnType<typeof createWebSocketConnectionManager>;
|
||||
export type SftpManagerInstance = ReturnType<typeof createSftpActionsManager>;
|
||||
@@ -27,7 +28,7 @@ export interface SessionState {
|
||||
editorTabs: Ref<FileTab[]>;
|
||||
activeEditorTabId: Ref<string | null>;
|
||||
commandInputContent: Ref<string>;
|
||||
isCommandRunning: Ref<boolean>;
|
||||
commandRuntime: Ref<SshCommandRuntimeSnapshot>;
|
||||
terminalInputBuffer: Ref<string>;
|
||||
isResuming?: boolean;
|
||||
isMarkedForSuspend?: boolean;
|
||||
@@ -43,5 +44,6 @@ export interface SessionTabInfoWithStatus {
|
||||
terminalIndex: number;
|
||||
status: WsConnectionStatus;
|
||||
isMarkedForSuspend?: boolean;
|
||||
commandRuntimePhase: SshCommandRuntimePhase;
|
||||
isCommandRunning: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user