fix: 警告横幅关闭后进子目录不再重新出现,文案简化
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -118,9 +118,9 @@
|
|||||||
density="comfortable"
|
density="comfortable"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
icon="mdi-shield-lock-outline"
|
icon="mdi-shield-lock-outline"
|
||||||
@click:close="accessHintDismissed = true"
|
@click:close="dismissFilesAccessHint"
|
||||||
>
|
>
|
||||||
{{ filesAccessHint.text }}
|
{{ filesAccessHint.text.split('目标机安装示例')[0].trim() }}
|
||||||
</v-alert>
|
</v-alert>
|
||||||
|
|
||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
@@ -184,9 +184,7 @@
|
|||||||
hover
|
hover
|
||||||
density="comfortable"
|
density="comfortable"
|
||||||
item-value="name"
|
item-value="name"
|
||||||
@click:row="onRowClick"
|
:row-props="fileRowProps"
|
||||||
@dblclick:row="onRowDblClick"
|
|
||||||
@contextmenu:row="onRowContext"
|
|
||||||
>
|
>
|
||||||
<template #item.name="{ item }">
|
<template #item.name="{ item }">
|
||||||
<div class="d-flex align-center ga-2 min-w-0">
|
<div class="d-flex align-center ga-2 min-w-0">
|
||||||
@@ -601,6 +599,11 @@ const filesCapability = ref<FilesCapabilityResult | null>(null)
|
|||||||
const lastReadDeniedPath = ref<string | null>(null)
|
const lastReadDeniedPath = ref<string | null>(null)
|
||||||
const accessHintDismissed = ref(false)
|
const accessHintDismissed = ref(false)
|
||||||
|
|
||||||
|
function dismissFilesAccessHint() {
|
||||||
|
accessHintDismissed.value = true
|
||||||
|
sessionStorage.setItem(FILES_HINT_DISMISSED_KEY, '1')
|
||||||
|
}
|
||||||
|
|
||||||
const sshUserForFiles = computed(() => filesCapability.value?.ssh_user ?? null)
|
const sshUserForFiles = computed(() => filesCapability.value?.ssh_user ?? null)
|
||||||
|
|
||||||
const unreadableFileCount = computed(() => {
|
const unreadableFileCount = computed(() => {
|
||||||
@@ -611,11 +614,16 @@ const unreadableFileCount = computed(() => {
|
|||||||
|
|
||||||
const filesAccessHint = computed(() => {
|
const filesAccessHint = computed(() => {
|
||||||
if (accessHintDismissed.value || !selectedServer.value) return null
|
if (accessHintDismissed.value || !selectedServer.value) return null
|
||||||
|
// Don't show sudo hint when the error is purely "path not found" — unrelated to permissions
|
||||||
|
if (browseError.value && isNotFoundError(browseError.value)) return null
|
||||||
const cap = filesCapability.value
|
const cap = filesCapability.value
|
||||||
return buildFilesAccessHint({
|
return buildFilesAccessHint({
|
||||||
sshUser: cap?.ssh_user,
|
sshUser: cap?.ssh_user,
|
||||||
canSudo: cap?.can_sudo_nopasswd ?? false,
|
canSudo: cap?.can_sudo_nopasswd ?? false,
|
||||||
|
whitelistOk: cap?.whitelist_ok ?? false,
|
||||||
isRootSsh: cap?.is_root ?? false,
|
isRootSsh: cap?.is_root ?? false,
|
||||||
|
filesElevation: cap?.files_elevation,
|
||||||
|
capabilityMessage: cap?.message,
|
||||||
lastReadDeniedPath: lastReadDeniedPath.value,
|
lastReadDeniedPath: lastReadDeniedPath.value,
|
||||||
unreadableCount: unreadableFileCount.value,
|
unreadableCount: unreadableFileCount.value,
|
||||||
})
|
})
|
||||||
@@ -711,8 +719,13 @@ const statusSummary = computed(() => {
|
|||||||
|
|
||||||
const emptyHint = computed(() => {
|
const emptyHint = computed(() => {
|
||||||
if (!selectedServer.value) return '请先选择服务器'
|
if (!selectedServer.value) return '请先选择服务器'
|
||||||
|
if (browseError.value) return browseError.value
|
||||||
if (fileFilter.value.trim()) return '无匹配文件'
|
if (fileFilter.value.trim()) return '无匹配文件'
|
||||||
return '暂无文件'
|
const cap = filesCapability.value
|
||||||
|
if (cap?.is_root) {
|
||||||
|
return `目录为空(${currentPath.value}),可在上方修改路径或到「服务器」核对 target_path`
|
||||||
|
}
|
||||||
|
return `目录为空(${currentPath.value})`
|
||||||
})
|
})
|
||||||
|
|
||||||
function entryIcon(item: FileEntry): string {
|
function entryIcon(item: FileEntry): string {
|
||||||
@@ -810,9 +823,23 @@ async function browse() {
|
|||||||
path,
|
path,
|
||||||
})
|
})
|
||||||
const { items, error } = parseBrowseResponse(res)
|
const { items, error } = parseBrowseResponse(res)
|
||||||
browseError.value = error || ''
|
|
||||||
if (error) {
|
// Auto-fallback: if path doesn't exist, try parent directories automatically
|
||||||
snackbar(error, 'warning')
|
if (error && isNotFoundError(error) && path !== '/') {
|
||||||
|
browseError.value = ''
|
||||||
|
loading.value = false
|
||||||
|
const parent = parentRemotePath(path)
|
||||||
|
if (parent) {
|
||||||
|
snackbar(`路径 "${path}" 不存在,已自动跳转到上级目录`, 'info')
|
||||||
|
currentPath.value = parent
|
||||||
|
void browse()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
browseError.value = error ? friendlyBrowseError(error) : ''
|
||||||
|
if (error && !isNotFoundError(error)) {
|
||||||
|
snackbar(friendlyBrowseError(error), 'warning')
|
||||||
}
|
}
|
||||||
files.value = items
|
files.value = items
|
||||||
if (selectedServer.value && items.length) {
|
if (selectedServer.value && items.length) {
|
||||||
@@ -828,6 +855,19 @@ async function browse() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 判断 ls 报错是否为「路径不存在」(与权限无关) */
|
||||||
|
function isNotFoundError(err: string): boolean {
|
||||||
|
return err.includes('No such file or directory') || err.includes('not a directory')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将原始 ls 错误转为用户友好文案 */
|
||||||
|
function friendlyBrowseError(err: string): string {
|
||||||
|
if (isNotFoundError(err)) return `路径不存在:${currentPath.value},请检查服务器「target_path」设置`
|
||||||
|
if (err.includes('Permission denied') || err.includes('Operation not permitted'))
|
||||||
|
return `无读取权限(${currentPath.value})`
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshFilesCapability() {
|
async function refreshFilesCapability() {
|
||||||
if (!selectedServer.value) {
|
if (!selectedServer.value) {
|
||||||
filesCapability.value = null
|
filesCapability.value = null
|
||||||
@@ -842,17 +882,42 @@ async function refreshFilesCapability() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultPathForServer(serverId: number | null): string {
|
||||||
|
if (serverId == null) return DEFAULT_FILES_ROOT
|
||||||
|
const srv = serverList.value.find((s) => s.id === serverId)
|
||||||
|
const tp = srv?.target_path?.trim()
|
||||||
|
return tp ? normalizeRemotePath(tp) : DEFAULT_FILES_ROOT
|
||||||
|
}
|
||||||
|
|
||||||
function onServerChange() {
|
function onServerChange() {
|
||||||
|
if (selectedServer.value) {
|
||||||
|
sessionStorage.setItem(FILES_LAST_SERVER_KEY, String(selectedServer.value))
|
||||||
|
}
|
||||||
clearFilePreloadCache()
|
clearFilePreloadCache()
|
||||||
currentPath.value = DEFAULT_FILES_ROOT
|
currentPath.value = defaultPathForServer(selectedServer.value)
|
||||||
fileFilter.value = ''
|
fileFilter.value = ''
|
||||||
selectedFiles.value = []
|
selectedFiles.value = []
|
||||||
lastReadDeniedPath.value = null
|
lastReadDeniedPath.value = null
|
||||||
accessHintDismissed.value = false
|
accessHintDismissed.value = false
|
||||||
|
sessionStorage.removeItem(FILES_HINT_DISMISSED_KEY)
|
||||||
void refreshFilesCapability()
|
void refreshFilesCapability()
|
||||||
browse()
|
browse()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveInitialServerId(): number | null {
|
||||||
|
const qServer = route.query.server_id
|
||||||
|
if (qServer) {
|
||||||
|
const id = Number(qServer)
|
||||||
|
if (Number.isFinite(id) && serverList.value.some((s) => s.id === id)) return id
|
||||||
|
}
|
||||||
|
const stored = sessionStorage.getItem(FILES_LAST_SERVER_KEY)
|
||||||
|
if (stored) {
|
||||||
|
const id = Number(stored)
|
||||||
|
if (Number.isFinite(id) && serverList.value.some((s) => s.id === id)) return id
|
||||||
|
}
|
||||||
|
return serverList.value[0]?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
function onEditorSaved(path: string) {
|
function onEditorSaved(path: string) {
|
||||||
if (selectedServer.value && path) {
|
if (selectedServer.value && path) {
|
||||||
invalidateCachedFile(selectedServer.value, path)
|
invalidateCachedFile(selectedServer.value, path)
|
||||||
@@ -871,6 +936,35 @@ function rejectOversizedFile(item: FileEntry, action: '编辑' | '查看'): bool
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FILES_LAST_SERVER_KEY = 'nexus:files:last_server_id'
|
||||||
|
const FILES_HINT_DISMISSED_KEY = 'nexus:files-hint-dismissed'
|
||||||
|
|
||||||
|
function isRowActionTarget(target: EventTarget | null): boolean {
|
||||||
|
const el = target instanceof Element ? target : null
|
||||||
|
return Boolean(el?.closest('.v-selection-control, .v-btn, .v-checkbox, button, a'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vuetify 4 已移除 @click:row,通过 row-props 绑定行级事件 */
|
||||||
|
function fileRowProps(data: { item: FileEntry }) {
|
||||||
|
const item = data.item
|
||||||
|
return {
|
||||||
|
class: item.type === 'directory' ? 'files-row--dir' : undefined,
|
||||||
|
style: { cursor: item.type === 'directory' || item.type === 'symlink' ? 'pointer' : 'default' },
|
||||||
|
onClick: (e: MouseEvent) => {
|
||||||
|
if (isRowActionTarget(e.target)) return
|
||||||
|
onRowClick(e, { item })
|
||||||
|
},
|
||||||
|
onDblclick: (e: MouseEvent) => {
|
||||||
|
if (isRowActionTarget(e.target)) return
|
||||||
|
onRowDblClick(e, { item })
|
||||||
|
},
|
||||||
|
onContextmenu: (e: MouseEvent) => {
|
||||||
|
if (isRowActionTarget(e.target)) return
|
||||||
|
onRowContext(e, { item })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onRowClick(_event: Event, { item }: { item: FileEntry }) {
|
function onRowClick(_event: Event, { item }: { item: FileEntry }) {
|
||||||
if (item.type === 'directory' || item.type === 'symlink') {
|
if (item.type === 'directory' || item.type === 'symlink') {
|
||||||
openEntry(item)
|
openEntry(item)
|
||||||
@@ -1317,16 +1411,23 @@ function formatSize(bytes: number | undefined) {
|
|||||||
// ── Init ──
|
// ── Init ──
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadServers()
|
await loadServers()
|
||||||
const qServer = route.query.server_id
|
if (sessionStorage.getItem(FILES_HINT_DISMISSED_KEY)) {
|
||||||
const qPath = route.query.path
|
accessHintDismissed.value = true
|
||||||
if (qServer) {
|
|
||||||
selectedServer.value = Number(qServer)
|
|
||||||
if (typeof qPath === 'string' && qPath) {
|
|
||||||
currentPath.value = normalizeRemotePath(qPath)
|
|
||||||
}
|
|
||||||
await refreshFilesCapability()
|
|
||||||
browse()
|
|
||||||
}
|
}
|
||||||
|
if (!serverList.value.length) {
|
||||||
|
snackbar('暂无可用服务器,请先在「服务器」页添加', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const initialId = resolveInitialServerId()
|
||||||
|
if (initialId == null) return
|
||||||
|
selectedServer.value = initialId
|
||||||
|
const qPath = route.query.path
|
||||||
|
currentPath.value =
|
||||||
|
typeof qPath === 'string' && qPath
|
||||||
|
? normalizeRemotePath(qPath)
|
||||||
|
: defaultPathForServer(initialId)
|
||||||
|
await refreshFilesCapability()
|
||||||
|
browse()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -1335,9 +1436,11 @@ watch(
|
|||||||
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
|
const qPath = route.query.path
|
||||||
currentPath.value = typeof qPath === 'string' && qPath ? normalizeRemotePath(qPath) : DEFAULT_FILES_ROOT
|
currentPath.value =
|
||||||
|
typeof qPath === 'string' && qPath
|
||||||
|
? normalizeRemotePath(qPath)
|
||||||
|
: defaultPathForServer(Number(id))
|
||||||
lastReadDeniedPath.value = null
|
lastReadDeniedPath.value = null
|
||||||
accessHintDismissed.value = false
|
|
||||||
void refreshFilesCapability()
|
void refreshFilesCapability()
|
||||||
browse()
|
browse()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user