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 为近期时间)
|
1. 登录后等待 access token 接近过期(或手动修改 token exp 为近期时间)
|
||||||
2. 进入文件管理器浏览目录
|
2. 进入文件管理器浏览目录
|
||||||
3. 打开 DevTools Network 过滤 `/sync/browse`
|
3. 打开 DevTools Network 过滤 `/sync/browse`
|
||||||
4. 预期:每次进目录只有 **1 条** `/sync/browse` 请求,不再出现 401 后重试的第 2 条
|
4. 预期:每次进目录只有 **1 条** `/sync/browse` 请求,不再出现 401 后重试的第 2 条
|
||||||
|
5. 空目录区不应再闪现「请先选择服务器」(已选服务器时)
|
||||||
|
|||||||
@@ -255,7 +255,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #no-data>
|
<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>
|
<v-icon size="40" class="mb-2">mdi-folder-open-outline</v-icon>
|
||||||
<div>{{ emptyHint }}</div>
|
<div>{{ emptyHint }}</div>
|
||||||
<div v-if="selectedServer" class="text-caption mt-2 text-disabled">
|
<div v-if="selectedServer" class="text-caption mt-2 text-disabled">
|
||||||
@@ -596,7 +596,9 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
// ── State ──
|
// ── 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 selectedServer = ref<number | null>(null)
|
||||||
const currentPath = ref(DEFAULT_FILES_ROOT)
|
const currentPath = ref(DEFAULT_FILES_ROOT)
|
||||||
const files = ref<FileEntry[]>([])
|
const files = ref<FileEntry[]>([])
|
||||||
@@ -758,6 +760,7 @@ const statusSummary = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const emptyHint = computed(() => {
|
const emptyHint = computed(() => {
|
||||||
|
if (!pageReady.value) return '正在加载…'
|
||||||
if (!selectedServer.value) return '请先选择服务器'
|
if (!selectedServer.value) return '请先选择服务器'
|
||||||
if (browseError.value) return browseError.value
|
if (browseError.value) return browseError.value
|
||||||
if (fileFilter.value.trim()) return '无匹配文件'
|
if (fileFilter.value.trim()) return '无匹配文件'
|
||||||
@@ -854,13 +857,23 @@ function openInTerminal() {
|
|||||||
|
|
||||||
// 序号保护:防止并发/重试请求导致旧结果覆盖新结果
|
// 序号保护:防止并发/重试请求导致旧结果覆盖新结果
|
||||||
let _browseSeq = 0
|
let _browseSeq = 0
|
||||||
|
let _browseInFlight: Promise<void> | null = null
|
||||||
|
let _browseInFlightKey = ''
|
||||||
|
|
||||||
async function browse() {
|
async function browse() {
|
||||||
if (!selectedServer.value) return
|
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
|
const seq = ++_browseSeq
|
||||||
loading.value = true
|
loading.value = true
|
||||||
const path = normalizeRemotePath(currentPath.value)
|
|
||||||
currentPath.value = path
|
currentPath.value = path
|
||||||
|
|
||||||
|
_browseInFlight = (async () => {
|
||||||
try {
|
try {
|
||||||
const res = await http.post<BrowseResponse | FileEntry[]>('/sync/browse', {
|
const res = await http.post<BrowseResponse | FileEntry[]>('/sync/browse', {
|
||||||
server_id: selectedServer.value,
|
server_id: selectedServer.value,
|
||||||
@@ -901,8 +914,15 @@ async function browse() {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
syncRouteQuery()
|
syncRouteQuery()
|
||||||
}
|
}
|
||||||
|
if (_browseInFlightKey === browseKey) {
|
||||||
|
_browseInFlight = null
|
||||||
|
_browseInFlightKey = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return _browseInFlight
|
||||||
|
}
|
||||||
|
|
||||||
/** 判断 ls 报错是否为「路径不存在」(与权限无关) */
|
/** 判断 ls 报错是否为「路径不存在」(与权限无关) */
|
||||||
function isNotFoundError(err: string): boolean {
|
function isNotFoundError(err: string): boolean {
|
||||||
@@ -939,6 +959,7 @@ function defaultPathForServer(serverId: number | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onServerChange() {
|
function onServerChange() {
|
||||||
|
if (_initing.value) return
|
||||||
if (selectedServer.value) {
|
if (selectedServer.value) {
|
||||||
sessionStorage.setItem(FILES_LAST_SERVER_KEY, String(selectedServer.value))
|
sessionStorage.setItem(FILES_LAST_SERVER_KEY, String(selectedServer.value))
|
||||||
}
|
}
|
||||||
@@ -1470,16 +1491,22 @@ function formatSize(bytes: number | undefined) {
|
|||||||
|
|
||||||
// ── Init ──
|
// ── Init ──
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
_initing.value = true
|
||||||
|
try {
|
||||||
await loadServers()
|
await loadServers()
|
||||||
if (sessionStorage.getItem(FILES_HINT_DISMISSED_KEY)) {
|
if (sessionStorage.getItem(FILES_HINT_DISMISSED_KEY)) {
|
||||||
accessHintDismissed.value = true
|
accessHintDismissed.value = true
|
||||||
}
|
}
|
||||||
if (!serverList.value.length) {
|
if (!serverList.value.length) {
|
||||||
snackbar('暂无可用服务器,请先在「服务器」页添加', 'warning')
|
snackbar('暂无可用服务器,请先在「服务器」页添加', 'warning')
|
||||||
|
pageReady.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const initialId = resolveInitialServerId()
|
const initialId = resolveInitialServerId()
|
||||||
if (initialId == null) return
|
if (initialId == null) {
|
||||||
|
pageReady.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
selectedServer.value = initialId
|
selectedServer.value = initialId
|
||||||
const qPath = route.query.path
|
const qPath = route.query.path
|
||||||
currentPath.value =
|
currentPath.value =
|
||||||
@@ -1487,24 +1514,44 @@ onMounted(async () => {
|
|||||||
? normalizeRemotePath(qPath)
|
? normalizeRemotePath(qPath)
|
||||||
: defaultPathForServer(initialId)
|
: defaultPathForServer(initialId)
|
||||||
await refreshFilesCapability()
|
await refreshFilesCapability()
|
||||||
browse()
|
await browse()
|
||||||
|
pageReady.value = true
|
||||||
_mounted.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(
|
watch(
|
||||||
() => route.query.server_id,
|
() => route.query.server_id,
|
||||||
(id) => {
|
(id) => {
|
||||||
if (!_mounted.value) return // 初始化未完成时跳过,避免与 onMounted 双重 browse
|
if (!_mounted.value) return
|
||||||
if (id && Number(id) !== selectedServer.value) {
|
if (id && Number(id) !== selectedServer.value) {
|
||||||
selectedServer.value = Number(id)
|
selectedServer.value = Number(id)
|
||||||
const qPath = route.query.path
|
currentPath.value = pathFromRouteQuery()
|
||||||
currentPath.value =
|
|
||||||
typeof qPath === 'string' && qPath
|
|
||||||
? normalizeRemotePath(qPath)
|
|
||||||
: defaultPathForServer(Number(id))
|
|
||||||
lastReadDeniedPath.value = null
|
lastReadDeniedPath.value = null
|
||||||
void refreshFilesCapability()
|
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。 */
|
/** 从 JWT 不验签地解码 exp 字段(秒)。失败返回 0。 */
|
||||||
function decodeJwtExp(jwt: string): number {
|
function decodeJwtExp(jwt: string): number {
|
||||||
try {
|
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 json = atob(part.replace(/-/g, '+').replace(/_/g, '/'))
|
||||||
const obj = JSON.parse(json) as { exp?: number }
|
const obj = JSON.parse(json) as { exp?: number }
|
||||||
return typeof obj.exp === 'number' ? obj.exp : 0
|
return typeof obj.exp === 'number' ? obj.exp : 0
|
||||||
|
|||||||
Reference in New Issue
Block a user