fix: file manager double browse from v-select init and stale chunks
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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. 空目录区不应再闪现「请先选择服务器」(已选服务器时)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user