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)
})
}