Compare commits

..

2 Commits

Author SHA1 Message Date
liumangmang
93cc13ddd0 docs: 添加 sftp 标签页规格与计划文档 2026-03-24 17:52:28 +08:00
liumangmang
43207e24bf feat: 为文件视图添加侧边栏标签页 2026-03-24 17:34:27 +08:00
6 changed files with 753 additions and 134 deletions

View File

@@ -0,0 +1,201 @@
# SFTP Sidebar Tabs Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add non-duplicated, session-scoped SFTP tabs in the sidebar so users can return to file sessions without losing tab entries.
**Architecture:** Introduce a dedicated Pinia store (`sftpTabs`) parallel to `terminalTabs`, then wire it into `ConnectionsView`, `MainLayout`, and `SftpView`. Keep `/sftp/:id` as the single SFTP route and use route-driven synchronization so tab switching updates the view correctly without stale state.
**Tech Stack:** Vue 3, TypeScript, Pinia, Vue Router, Vite, vue-tsc
---
## File Structure
- Create: `frontend/src/stores/sftpTabs.ts`
- Owns SFTP tab state and actions (`openOrFocus`, `activate`, `close`).
- Modify: `frontend/src/views/ConnectionsView.vue`
- Registers/focuses SFTP tab before routing to `/sftp/:id`.
- Modify: `frontend/src/layouts/MainLayout.vue`
- Renders SFTP sidebar tabs, handles click/close navigation behavior.
- Modify: `frontend/src/views/SftpView.vue`
- Keeps route param and SFTP tab state in sync via watcher.
- Verify: `frontend/src/router/index.ts`
- Route shape remains unchanged (`/sftp/:id`).
- Verify: `docs/superpowers/specs/2026-03-24-sftp-tabs-design.md`
- Source of truth for requirements and acceptance criteria.
### Task 1: Add Dedicated SFTP Tabs Store
**Files:**
- Create: `frontend/src/stores/sftpTabs.ts`
- Reference: `frontend/src/stores/terminalTabs.ts`
- [ ] **Step 1: Check current store pattern and workspace status**
Run `git status --short` and read `frontend/src/stores/terminalTabs.ts` to mirror established naming/style patterns.
- [ ] **Step 2: Add `SftpTab` model and store state**
Create `sftpTabs` store with:
```ts
export interface SftpTab {
id: string
connectionId: number
title: string
active: boolean
}
```
State and computed:
```ts
const tabs = ref<SftpTab[]>([])
const activeTabId = ref<string | null>(null)
const activeTab = computed(() => tabs.value.find(t => t.id === activeTabId.value) || null)
```
- [ ] **Step 3: Implement open/activate/close logic with dedup**
Implement actions analogous to terminal tabs:
- `openOrFocus(connection)` reuses existing tab by `connectionId`
- `activate(tabId)` flips active flags and updates `activeTabId`
- `close(tabId)` removes tab and activates neighbor when needed
- [ ] **Step 4: Build-check after new store**
Run in `frontend/`: `npm run build`
Expected: build succeeds and no type errors in new store.
### Task 2: Register SFTP Tabs from Connections Entry
**Files:**
- Modify: `frontend/src/views/ConnectionsView.vue`
- Use: `frontend/src/stores/sftpTabs.ts`
- [ ] **Step 1: Add store import and instance**
Import `useSftpTabsStore` and initialize `const sftpTabsStore = useSftpTabsStore()`.
- [ ] **Step 2: Update `openSftp(conn)` behavior**
Before routing, call:
```ts
sftpTabsStore.openOrFocus(conn)
router.push(`/sftp/${conn.id}`)
```
- [ ] **Step 3: Build-check for integration safety**
Run in `frontend/`: `npm run build`
Expected: build succeeds and `ConnectionsView` typing remains valid.
### Task 3: Render and Control SFTP Tabs in Sidebar
**Files:**
- Modify: `frontend/src/layouts/MainLayout.vue`
- Use: `frontend/src/stores/sftpTabs.ts`
- [ ] **Step 1: Add SFTP store wiring and computed values**
Add:
- `const sftpTabsStore = useSftpTabsStore()`
- `const sftpTabs = computed(() => sftpTabsStore.tabs)`
- route helper for SFTP context (for active styling and close-navigation logic)
- [ ] **Step 2: Add SFTP tab click and close handlers**
Implement:
- click handler: activate tab + `router.push(`/sftp/${tab.connectionId}`)` + close sidebar
- close handler:
- always close in store
- only navigate when current route is the closed tab's route
- if tabs remain, navigate to active tab route
- if no tabs remain, navigate `/connections`
- [ ] **Step 3: Render `文件` sidebar section near terminal section**
Add a section matching current visual style conventions:
- title row with `FolderOpen` icon and text `文件`
- list items for `sftpTabs`
- close button per item
- active class aligned with current route context
- [ ] **Step 4: Build-check and inspect no terminal regressions**
Run in `frontend/`: `npm run build`
Expected: build succeeds; no type/template errors in `MainLayout`.
### Task 4: Route-Driven Sync in SFTP View
**Files:**
- Modify: `frontend/src/views/SftpView.vue`
- Use: `frontend/src/stores/sftpTabs.ts`
- [ ] **Step 1: Add SFTP tabs store and route-param watcher**
Add watcher on `route.params.id` with `{ immediate: true }` so first load and tab switching share one code path.
- [ ] **Step 2: Consolidate initialization into watcher path**
Use watcher flow:
- parse and validate id; if invalid, show user-visible error feedback, do not create/open any SFTP tab, and navigate to `/connections`
- ensure connections loaded (fetch when needed)
- resolve connection; if missing, show user-visible error and route back to `/connections`
- call `sftpTabsStore.openOrFocus(connection)`
- refresh connection-bound SFTP view state for current route id
Avoid duplicate request/init logic split across both watcher and old mount flow.
- [ ] **Step 3: Keep existing file-management behavior unchanged**
Do not alter existing upload/download/delete/core SFTP operations; only route/tab synchronization behavior should change.
- [ ] **Step 4: Build-check after route-sync changes**
Run in `frontend/`: `npm run build`
Expected: build succeeds and SftpView compiles cleanly.
### Task 5: End-to-End Verification
**Files:**
- Verify runtime behavior in app UI
- Verify `frontend/src/router/index.ts` unchanged for route shape
- [ ] **Step 1: Run final build verification**
Run in `frontend/`: `npm run build`
Expected: `vue-tsc -b` and Vite build both pass.
- [ ] **Step 2: Manual behavior checks**
Verify all acceptance criteria:
1. Same-connection `文件` clicks do not create duplicate tabs.
2. Multiple SFTP tabs can be opened and switched.
3. Navigating away (for example back to connections) keeps SFTP tabs in sidebar during current session.
4. Closing active/non-active tabs follows designed route behavior.
5. Switching `/sftp/:id` between tabs updates header/file list without stale previous-connection state.
6. Invalid/nonexistent `/sftp/:id` creates no tab, shows visible feedback, and routes back to `/connections`.
7. Terminal tabs continue to open/switch/close normally.
- [ ] **Step 3: Commit if user requests**
If the user asks to commit, keep commit focused:
```bash
git add frontend/src/stores/sftpTabs.ts frontend/src/views/ConnectionsView.vue frontend/src/layouts/MainLayout.vue frontend/src/views/SftpView.vue
git commit -m "feat: add sidebar tabs for sftp sessions"
```

