fix: 修复 MSD ISO/FLASH 挂载识别错误;完善错误提示

This commit is contained in:
mofeng-git
2026-07-26 22:59:27 +08:00
parent 27c8da9a75
commit 376dc97134
24 changed files with 1703 additions and 522 deletions

View File

@@ -1,4 +1,4 @@
import { request, ApiError } from './request'
import { request, uploadRequest, ApiError } from './request'
import type {
CanonicalKey,
Ch9329DescriptorState,
@@ -620,61 +620,42 @@ export const msdApi = {
} | null
usb_reenumerating: boolean
}
}>('/msd/status'),
}>('/msd/status', {}, { toastOnError: false }),
listImages: () => request<MsdImage[]>('/msd/images'),
listImages: () => request<MsdImage[]>('/msd/images', {}, { toastOnError: false }),
uploadImage: async (file: File, onProgress?: (progress: number) => void) => {
const formData = new FormData()
formData.append('file', file)
const xhr = new XMLHttpRequest()
xhr.open('POST', `${API_BASE}/msd/images`)
xhr.withCredentials = true
return new Promise<MsdImage>((resolve, reject) => {
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) {
onProgress((e.loaded / e.total) * 100)
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText))
} else {
reject(new ApiError(xhr.status, 'Upload failed'))
}
}
xhr.onerror = () => reject(new ApiError(0, 'Network error'))
xhr.send(formData)
return uploadRequest<MsdImage>('/msd/images', formData, onProgress, {
errorTitleKey: 'msd.operations.uploadImage',
})
},
deleteImage: (id: string) =>
request<{ success: boolean }>(`/msd/images/${id}`, { method: 'DELETE' }),
request<{ success: boolean }>(`/msd/images/${id}`, { method: 'DELETE' }, { errorTitleKey: 'msd.operations.deleteImage' }),
setDiskMode: (diskMode: DiskMode) =>
request<{ success: boolean }>('/msd/disk-mode', {
method: 'PUT',
body: JSON.stringify({ disk_mode: diskMode }),
}),
}, { errorTitleKey: 'msd.operations.changeMode' }),
mountImage: (id: string, cdrom: boolean, readOnly: boolean) =>
request<{ success: boolean }>(`/msd/images/${id}/mount`, {
method: 'POST',
body: JSON.stringify({ cdrom, read_only: readOnly }),
}),
}, { errorTitleKey: 'msd.operations.mountImage' }),
unmountImage: (id: string) =>
request<{ success: boolean }>(`/msd/images/${id}/mount`, { method: 'DELETE' }),
request<{ success: boolean }>(`/msd/images/${id}/mount`, { method: 'DELETE' }, { errorTitleKey: 'msd.operations.unmountImage' }),
mountDrive: () =>
request<{ success: boolean }>('/msd/drive/mount', { method: 'POST' }),
request<{ success: boolean }>('/msd/drive/mount', { method: 'POST' }, { errorTitleKey: 'msd.operations.mountDrive' }),
unmountDrive: () =>
request<{ success: boolean }>('/msd/drive/mount', { method: 'DELETE' }),
request<{ success: boolean }>('/msd/drive/mount', { method: 'DELETE' }, { errorTitleKey: 'msd.operations.unmountDrive' }),
driveInfo: () =>
request<{
@@ -696,11 +677,11 @@ export const msdApi = {
method: 'POST',
body: JSON.stringify({ size_mb: sizeMb }),
},
{ toastOnError: false },
{ errorTitleKey: 'msd.operations.initializeDrive' },
),
deleteDrive: () =>
request<{ success: boolean }>('/msd/drive', { method: 'DELETE' }),
request<{ success: boolean }>('/msd/drive', { method: 'DELETE' }, { errorTitleKey: 'msd.operations.deleteDrive' }),
listDriveFiles: (path = '/') =>
request<DriveFile[]>(
@@ -713,28 +694,12 @@ export const msdApi = {
const formData = new FormData()
formData.append('file', file)
const xhr = new XMLHttpRequest()
xhr.open('POST', `${API_BASE}/msd/drive/files?path=${encodeURIComponent(targetPath)}`)
xhr.withCredentials = true
return new Promise<{ success: boolean; message?: string }>((resolve, reject) => {
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) {
onProgress((e.loaded / e.total) * 100)
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText))
} else {
reject(new ApiError(xhr.status, 'Upload failed'))
}
}
xhr.onerror = () => reject(new ApiError(0, 'Network error'))
xhr.send(formData)
})
return uploadRequest<{ success: boolean; message?: string }>(
`/msd/drive/files?path=${encodeURIComponent(targetPath)}`,
formData,
onProgress,
{ errorTitleKey: 'msd.operations.uploadDriveFile' },
)
},
downloadDriveFile: (path: string) =>
@@ -743,12 +708,12 @@ export const msdApi = {
deleteDriveFile: (path: string) =>
request<{ success: boolean }>(`/msd/drive/files${encodeDrivePath(path)}`, {
method: 'DELETE',
}),
}, { errorTitleKey: 'msd.operations.deleteDriveFile' }),
createDirectory: (path: string) =>
request<{ success: boolean }>(`/msd/drive/mkdir${encodeDrivePath(path)}`, {
method: 'POST',
}),
}, { errorTitleKey: 'msd.operations.createDirectory' }),
downloadFromUrl: (url: string, filename?: string) =>
request<{
@@ -759,17 +724,17 @@ export const msdApi = {
total_bytes: number | null
progress_pct: number | null
status: string
error: string | null
error_code: string | null
}>('/msd/images/download', {
method: 'POST',
body: JSON.stringify({ url, filename }),
}),
}, { errorTitleKey: 'msd.operations.startDownload' }),
cancelDownload: (downloadId: string) =>
request<{ success: boolean }>('/msd/images/download/cancel', {
method: 'POST',
body: JSON.stringify({ download_id: downloadId }),
}),
}, { errorTitleKey: 'msd.operations.cancelDownload' }),
}
interface SerialDeviceOption {

View File

@@ -29,11 +29,13 @@ function hasTranslation(key: string): boolean {
export class ApiError extends Error {
status: number
code?: string
constructor(status: number, message: string) {
constructor(status: number, message: string, code?: string) {
super(message)
this.name = 'ApiError'
this.status = status
this.code = code
}
}
@@ -47,26 +49,69 @@ export interface ApiRequestConfig {
* Toast debounce key. Defaults to `error_${endpoint}`.
*/
toastKey?: string
/** Translation key used as the error toast title. */
errorTitleKey?: string
}
function getToastKey(endpoint: string, config?: ApiRequestConfig): string {
return config?.toastKey ?? `error_${endpoint}`
}
function getErrorMessage(data: unknown, fallback: string): string {
function isAuthenticationIssue(status: number, message: string): boolean {
const normalized = message.toLowerCase()
return status === 401 && (
normalized.includes('not authenticated')
|| normalized.includes('session expired')
|| normalized.includes('logged in elsewhere')
)
}
const msdErrorKeys: Record<string, string> = {
MSD_UNAVAILABLE: 'msd.errors.unavailable',
MSD_OPERATION_IN_PROGRESS: 'msd.errors.operationInProgress',
MSD_OPERATION_FAILED: 'msd.errors.operationFailed',
MSD_INVALID_REQUEST: 'msd.errors.invalidRequest',
MSD_RESOURCE_NOT_FOUND: 'msd.errors.resourceNotFound',
MSD_RESOURCE_ALREADY_EXISTS: 'msd.errors.resourceAlreadyExists',
MSD_MEDIA_SLOTS_FULL: 'msd.errors.mediaSlotsFull',
MSD_MEDIA_ALREADY_MOUNTED: 'msd.errors.mediaAlreadyMounted',
MSD_MEDIA_IN_USE: 'msd.errors.mediaInUse',
MSD_IMAGE_TOO_LARGE: 'msd.errors.imageTooLarge',
MSD_INVALID_URL: 'msd.errors.invalidUrl',
MSD_REMOTE_DOWNLOAD_FAILED: 'msd.errors.remoteDownloadFailed',
MSD_DOWNLOAD_INCOMPLETE: 'msd.errors.downloadIncomplete',
MSD_DRIVE_NOT_INITIALIZED: 'msd.errors.driveNotInitialized',
MSD_DRIVE_CONNECTED: 'msd.errors.driveConnected',
MSD_DRIVE_FILESYSTEM_UNSUPPORTED: 'msd.errors.driveFilesystemUnsupported',
MSD_DRIVE_SIZE_INVALID: 'msd.errors.driveSizeInvalid',
MSD_STORAGE_SPACE_UNAVAILABLE: 'msd.errors.storageSpaceUnavailable',
MSD_STORAGE_FULL: 'msd.errors.storageFull',
MSD_STORAGE_READ_ONLY: 'msd.errors.storageReadOnly',
MSD_STORAGE_PERMISSION_DENIED: 'msd.errors.storagePermissionDenied',
MSD_MEDIUM_REMOVAL_PREVENTED: 'msd.errors.mediumRemovalPrevented',
MSD_DISCONNECT_FAILED: 'msd.errors.disconnectFailed',
}
export function localizeMsdErrorCode(code?: string, fallback?: string): string {
const key = code ? msdErrorKeys[code] : undefined
if (key && hasTranslation(key)) return t(key)
return fallback ? localizeBackendErrorMessage(fallback) : t('msd.errors.operationFailed')
}
function getErrorDetails(data: unknown, fallback: string): { message: string; code?: string } {
if (data && typeof data === 'object') {
const code = (data as any).code
const keyByCode: Record<string, string> = {
MSD_MEDIUM_REMOVAL_PREVENTED: 'msd.errors.mediumRemovalPrevented',
MSD_DISCONNECT_FAILED: 'msd.errors.disconnectFailed',
const normalizedCode = typeof code === 'string' ? code : undefined
if (normalizedCode && msdErrorKeys[normalizedCode]) {
return { message: localizeMsdErrorCode(normalizedCode), code: normalizedCode }
}
const key = typeof code === 'string' ? keyByCode[code] : undefined
if (key && hasTranslation(key)) return t(key)
const message = (data as any).message
if (typeof message === 'string' && message.trim()) return localizeBackendErrorMessage(message)
if (typeof message === 'string' && message.trim()) {
return { message: localizeBackendErrorMessage(message), code: normalizedCode }
}
}
return localizeBackendErrorMessage(fallback)
return { message: localizeBackendErrorMessage(fallback) }
}
function extractCh9329Command(reason: string): string {
@@ -141,6 +186,7 @@ export async function request<T>(
const url = `${API_BASE}${endpoint}`
const toastOnError = config.toastOnError !== false
const toastKey = getToastKey(endpoint, config)
const errorTitle = t(config.errorTitleKey ?? 'api.operationFailed')
try {
const response = await fetch(url, {
@@ -156,40 +202,35 @@ export async function request<T>(
// Handle HTTP errors (in case backend returns non-2xx)
if (!response.ok) {
const message = getErrorMessage(data, `HTTP ${response.status}`)
const normalized = message.toLowerCase()
const isNotAuthenticated = normalized.includes('not authenticated')
const isSessionExpired = normalized.includes('session expired')
const isLoggedInElsewhere = normalized.includes('logged in elsewhere')
const isAuthIssue = response.status === 401 && (isNotAuthenticated || isSessionExpired || isLoggedInElsewhere)
if (toastOnError && shouldShowToast(toastKey) && !isAuthIssue) {
toast.error(t('api.operationFailed'), {
const { message, code } = getErrorDetails(data, `HTTP ${response.status}`)
if (toastOnError && shouldShowToast(toastKey) && !isAuthenticationIssue(response.status, message)) {
toast.error(errorTitle, {
description: message,
duration: 4000,
})
}
throw new ApiError(response.status, message)
throw new ApiError(response.status, message, code)
}
// Handle backend "success=false" convention (even when HTTP is 200)
if (data && typeof (data as any).success === 'boolean' && !(data as any).success) {
const message = getErrorMessage(data, t('api.operationFailedDesc'))
const { message, code } = getErrorDetails(data, t('api.operationFailedDesc'))
if (toastOnError && shouldShowToast(toastKey)) {
toast.error(t('api.operationFailed'), {
toast.error(errorTitle, {
description: message,
duration: 4000,
})
}
throw new ApiError(response.status, message)
throw new ApiError(response.status, message, code)
}
// If response body isn't JSON (or empty), treat as failure for callers expecting JSON.
if (data === null) {
const message = t('api.parseResponseFailed')
if (toastOnError && shouldShowToast(toastKey)) {
toast.error(t('api.operationFailed'), {
toast.error(errorTitle, {
description: message,
duration: 4000,
})
@@ -211,3 +252,55 @@ export async function request<T>(
throw new ApiError(0, t('api.networkError'))
}
}
export function uploadRequest<T>(
endpoint: string,
formData: FormData,
onProgress?: (progress: number) => void,
config: ApiRequestConfig = {},
): Promise<T> {
const xhr = new XMLHttpRequest()
xhr.open('POST', `${API_BASE}${endpoint}`)
xhr.withCredentials = true
return new Promise<T>((resolve, reject) => {
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && onProgress) onProgress((event.loaded / event.total) * 100)
}
xhr.onload = () => {
const data: unknown = (() => {
try { return JSON.parse(xhr.responseText) } catch { return null }
})()
if (xhr.status >= 200 && xhr.status < 300 && data !== null) {
resolve(data as T)
return
}
const { message, code } = getErrorDetails(data, `HTTP ${xhr.status}`)
const error = new ApiError(xhr.status, message, code)
if (
config.toastOnError !== false
&& shouldShowToast(getToastKey(endpoint, config))
&& !isAuthenticationIssue(xhr.status, message)
) {
toast.error(t(config.errorTitleKey ?? 'api.operationFailed'), {
description: message,
duration: 4000,
})
}
reject(error)
}
xhr.onerror = () => {
if (config.toastOnError !== false && shouldShowToast('network_error')) {
toast.error(t('api.networkError'), {
description: t('api.networkErrorDesc'),
duration: 4000,
})
}
reject(new ApiError(0, t('api.networkError')))
}
xhr.send(formData)
})
}

View File

@@ -352,7 +352,7 @@ const hasRightOverflow = computed(() => {
</Button>
</PopoverTrigger>
<PopoverContent class="w-[min(400px,90vw)] p-0" align="start">
<PasteModal @close="pasteOpen = false" />
<PasteModal v-if="pasteOpen" @close="pasteOpen = false" />
</PopoverContent>
</Popover>
</div>
@@ -575,7 +575,7 @@ const hasRightOverflow = computed(() => {
<SheetHeader class="mb-2">
<SheetTitle>{{ t('actionbar.paste') }}</SheetTitle>
</SheetHeader>
<PasteModal @close="mobilePasteOpen = false" />
<PasteModal v-if="mobilePasteOpen" @close="mobilePasteOpen = false" />
</SheetContent>
</Sheet>

View File

@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { useSystemStore } from '@/stores/system'
import { msdApi, type MsdImage, type DriveFile, type MountedMedia, type DiskMode } from '@/api'
import { ApiError } from '@/api/request'
import { ApiError, localizeMsdErrorCode } from '@/api/request'
import { useWebSocket } from '@/composables/useWebSocket'
import {
Dialog,
@@ -64,9 +64,11 @@ const systemStore = useSystemStore()
const { on, off } = useWebSocket()
const activeTab = ref('images')
const msdStatusError = ref<string | null>(null)
const images = ref<MsdImage[]>([])
const loadingImages = ref(false)
const imagesError = ref<string | null>(null)
const uploadProgress = ref(0)
const uploading = ref(false)
@@ -92,6 +94,8 @@ const driveInitialized = ref(false)
const uploadingFile = ref(false)
const fileUploadProgress = ref(0)
const driveError = ref<string | null>(null) // filesystem error (e.g. unsupported format)
const driveErrorCode = ref<string | null>(null)
const driveFilesystemUnsupported = computed(() => driveErrorCode.value === 'MSD_DRIVE_FILESYSTEM_UNSUPPORTED')
const showDeleteDialog = ref(false)
const deleteTarget = ref<{ type: 'image' | 'file'; id: string; name: string } | null>(null)
@@ -150,7 +154,9 @@ const downloadProgress = ref<{
total_bytes: number | null
progress_pct: number | null
status: string
error_code: string | null
} | null>(null)
const downloadFailureNotifiedId = ref<string | null>(null)
const TWO_POINT_TWO_GB = 2.2 * 1024 * 1024 * 1024
const tabTriggerClass = 'h-8 rounded-md border-0 bg-transparent text-center text-muted-foreground shadow-none hover:text-foreground data-[state=active]:border-0 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm'
@@ -241,7 +247,7 @@ async function refreshDiskSpace() {
async function loadData() {
await refreshDiskSpace()
await systemStore.fetchMsdState()
await refreshMsdState()
await loadImages()
await loadDriveInfo()
if (driveInitialized.value) {
@@ -249,12 +255,24 @@ async function loadData() {
}
}
async function refreshMsdState() {
msdStatusError.value = null
try {
await systemStore.fetchMsdState()
} catch (e: any) {
msdStatusError.value = e?.message ?? t('msd.errors.operationFailed')
}
}
async function loadImages() {
loadingImages.value = true
imagesError.value = null
try {
images.value = await msdApi.listImages()
} catch (e) {
} catch (e: any) {
console.error('Failed to load images:', e)
imagesError.value = e?.message ?? t('msd.errors.operationFailed')
images.value = []
} finally {
loadingImages.value = false
}
@@ -308,7 +326,7 @@ async function confirmImageMount() {
connecting.value = true
try {
await msdApi.mountImage(image.id, cdromMode.value, cdromMode.value || readOnly.value)
await systemStore.fetchMsdState()
await refreshMsdState()
showMountOptionsDialog.value = false
pendingMountImage.value = null
} catch (e) {
@@ -333,7 +351,7 @@ async function connectDrive() {
connecting.value = true
try {
await msdApi.mountDrive()
await systemStore.fetchMsdState()
await refreshMsdState()
} catch (e) {
console.error('Failed to mount drive:', e)
} finally {
@@ -352,7 +370,7 @@ async function unmountMedia(media: MountedMedia) {
} else {
await msdApi.unmountImage(media.id)
}
await systemStore.fetchMsdState()
await refreshMsdState()
} catch (e) {
console.error('Failed to unmount media:', e)
} finally {
@@ -374,7 +392,7 @@ async function changeDiskMode(value: unknown) {
modeChanging.value = true
try {
await msdApi.setDiskMode(next as DiskMode)
await systemStore.fetchMsdState()
await refreshMsdState()
} catch (e) {
console.error('Failed to change MSD disk mode:', e)
} finally {
@@ -408,9 +426,8 @@ async function executeDelete() {
await msdApi.deleteDriveFile(deleteTarget.value.id)
await loadDriveFiles()
}
} catch (e: any) {
} catch (e) {
console.error('Failed to delete:', e)
toast.error(t('common.error'), { description: e?.message })
} finally {
showDeleteDialog.value = false
deleteTarget.value = null
@@ -420,12 +437,13 @@ async function executeDelete() {
async function loadDriveInfo() {
driveError.value = null
driveErrorCode.value = null
try {
driveInfo.value = await msdApi.driveInfo()
driveInitialized.value = true
} catch (e: any) {
if (e instanceof ApiError) {
if (e.status === 404) {
if (e.code === 'MSD_DRIVE_NOT_INITIALIZED' || e.status === 404) {
// Drive image file does not exist — truly not initialized
driveInitialized.value = false
driveInfo.value = null
@@ -435,6 +453,7 @@ async function loadDriveInfo() {
// an error banner instead of the misleading "Initialize Drive" button.
driveInitialized.value = true
driveError.value = e.message
driveErrorCode.value = e.code ?? null
driveInfo.value = null
}
} else {
@@ -469,16 +488,6 @@ async function createDrive() {
showDriveInitDialog.value = false
} catch (e) {
console.error('Failed to initialize drive:', e)
let description: string | undefined
if (e instanceof ApiError) {
const message = e.message
if (message.includes('does not support a virtual drive file')) description = t('msd.driveFileTooLarge')
else if (message.includes('does not have enough free space')) description = t('msd.driveSpaceUnavailable')
else if (message.includes('filesystem is read-only')) description = t('msd.driveReadOnly')
else if (message.includes('permission to write')) description = t('msd.drivePermissionDenied')
else description = message
}
toast.error(t('msd.driveCreateFailed'), { description })
} finally {
initializingDrive.value = false
}
@@ -510,12 +519,14 @@ async function loadDriveFiles() {
}
loadingDrive.value = true
driveError.value = null
driveErrorCode.value = null
try {
driveFiles.value = await msdApi.listDriveFiles(currentPath.value)
} catch (e: any) {
console.error('Failed to load drive files:', e)
// Surface the error — could be unsupported filesystem format
driveError.value = e?.message ?? String(e)
driveErrorCode.value = e instanceof ApiError ? (e.code ?? null) : null
driveFiles.value = []
} finally {
loadingDrive.value = false
@@ -564,9 +575,8 @@ async function handleFileUpload(e: Event) {
fileUploadProgress.value = progress
})
await loadDriveFiles()
} catch (e: any) {
} catch (e) {
console.error('Failed to upload file:', e)
toast.error(t('msd.uploadFailed'), { description: e?.message })
} finally {
uploadingFile.value = false
fileUploadProgress.value = 0
@@ -591,9 +601,8 @@ async function createFolder() {
: currentPath.value + '/' + newFolderName.value
await msdApi.createDirectory(path)
await loadDriveFiles()
} catch (e: any) {
} catch (e) {
console.error('Failed to create folder:', e)
toast.error(t('common.error'), { description: e?.message })
} finally {
showNewFolderDialog.value = false
newFolderName.value = ''
@@ -616,6 +625,7 @@ async function startUrlDownload() {
total_bytes: result.total_bytes,
progress_pct: result.progress_pct,
status: result.status,
error_code: result.error_code,
}
} catch (e) {
console.error('Failed to start download:', e)
@@ -649,6 +659,7 @@ function handleDownloadProgress(data: {
total_bytes: number | null
progress_pct: number | null
status: string
error_code: string | null
}) {
if (downloadProgress.value?.download_id === data.download_id) {
downloadProgress.value = data
@@ -659,8 +670,14 @@ function handleDownloadProgress(data: {
showUrlDialog.value = false
resetDownloadState()
}, 1000)
} else if (data.status.startsWith('failed')) {
} else if (data.status === 'failed') {
downloading.value = false
if (downloadFailureNotifiedId.value !== data.download_id) {
downloadFailureNotifiedId.value = data.download_id
toast.error(t('msd.operations.downloadImage'), {
description: localizeMsdErrorCode(data.error_code ?? undefined),
})
}
}
}
}
@@ -747,6 +764,17 @@ onUnmounted(() => {
<Separator class="shrink-0" />
<div
v-if="msdStatusError"
class="mx-5 mt-3 flex shrink-0 items-center justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/5 p-3"
>
<div class="min-w-0">
<p class="text-sm font-medium text-destructive">{{ t('msd.operations.loadStatus') }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ msdStatusError }}</p>
</div>
<Button variant="outline" size="sm" @click="refreshMsdState">{{ t('common.retry') }}</Button>
</div>
<div class="flex-1 min-h-0 flex flex-col px-5 pb-4 pt-3">
<Tabs v-model="activeTab" class="flex-1 flex flex-col min-h-0">
<TabsList class="grid h-auto w-full shrink-0 grid-cols-2 gap-1 rounded-md border border-border bg-muted p-0.5">
@@ -796,6 +824,16 @@ onUnmounted(() => {
<Progress v-if="uploading" :model-value="uploadProgress" class="h-1 shrink-0" />
<Skeleton v-if="loadingImages" class="h-24 w-full" />
<div
v-else-if="imagesError"
class="flex shrink-0 items-center justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/5 p-3"
>
<div class="min-w-0">
<p class="text-sm font-medium text-destructive">{{ t('msd.operations.loadImages') }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ imagesError }}</p>
</div>
<Button variant="outline" size="sm" @click="loadImages">{{ t('common.retry') }}</Button>
</div>
<Empty v-else-if="images.length === 0" class="shrink-0 py-6">
<EmptyHeader>
<EmptyMedia variant="icon"><HardDrive /></EmptyMedia>
@@ -929,7 +967,7 @@ onUnmounted(() => {
<!-- Show unreadable badge when format is wrong -->
<template v-else-if="driveError">
<Badge variant="outline" class="text-xs border-destructive/50 text-destructive">
{{ t('msd.driveUnreadable') }}
{{ driveFilesystemUnsupported ? t('msd.driveUnreadable') : t('common.error') }}
</Badge>
<Tooltip>
<TooltipTrigger as-child>
@@ -938,14 +976,14 @@ onUnmounted(() => {
</span>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('msd.driveUnreadableTooltip') }}</p>
<p>{{ driveError }}</p>
</TooltipContent>
</Tooltip>
</template>
</div>
<div class="flex items-center gap-1.5">
<!-- When drive format is unrecognized, only offer re-initialization -->
<template v-if="driveError && !msdConnected">
<template v-if="driveFilesystemUnsupported && !msdConnected">
<Button
variant="outline"
size="sm"
@@ -1013,6 +1051,17 @@ onUnmounted(() => {
</div>
</div>
<div
v-if="driveError"
class="flex shrink-0 items-center justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/5 p-3"
>
<div class="min-w-0">
<p class="text-sm font-medium text-destructive">{{ t('msd.operations.loadDriveFiles') }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ driveError }}</p>
</div>
<Button variant="outline" size="sm" @click="refreshDriveBrowser">{{ t('common.retry') }}</Button>
</div>
<!-- File Browser -->
<div class="flex-1 min-h-0 flex flex-col space-y-2">
@@ -1289,9 +1338,11 @@ onUnmounted(() => {
<!-- Image Mount Options Dialog -->
<Dialog v-model:open="showMountOptionsDialog">
<DialogContent class="max-w-md">
<DialogHeader>
<DialogHeader class="min-w-0">
<DialogTitle>{{ t('msd.mountImage') }}</DialogTitle>
<DialogDescription>
<DialogDescription
class="block min-w-0 truncate text-left"
>
{{ pendingMountImage?.name }}
</DialogDescription>
</DialogHeader>
@@ -1410,8 +1461,8 @@ onUnmounted(() => {
<div v-if="downloadProgress.status === 'completed'" class="text-xs text-success">
{{ t('msd.downloadComplete') }}
</div>
<div v-else-if="downloadProgress.status.startsWith('failed')" class="text-xs text-destructive">
{{ downloadProgress.status }}
<div v-else-if="downloadProgress.status === 'failed'" class="text-xs text-destructive">
{{ localizeMsdErrorCode(downloadProgress.error_code ?? undefined) }}
</div>
</div>
</div>

View File

@@ -17,7 +17,7 @@ const emit = defineEmits<{
const { t } = useI18n()
const text = ref('')
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const textareaRef = ref<{ focus: (options?: FocusOptions) => void } | null>(null)
const isPasting = ref(false)
const progress = ref(0)
const currentChar = ref(0)
@@ -36,9 +36,7 @@ const hasUntypableChars = computed(() => {
})
onMounted(() => {
setTimeout(() => {
textareaRef.value?.focus()
}, 100)
textareaRef.value?.focus()
})
onUnmounted(() => {

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { ref } from "vue"
import { useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils"
@@ -17,10 +18,19 @@ const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
})
const textareaElement = ref<HTMLTextAreaElement | null>(null)
function focus(options?: FocusOptions) {
textareaElement.value?.focus(options)
}
defineExpose({ focus })
</script>
<template>
<textarea
ref="textareaElement"
v-model="modelValue"
data-slot="textarea"
:class="cn('border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', props.class)"

View File

@@ -435,7 +435,48 @@ export default {
mediaCount: 'Media {count}/{capacity}',
mediaSlotsFull: 'Media slots are full; no more media can be mounted',
reenumerating: 'USB is re-enumerating',
operations: {
loadStatus: 'Failed to load virtual media status',
loadImages: 'Failed to load image list',
uploadImage: 'Image upload failed',
deleteImage: 'Image deletion failed',
mountImage: 'Image mounting failed',
unmountImage: 'Image unmounting failed',
mountDrive: 'Virtual drive mounting failed',
unmountDrive: 'Virtual drive unmounting failed',
changeMode: 'Drive mode change failed',
initializeDrive: 'Virtual drive initialization failed',
deleteDrive: 'Virtual drive deletion failed',
loadDriveFiles: 'Failed to load virtual drive files',
uploadDriveFile: 'File upload failed',
deleteDriveFile: 'File deletion failed',
createDirectory: 'Folder creation failed',
startDownload: 'Failed to start image download',
cancelDownload: 'Failed to cancel image download',
downloadImage: 'Image download failed',
},
errors: {
unavailable: 'Virtual media service is unavailable.',
operationInProgress: 'Another virtual media operation is in progress.',
operationFailed: 'The virtual media operation failed.',
invalidRequest: 'The virtual media request is invalid.',
resourceNotFound: 'The requested virtual media resource was not found.',
resourceAlreadyExists: 'The virtual media resource already exists.',
mediaSlotsFull: 'All virtual media slots are in use.',
mediaAlreadyMounted: 'The virtual medium is already mounted.',
mediaInUse: 'The virtual medium is currently in use.',
imageTooLarge: 'The virtual media image is too large.',
invalidUrl: 'The download URL is invalid.',
remoteDownloadFailed: 'The remote image download failed.',
downloadIncomplete: 'The remote image download was incomplete.',
driveNotInitialized: 'The virtual drive is not initialized.',
driveConnected: 'The virtual drive is connected to the controlled computer. Disconnect it before editing files.',
driveFilesystemUnsupported: 'The virtual drive filesystem is unsupported. Reinitialize it to continue.',
driveSizeInvalid: 'The virtual drive size is invalid.',
storageSpaceUnavailable: 'Available virtual media storage space could not be determined.',
storageFull: 'Virtual media storage does not have enough free space.',
storageReadOnly: 'Virtual media storage is read-only.',
storagePermissionDenied: 'Permission to access virtual media storage was denied.',
mediumRemovalPrevented: 'The controlled computer is using this virtual medium and has prevented its removal. Eject or unmount it on the controlled computer, then try again.',
disconnectFailed: 'Virtual media could not be disconnected. Please try again or check the system logs.',
},

View File

@@ -434,7 +434,48 @@ export default {
mediaCount: '介质 {count}/{capacity}',
mediaSlotsFull: '介质槽已满,无法挂载更多介质',
reenumerating: 'USB 正在重新枚举',
operations: {
loadStatus: '虚拟媒体状态加载失败',
loadImages: '镜像列表加载失败',
uploadImage: '镜像上传失败',
deleteImage: '镜像删除失败',
mountImage: '镜像挂载失败',
unmountImage: '镜像卸载失败',
mountDrive: '虚拟盘挂载失败',
unmountDrive: '虚拟盘卸载失败',
changeMode: '驱动器模式切换失败',
initializeDrive: '虚拟盘初始化失败',
deleteDrive: '虚拟盘删除失败',
loadDriveFiles: '虚拟盘文件列表加载失败',
uploadDriveFile: '虚拟盘文件上传失败',
deleteDriveFile: '虚拟盘文件删除失败',
createDirectory: '文件夹创建失败',
startDownload: '镜像下载启动失败',
cancelDownload: '镜像下载取消失败',
downloadImage: '镜像下载失败',
},
errors: {
unavailable: '虚拟媒体服务当前不可用。',
operationInProgress: '另一项虚拟媒体操作正在进行中,请稍候。',
operationFailed: '虚拟媒体操作失败。',
invalidRequest: '虚拟媒体请求无效。',
resourceNotFound: '未找到请求的虚拟媒体资源。',
resourceAlreadyExists: '虚拟媒体资源已存在。',
mediaSlotsFull: '虚拟媒体槽位已全部占用。',
mediaAlreadyMounted: '该虚拟介质已经挂载。',
mediaInUse: '该虚拟介质正在使用中。',
imageTooLarge: '虚拟媒体镜像过大。',
invalidUrl: '下载 URL 无效。',
remoteDownloadFailed: '远程镜像下载失败。',
downloadIncomplete: '远程镜像下载不完整。',
driveNotInitialized: '虚拟盘尚未初始化。',
driveConnected: '虚拟盘已连接到被控机,请先断开连接再操作文件。',
driveFilesystemUnsupported: '虚拟盘文件系统不受支持,请重新初始化后再操作。',
driveSizeInvalid: '虚拟盘大小无效。',
storageSpaceUnavailable: '无法获取虚拟媒体存储空间信息。',
storageFull: '虚拟媒体存储空间不足。',
storageReadOnly: '虚拟媒体存储为只读。',
storagePermissionDenied: '没有访问虚拟媒体存储的权限。',
mediumRemovalPrevented: '被控机正在使用该虚拟介质,并拒绝移除。请先在被控机中弹出或卸载该介质,然后重试。',
disconnectFailed: '虚拟介质断开失败,请重试或检查系统日志。',
},

View File

@@ -572,7 +572,7 @@ const msdQuickInfo = computed(() => {
const msd = systemStore.msd
if (!msd?.available) return ''
if (msd.mountedCount === 0) return t('statusCard.msdStandby')
return `${msd.diskMode === 'single' ? t('msd.singleDiskMode') : t('msd.multiDiskMode')} · ${t('msd.mediaCount', { count: msd.mountedCount, capacity: msd.slotCapacity })}`
return msd.diskMode === 'single' ? t('msd.singleDiskMode') : t('msd.multiDiskMode')
})
const msdErrorMessage = computed(() => {
@@ -605,18 +605,6 @@ const msdDetails = computed<StatusDetail[]>(() => {
status: msd.mountedCount > 0 ? 'ok' : undefined
})
if (msd.mountedMedia.length > 0) {
for (const media of msd.mountedMedia) {
details.push({
label: media.kind === 'drive' ? t('statusCard.msdDriveMode') : t('statusCard.msdCurrentImage'),
value: media.kind === 'drive'
? t('statusCard.msdDriveMode')
: `${media.name || media.id || t('statusCard.msdNoImage')} (${media.cdrom ? t('msd.cdrom') : t('msd.flash')})`,
status: 'ok'
})
}
}
return details
})