mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-10-30 01:10:50 +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)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user