View File

@@ -0,0 +1,126 @@
# 2026-03-24 SFTP Tabs Design
## Background
The current connection list provides both `终端` and `文件` actions. `终端` has persistent sidebar tabs, but `文件` (SFTP) opens a route directly and has no sidebar tab lifecycle. Users lose the quick return path after navigating away, which feels inconsistent.
## Goal
- Add sidebar tabs for SFTP sessions near the existing terminal tabs.
- Ensure SFTP tabs are unique per connection (no duplicates).
- Keep tab state only for the current in-memory session.
## Non-Goals
- No persistence across full page refresh (no localStorage/sessionStorage).
- No refactor that merges terminal and SFTP tab models into one generic tab system.
- No route restructuring; keep `/sftp/:id` as the SFTP route entry.
## Current State
- Terminal tabs are managed by `frontend/src/stores/terminalTabs.ts` and rendered in `frontend/src/layouts/MainLayout.vue`.
- `ConnectionsView` opens terminal via `terminalTabs.openOrFocus(conn)` and then routes to `/terminal`.
- `ConnectionsView` opens SFTP by routing directly to `/sftp/:id`, with no tab store.
- `SftpView` currently has no tab registration step.
## Selected Approach
Introduce a dedicated SFTP tab store and render a new sidebar tab section in `MainLayout`, following the same interaction model as terminal tabs while keeping route behavior centered on `/sftp/:id`.
### 1. New store: `sftpTabs`
Create `frontend/src/stores/sftpTabs.ts` with a model parallel to terminal tabs:
- `tabs: SftpTab[]`
- `activeTabId: string | null`
- `activeTab` computed
- `openOrFocus(connection)`
- `activate(tabId)`
- `close(tabId)`
Each tab includes:
- `id: string`
- `connectionId: number`
- `title: string`
- `active: boolean`
Dedup rule:
- `openOrFocus(connection)` must reuse an existing tab when `connectionId` matches.
### 2. Connection list behavior
Update `frontend/src/views/ConnectionsView.vue`:
- In `openSftp(conn)`, call `sftpTabs.openOrFocus(conn)` before `router.push(`/sftp/${conn.id}`)`.
Result:
- Clicking `文件` on the same connection repeatedly does not create duplicate tabs.
### 3. Sidebar rendering and controls
Update `frontend/src/layouts/MainLayout.vue`:
- Import and use `useSftpTabsStore`.
- Add computed `sftpTabs`.
- Add a new sidebar section (near terminal tabs) titled `文件`.
- Render each tab as a clickable item with close button.
Interactions:
- Click tab: activate and navigate to `/sftp/:connectionId`.
- Close tab:
- Always remove the tab from store.
- Only trigger route navigation when the current route is the closed tab's route (`/sftp/:connectionId`):
- If another SFTP tab remains, navigate to `/sftp/:newActiveConnectionId`.
- If no SFTP tabs remain, navigate to `/connections`.
- If closing a non-active tab, or closing from a non-SFTP route, remove only (no route change).
Highlighting:
- Keep active style tied to both tab active state and current SFTP route context.
### 4. Route-entry consistency
Update `frontend/src/views/SftpView.vue`:
- Import and use `useSftpTabsStore`.
- Watch `route.params.id` with `{ immediate: true }` so logic runs on first load and on `/sftp/:id` param changes (tab-to-tab switching).
- On each `id` change:
- Parse `id` to number; if invalid, navigate to `/connections`.
- Ensure connections are loaded (fetch when needed).
- Resolve the matching connection; if not found, show error feedback and navigate to `/connections`.
- Call `sftpTabs.openOrFocus(connection)` and then refresh SFTP view state for that connection.
- Consolidate route-driven initialization into this watcher (avoid a separate `onMounted` path-init flow for the same concern) so first load and param switching use one code path and do not trigger duplicate requests.
This keeps behavior consistent for direct navigation and sidebar tab switching, without duplicate tabs.
## Behavior Kept Unchanged
- Existing terminal tab logic and terminal workspace lifecycle.
- Existing SFTP route path (`/sftp/:id`) and core file-management interactions remain unchanged, except for tab registration/sync and invalid-or-missing connection route handling described above.
- Authentication and router guards.
- UI visual language (slate/cyan styling).
## Acceptance Criteria
- `文件` opens/activates an SFTP tab in sidebar.
- Repeatedly opening `文件` for the same connection does not create duplicate tabs.
- SFTP tabs remain available when navigating back to connections or other pages within the same session.
- Closing active/non-active SFTP tabs follows the navigation rules above.
- Terminal tabs continue working exactly as before.
- Switching between existing SFTP tabs (for example `/sftp/1` and `/sftp/2`) updates connection header and file list correctly, without stale data from the previous connection.
- Direct navigation to an invalid or nonexistent `/sftp/:id` does not create a tab and returns the user to `/connections` with visible feedback.
## Verification
- Run `npm run build` in `frontend/`.
- Manual verification:
1. Open SFTP for one connection multiple times and confirm single tab.
2. Open SFTP for multiple connections and switch between tabs.
3. Navigate away and return via sidebar SFTP tabs.
4. Close active and inactive SFTP tabs and verify resulting route behavior.
5. Re-check terminal tab open/switch/close behavior for regressions.
6. Open an invalid/nonexistent `/sftp/:id` and verify no tab is created, visible error feedback appears, and navigation returns to `/connections`.

