fix: file manager double browse from v-select init and stale chunks

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-06-02 04:36:46 +08:00
parent 5393252036
commit cb738a8e9a
3 changed files with 87 additions and 29 deletions
@@ -63,9 +63,17 @@ if (!_retry && !AUTH_SKIP_REFRESH.has(path) && auth.token && auth.isTokenExpirin
---
## 追加修复(2026-06-02 续)
- `FilesPage.vue``v-select``onMounted` 赋值 `selectedServer` 时会触发 `@update:model-value``onServerChange` 再次 `browse()`;增加 `_initing` 守卫
- `FilesPage.vue``pageReady` 避免首屏短暂显示「请先选择服务器」
- `FilesPage.vue`:同路径并发 `browse()` 合并为单次 in-flight 请求
- 部署须执行 `deploy/prune_frontend_assets.py` 清理服务器上残留的旧 hash chunk(否则浏览器可能仍加载旧 `FilesPage-*.js`
## 验证方式
1. 登录后等待 access token 接近过期(或手动修改 token exp 为近期时间)
2. 进入文件管理器浏览目录
3. 打开 DevTools Network 过滤 `/sync/browse`
4. 预期:每次进目录只有 **1 条** `/sync/browse` 请求,不再出现 401 后重试的第 2 条
5. 空目录区不应再闪现「请先选择服务器」(已选服务器时)
+59 -12
View File
@@ -255,7 +255,7 @@
</div>
</template>
<template #no-data>
<div v-if="!loading" class="text-center text-medium-emphasis py-8">
<div v-if="pageReady && !loading" class="text-center text-medium-emphasis py-8">
<v-icon size="40" class="mb-2">mdi-folder-open-outline</v-icon>
<div>{{ emptyHint }}</div>
<div v-if="selectedServer" class="text-caption mt-2 text-disabled">
@@ -596,7 +596,9 @@ const route = useRoute()
const router = useRouter()
// ── State ──
const _mounted = ref(false) // 防止 route watcher 在 onMounted 前触发双重 browse
const _mounted = ref(false) // route watcher 仅在初始化完成后响应
const _initing = ref(true) // 阻止 v-select 在 onMounted 赋值时触发 onServerChange 二次 browse
const pageReady = ref(false) // 避免首屏短暂显示「请先选择服务器」
const selectedServer = ref<number | null>(null)
const currentPath = ref(DEFAULT_FILES_ROOT)
const files = ref<FileEntry[]>([])
@@ -758,6 +760,7 @@ const statusSummary = computed(() => {
})
const emptyHint = computed(() => {
if (!pageReady.value) return '正在加载…'
if (!selectedServer.value) return '请先选择服务器'
if (browseError.value) return browseError.value
if (fileFilter.value.trim()) return '无匹配文件'
@@ -854,13 +857,23 @@ function openInTerminal() {
// 序号保护:防止并发/重试请求导致旧结果覆盖新结果
let _browseSeq = 0
let _browseInFlight: Promise<void> | null = null
let _browseInFlightKey = ''
async function browse() {
if (!selectedServer.value) return
const path = normalizeRemotePath(currentPath.value)
const browseKey = `${selectedServer.value}:${path}`
if (_browseInFlight && _browseInFlightKey === browseKey) {
return _browseInFlight
}
_browseInFlightKey = browseKey
const seq = ++_browseSeq
loading.value = true
const path = normalizeRemotePath(currentPath.value)
currentPath.value = path
_browseInFlight = (async () => {
try {
const res = await http.post<BrowseResponse | FileEntry[]>('/sync/browse', {
server_id: selectedServer.value,
@@ -901,8 +914,15 @@ async function browse() {
loading.value = false
syncRouteQuery()
}
if (_browseInFlightKey === browseKey) {
_browseInFlight = null
_browseInFlightKey = ''
}
}
})()
return _browseInFlight
}
/** 判断 ls 报错是否为「路径不存在」(与权限无关) */
function isNotFoundError(err: string): boolean {
@@ -939,6 +959,7 @@ function defaultPathForServer(serverId: number | null): string {
}
function onServerChange() {
if (_initing.value) return
if (selectedServer.value) {
sessionStorage.setItem(FILES_LAST_SERVER_KEY, String(selectedServer.value))
}
@@ -1470,16 +1491,22 @@ function formatSize(bytes: number | undefined) {
// ── Init ──
onMounted(async () => {
_initing.value = true
try {
await loadServers()
if (sessionStorage.getItem(FILES_HINT_DISMISSED_KEY)) {
accessHintDismissed.value = true
}
if (!serverList.value.length) {
snackbar('暂无可用服务器,请先在「服务器」页添加', 'warning')
pageReady.value = true
return
}
const initialId = resolveInitialServerId()
if (initialId == null) return
if (initialId == null) {
pageReady.value = true
return
}
selectedServer.value = initialId
const qPath = route.query.path
currentPath.value =
@@ -1487,24 +1514,44 @@ onMounted(async () => {
? normalizeRemotePath(qPath)
: defaultPathForServer(initialId)
await refreshFilesCapability()
browse()
await browse()
pageReady.value = true
_mounted.value = true
} finally {
_initing.value = false
}
})
function pathFromRouteQuery(): string {
const qPath = route.query.path
if (typeof qPath === 'string' && qPath.trim()) {
return normalizeRemotePath(qPath)
}
return defaultPathForServer(selectedServer.value)
}
watch(
() => route.query.server_id,
(id) => {
if (!_mounted.value) return // 初始化未完成时跳过,避免与 onMounted 双重 browse
if (!_mounted.value) return
if (id && Number(id) !== selectedServer.value) {
selectedServer.value = Number(id)
const qPath = route.query.path
currentPath.value =
typeof qPath === 'string' && qPath
? normalizeRemotePath(qPath)
: defaultPathForServer(Number(id))
currentPath.value = pathFromRouteQuery()
lastReadDeniedPath.value = null
void refreshFilesCapability()
browse()
void browse()
}
},
)
watch(
() => route.query.path,
() => {
if (!_mounted.value || !selectedServer.value) return
const next = pathFromRouteQuery()
if (next !== normalizeRemotePath(currentPath.value)) {
currentPath.value = next
void browse()
}
},
)
+4 -1
View File
@@ -10,7 +10,10 @@ export { TotpRequiredError }
/** 从 JWT 不验签地解码 exp 字段(秒)。失败返回 0。 */
function decodeJwtExp(jwt: string): number {
try {
const part = jwt.split('.')[1]
let part = jwt.split('.')[1]
if (!part) return 0
const pad = part.length % 4
if (pad) part += '='.repeat(4 - pad)
const json = atob(part.replace(/-/g, '+').replace(/_/g, '/'))
const obj = JSON.parse(json) as { exp?: number }
return typeof obj.exp === 'number' ? obj.exp : 0