mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-13 02:54:26 +08:00
fix: 修复 MSD ISO/FLASH 挂载识别错误;完善错误提示
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
|
||||
@@ -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: '虚拟介质断开失败,请重试或检查系统日志。',
|
||||
},
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user