View File

@@ -3,18 +3,21 @@ import { ref, computed } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useConnectionsStore } from '../stores/connections'
import { useSftpTabsStore } from '../stores/sftpTabs'
import { useTerminalTabsStore } from '../stores/terminalTabs'
import TerminalWorkspaceView from '../views/TerminalWorkspaceView.vue'
import { ArrowLeftRight, Server, LogOut, Menu, X, Terminal } from 'lucide-vue-next'
import { ArrowLeftRight, Server, LogOut, Menu, X, Terminal, FolderOpen } from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const connectionsStore = useConnectionsStore()
const tabsStore = useTerminalTabsStore()
const authStore = useAuthStore()
const connectionsStore = useConnectionsStore()
const sftpTabsStore = useSftpTabsStore()
const tabsStore = useTerminalTabsStore()
const sidebarOpen = ref(false)
const terminalTabs = computed(() => tabsStore.tabs)
const sftpTabs = computed(() => sftpTabsStore.tabs)
const showTerminalWorkspace = computed(() => route.path === '/terminal')
const keepTerminalWorkspaceMounted = computed(() => showTerminalWorkspace.value || terminalTabs.value.length > 0)
@@ -30,10 +33,39 @@ function handleTabClick(tabId: string) {
closeSidebar()
}
function handleTabClose(tabId: string, event: Event) {
event.stopPropagation()
tabsStore.close(tabId)
}
function handleTabClose(tabId: string, event: Event) {
event.stopPropagation()
tabsStore.close(tabId)
}
function isCurrentSftpRoute(connectionId: number) {
if (route.name !== 'Sftp') return false
const routeParamId = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
return Number(routeParamId) === connectionId
}
function handleSftpTabClick(tabId: string, connectionId: number) {
sftpTabsStore.activate(tabId)
router.push(`/sftp/${connectionId}`)
closeSidebar()
}
function handleSftpTabClose(tabId: string, connectionId: number, event: Event) {
event.stopPropagation()
const shouldNavigate = isCurrentSftpRoute(connectionId)
sftpTabsStore.close(tabId)
if (!shouldNavigate) return
if (sftpTabsStore.activeTab) {
router.push(`/sftp/${sftpTabsStore.activeTab.connectionId}`)
return
}
router.push('/connections')
}
</script>
<template>
@@ -80,11 +112,11 @@ function handleTabClose(tabId: string, event: Event) {
</RouterLink>
<!-- 终端标签区域 -->
<div v-if="terminalTabs.length > 0" class="pt-4 mt-4 border-t border-slate-700">
<div class="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wider">
<Terminal class="w-4 h-4" aria-hidden="true" />
<span>终端</span>
</div>
<div v-if="terminalTabs.length > 0" class="pt-4 mt-4 border-t border-slate-700">
<div class="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wider">
<Terminal class="w-4 h-4" aria-hidden="true" />
<span>终端</span>
</div>
<div class="space-y-1 mt-2">
<button
v-for="tab in terminalTabs"
@@ -101,9 +133,41 @@ function handleTabClose(tabId: string, event: Event) {
>
<X class="w-3 h-3" aria-hidden="true" />
</button>
</button>
</div>
</div>
</button>
</div>
</div>
<div v-if="sftpTabs.length > 0" class="pt-4 mt-4 border-t border-slate-700">
<div class="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wider">
<FolderOpen class="w-4 h-4" aria-hidden="true" />
<span>文件</span>
</div>
<div class="space-y-1 mt-2">
<div
v-for="tab in sftpTabs"
:key="tab.id"
class="w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 min-h-[44px] group focus-within:outline-none focus-within:ring-2 focus-within:ring-cyan-500 focus-within:ring-inset"
:class="{ 'bg-slate-700 text-cyan-400': tab.active && route.path === `/sftp/${tab.connectionId}` }"
>
<button
type="button"
@click="handleSftpTabClick(tab.id, tab.connectionId)"
class="flex-1 min-w-0 text-left cursor-pointer focus:outline-none"
:aria-label="`打开文件标签 ${tab.title}`"
>
<span class="truncate text-sm block">{{ tab.title }}</span>
</button>
<button
type="button"
@click="(e) => handleSftpTabClose(tab.id, tab.connectionId, e)"
class="p-1 rounded opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100 hover:bg-slate-600 transition-opacity duration-200 transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-cyan-500"
aria-label="关闭文件标签"
>
<X class="w-3 h-3" aria-hidden="true" />
</button>
</div>
</div>
</div>
</nav>
<div class="p-4 border-t border-slate-700">
<button

View File

@@ -0,0 +1,84 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Connection } from '../api/connections'
export interface SftpTab {
id: string
connectionId: number
title: string
active: boolean
}
export const useSftpTabsStore = defineStore('sftpTabs', () => {
const tabs = ref<SftpTab[]>([])
const activeTabId = ref<string | null>(null)
const activeTab = computed(() => tabs.value.find(t => t.id === activeTabId.value) || null)
function generateTabId() {
return `tab-${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function openOrFocus(connection: Connection) {
const existing = tabs.value.find(t => t.connectionId === connection.id)
if (existing) {
activate(existing.id)
return existing.id
}
const newTab: SftpTab = {
id: generateTabId(),
connectionId: connection.id,
title: connection.name,
active: true,
}
tabs.value.push(newTab)
activate(newTab.id)
return newTab.id
}
function syncActiveState() {
const hasActiveTab = activeTabId.value !== null && tabs.value.some(t => t.id === activeTabId.value)
if (!hasActiveTab) {
activeTabId.value = null
}
tabs.value.forEach(t => {
t.active = t.id === activeTabId.value
})
}
function activate(tabId: string) {
if (!tabs.value.some(t => t.id === tabId)) return
activeTabId.value = tabId
syncActiveState()
}
function close(tabId: string) {
const index = tabs.value.findIndex(t => t.id === tabId)
if (index === -1) return
const wasActive = activeTabId.value === tabId
tabs.value.splice(index, 1)
if (wasActive && tabs.value.length > 0) {
const newIndex = Math.min(index, tabs.value.length - 1)
activeTabId.value = tabs.value[newIndex]!.id
} else if (tabs.value.length === 0) {
activeTabId.value = null
}
syncActiveState()
}
return {
tabs,
activeTabId,
activeTab,
openOrFocus,
activate,
close,
}
})

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, ref, onMounted, watch } from 'vue'
import { computed, ref, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useConnectionsStore } from '../stores/connections'
import { useSftpTabsStore } from '../stores/sftpTabs'
import { useTerminalTabsStore } from '../stores/terminalTabs'
import type { Connection, ConnectionCreateRequest } from '../api/connections'
import ConnectionForm from '../components/ConnectionForm.vue'
@@ -19,9 +20,10 @@ import {
} from 'lucide-vue-next'
const router = useRouter()
const route = useRoute()
const store = useConnectionsStore()
const tabsStore = useTerminalTabsStore()
const route = useRoute()
const store = useConnectionsStore()
const tabsStore = useTerminalTabsStore()
const sftpTabsStore = useSftpTabsStore()
const showForm = ref(false)
const editingConn = ref<Connection | null>(null)
@@ -95,6 +97,7 @@ function openTerminal(conn: Connection) {
}
function openSftp(conn: Connection) {
sftpTabsStore.openOrFocus(conn)
router.push(`/sftp/${conn.id}`)
}

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'vue-toast-notification'
import { useConnectionsStore } from '../stores/connections'
import * as sftpApi from '../api/sftp'
import type { SftpFileInfo } from '../api/sftp'
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'vue-toast-notification'
import { useConnectionsStore } from '../stores/connections'
import { useSftpTabsStore } from '../stores/sftpTabs'
import * as sftpApi from '../api/sftp'
import type { SftpFileInfo } from '../api/sftp'
import {
ArrowLeft,
FolderOpen,
@@ -24,12 +25,13 @@ import {
} from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const store = useConnectionsStore()
const connectionId = computed(() => Number(route.params.id))
const conn = ref(store.getConnection(connectionId.value))
const router = useRouter()
const toast = useToast()
const store = useConnectionsStore()
const sftpTabsStore = useSftpTabsStore()
const connectionId = computed(() => Number(route.params.id))
const conn = ref(store.getConnection(connectionId.value))
const currentPath = ref('.')
const pathParts = ref<string[]>([])
@@ -59,12 +61,23 @@ watch([searchQuery, showHiddenFiles, files], () => {
}, { immediate: true })
onBeforeUnmount(() => {
invalidateUploadContext()
clearTimeout(searchDebounceTimer)
stopTransferProgress()
})
const showUploadProgress = ref(false)
const uploadProgressList = ref<{ id: string; name: string; size: number; uploaded: number; total: number; status: 'pending' | 'uploading' | 'success' | 'error'; message?: string }[]>([])
const showUploadProgress = ref(false)
const uploadProgressList = ref<{ id: string; name: string; size: number; uploaded: number; total: number; status: 'pending' | 'uploading' | 'success' | 'error'; message?: string }[]>([])
let activeUploadContextId = 0
function createUploadContext() {
activeUploadContextId += 1
return activeUploadContextId
}
function invalidateUploadContext() {
activeUploadContextId += 1
}
const totalProgress = computed(() => {
if (uploadProgressList.value.length === 0) return 0
@@ -164,51 +177,153 @@ async function cancelTransfer() {
}
}
onMounted(() => {
conn.value = store.getConnection(connectionId.value)
if (!conn.value) {
store.fetchConnections().then(() => {
conn.value = store.getConnection(connectionId.value)
initPath()
})
} else {
initPath()
}
})
function initPath() {
sftpApi.getPwd(connectionId.value).then((res) => {
const p = res.data.path || '/'
currentPath.value = p === '/' ? '/' : p
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
loadPath()
}).catch((err: { response?: { data?: { error?: string } } }) => {
error.value = err?.response?.data?.error ?? '获取当前路径失败,请检查连接与认证'
currentPath.value = '.'
pathParts.value = []
loadPath()
})
}
function loadPath() {
loading.value = true
error.value = ''
searchQuery.value = ''
sftpApi
.listFiles(connectionId.value, currentPath.value)
.then((res) => {
files.value = res.data.sort((a, b) => {
if (a.directory !== b.directory) return a.directory ? -1 : 1
return a.name.localeCompare(b.name)
})
})
.catch((err: { response?: { data?: { error?: string } } }) => {
error.value = err?.response?.data?.error ?? '获取文件列表失败'
})
.finally(() => {
loading.value = false
})
}
let routeInitRequestId = 0
function isStaleRouteInit(requestId?: number, isCancelled?: () => boolean) {
return (isCancelled?.() ?? false) || (requestId != null && requestId !== routeInitRequestId)
}
function resetVolatileSftpState() {
invalidateUploadContext()
conn.value = undefined
currentPath.value = '.'
pathParts.value = []
files.value = []
filteredFiles.value = []
loading.value = false
error.value = ''
selectedFile.value = null
searchQuery.value = ''
showUploadProgress.value = false
uploadProgressList.value = []
uploading.value = false
stopTransferProgress()
showTransferModal.value = false
transferFile.value = null
transferTargetConnectionId.value = null
transferTargetPath.value = ''
transferError.value = ''
transferring.value = false
resetTransferProgress()
}
watch(
() => route.params.id,
async (routeId, _, onCleanup) => {
const requestId = ++routeInitRequestId
let cleanedUp = false
onCleanup(() => {
cleanedUp = true
})
const isRouteInitCancelled = () => cleanedUp
resetVolatileSftpState()
const rawId = Array.isArray(routeId) ? routeId[0] : routeId
const parsedId = Number(rawId)
if (!rawId || !Number.isInteger(parsedId) || parsedId <= 0) {
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
conn.value = undefined
await redirectToConnections('连接参数无效,请从连接列表重新进入', requestId, isRouteInitCancelled)
return
}
if (store.connections.length === 0) {
try {
await store.fetchConnections()
} catch {
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
await redirectToConnections('加载连接列表失败,请稍后重试', requestId, isRouteInitCancelled)
return
}
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
}
const targetConnection = store.getConnection(parsedId)
if (!targetConnection) {
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
conn.value = undefined
await redirectToConnections('连接不存在或已删除', requestId, isRouteInitCancelled)
return
}
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
conn.value = targetConnection
sftpTabsStore.openOrFocus(targetConnection)
await initPath(parsedId, requestId, isRouteInitCancelled)
},
{ immediate: true }
)
async function redirectToConnections(
message: string,
requestId = routeInitRequestId,
isCancelled?: () => boolean
) {
if (isStaleRouteInit(requestId, isCancelled)) return
toast.error(message)
if (isStaleRouteInit(requestId, isCancelled)) return
await router.replace('/connections')
}
async function initPath(
targetConnectionId = connectionId.value,
requestId = routeInitRequestId,
isCancelled?: () => boolean
) {
try {
const res = await sftpApi.getPwd(targetConnectionId)
if (isStaleRouteInit(requestId, isCancelled)) return
const p = res.data.path || '/'
currentPath.value = p === '/' ? '/' : p
pathParts.value = p === '/' ? [''] : p.split('/').filter(Boolean)
await loadPath(targetConnectionId, requestId, isCancelled)
} catch (err) {
if (isStaleRouteInit(requestId, isCancelled)) return
const typedErr = err as { response?: { data?: { error?: string } } }
error.value = typedErr?.response?.data?.error ?? '获取当前路径失败,请检查连接与认证'
currentPath.value = '.'
pathParts.value = []
await loadPath(targetConnectionId, requestId, isCancelled)
}
}
async function loadPath(
targetConnectionId = connectionId.value,
requestId = routeInitRequestId,
isCancelled?: () => boolean
) {
if (isStaleRouteInit(requestId, isCancelled)) return
loading.value = true
error.value = ''
searchQuery.value = ''
try {
const res = await sftpApi.listFiles(targetConnectionId, currentPath.value)
if (isStaleRouteInit(requestId, isCancelled)) return
files.value = res.data.sort((a, b) => {
if (a.directory !== b.directory) return a.directory ? -1 : 1
return a.name.localeCompare(b.name)
})
} catch (err) {
if (isStaleRouteInit(requestId, isCancelled)) return
const typedErr = err as { response?: { data?: { error?: string } } }
error.value = typedErr?.response?.data?.error ?? '获取文件列表失败'
} finally {
if (!isStaleRouteInit(requestId, isCancelled)) {
loading.value = false
}
}
}
function navigateToDir(name: string) {
if (loading.value) return
@@ -267,13 +382,18 @@ function triggerUpload() {
fileInputRef.value?.click()
}
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
const selected = input.files
if (!selected?.length) return
uploading.value = true
error.value = ''
const path = currentPath.value === '.' ? '' : currentPath.value
async function handleFileSelect(e: Event) {
const uploadContextId = createUploadContext()
const isUploadStale = () => uploadContextId !== activeUploadContextId
const input = e.target as HTMLInputElement
const selected = input.files
if (!selected?.length || isUploadStale()) return
const targetConnectionId = connectionId.value
uploading.value = true
error.value = ''
const path = currentPath.value === '.' ? '' : currentPath.value
const uploadTasks: { id: string; file: File; taskId?: string }[] = []
for (let i = 0; i < selected.length; i++) {
@@ -293,29 +413,40 @@ async function handleFileSelect(e: Event) {
showUploadProgress.value = true
const MAX_PARALLEL = 5
for (let i = 0; i < uploadTasks.length; i += MAX_PARALLEL) {
const batch = uploadTasks.slice(i, i + MAX_PARALLEL)
const batchPromises = batch.map(async task => {
if (!task) return
const { id, file } = task
const item = uploadProgressList.value.find(item => item.id === id)
if (!item) return
item.status = 'uploading'
try {
// Start upload and get taskId
const uploadRes = await sftpApi.uploadFile(connectionId.value, path, file)
const taskId = uploadRes.data.taskId
// Poll for progress
while (true) {
const statusRes = await sftpApi.getUploadTask(taskId)
const taskStatus = statusRes.data
item.uploaded = taskStatus.transferredBytes
const MAX_PARALLEL = 5
for (let i = 0; i < uploadTasks.length; i += MAX_PARALLEL) {
if (isUploadStale()) return
const batch = uploadTasks.slice(i, i + MAX_PARALLEL)
const batchPromises = batch.map(async task => {
if (!task || isUploadStale()) return
const { id, file } = task
const item = uploadProgressList.value.find(item => item.id === id)
if (!item || isUploadStale()) return
item.status = 'uploading'
try {
if (isUploadStale()) return
// Start upload and get taskId
const uploadRes = await sftpApi.uploadFile(targetConnectionId, path, file)
if (isUploadStale()) return
const taskId = uploadRes.data.taskId
// Poll for progress
while (true) {
if (isUploadStale()) return
const statusRes = await sftpApi.getUploadTask(taskId)
if (isUploadStale()) return
const taskStatus = statusRes.data
item.uploaded = taskStatus.transferredBytes
item.total = taskStatus.totalBytes
if (taskStatus.status === 'success') {
@@ -327,24 +458,34 @@ async function handleFileSelect(e: Event) {
item.message = taskStatus.error || 'Upload failed'
break
}
await new Promise(resolve => setTimeout(resolve, 200))
}
} catch (err: any) {
item.status = 'error'
item.message = err?.response?.data?.error || 'Upload failed'
}
})
await Promise.allSettled(batchPromises)
}
await loadPath()
const successCount = uploadProgressList.value.filter(item => item.status === 'success').length
showUploadProgress.value = false
uploadProgressList.value = []
uploading.value = false
fileInputRef.value!.value = ''
toast.success(`成功上传 ${successCount} 个文件`)
await new Promise(resolve => setTimeout(resolve, 200))
}
} catch (err: any) {
if (isUploadStale()) return
item.status = 'error'
item.message = err?.response?.data?.error || 'Upload failed'
}
})
await Promise.allSettled(batchPromises)
}
if (isUploadStale()) return
await loadPath(targetConnectionId)
if (isUploadStale()) return
const successCount = uploadProgressList.value.filter(item => item.status === 'success').length
showUploadProgress.value = false
uploadProgressList.value = []
uploading.value = false
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
if (isUploadStale()) return
toast.success(`成功上传 ${successCount} 个文件`)
}
function handleMkdir() {
@@ -488,12 +629,12 @@ async function submitTransfer() {
>
<FolderPlus class="w-4 h-4" aria-hidden="true" />
</button>
<button
@click="loadPath"
:disabled="loading"
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer disabled:opacity-50"
aria-label="刷新"
>
<button
@click="loadPath()"
:disabled="loading"
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer disabled:opacity-50"
aria-label="刷新"
>
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': loading }" aria-hidden="true" />
</button>
</div>