mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-07-29 14:41:45 +08:00
feat: 虚拟媒体支持同时挂载多个镜像
This commit is contained in:
@@ -542,25 +542,34 @@ export interface DriveFile {
|
||||
size: number
|
||||
}
|
||||
|
||||
export type DiskMode = 'single' | 'multi'
|
||||
export type MountedMediaKind = 'drive' | 'image'
|
||||
|
||||
export interface MountedMedia {
|
||||
id: string
|
||||
kind: MountedMediaKind
|
||||
name: string
|
||||
cdrom: boolean
|
||||
read_only: boolean
|
||||
size: number
|
||||
}
|
||||
|
||||
export const msdApi = {
|
||||
status: () =>
|
||||
request<{
|
||||
available: boolean
|
||||
state: {
|
||||
connected: boolean
|
||||
mode: 'none' | 'image' | 'drive'
|
||||
current_image: {
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
created_at: string
|
||||
} | null
|
||||
disk_mode: DiskMode
|
||||
slot_capacity: number
|
||||
mounted_count: number
|
||||
mounted_media: MountedMedia[]
|
||||
drive_info: {
|
||||
size: number
|
||||
used: number
|
||||
free: number
|
||||
initialized: boolean
|
||||
} | null
|
||||
usb_reenumerating: boolean
|
||||
}
|
||||
}>('/msd/status'),
|
||||
|
||||
@@ -597,14 +606,26 @@ export const msdApi = {
|
||||
deleteImage: (id: string) =>
|
||||
request<{ success: boolean }>(`/msd/images/${id}`, { method: 'DELETE' }),
|
||||
|
||||
connect: (mode: 'image' | 'drive', imageId?: string, cdrom?: boolean, readOnly?: boolean) =>
|
||||
request<{ success: boolean }>('/msd/connect', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode, image_id: imageId, cdrom, read_only: readOnly }),
|
||||
setDiskMode: (diskMode: DiskMode) =>
|
||||
request<{ success: boolean }>('/msd/disk-mode', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ disk_mode: diskMode }),
|
||||
}),
|
||||
|
||||
disconnect: () =>
|
||||
request<{ success: boolean }>('/msd/disconnect', { method: 'POST' }),
|
||||
mountImage: (id: string, cdrom: boolean, readOnly: boolean) =>
|
||||
request<{ success: boolean }>(`/msd/images/${id}/mount`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ cdrom, read_only: readOnly }),
|
||||
}),
|
||||
|
||||
unmountImage: (id: string) =>
|
||||
request<{ success: boolean }>(`/msd/images/${id}/mount`, { method: 'DELETE' }),
|
||||
|
||||
mountDrive: () =>
|
||||
request<{ success: boolean }>('/msd/drive/mount', { method: 'POST' }),
|
||||
|
||||
unmountDrive: () =>
|
||||
request<{ success: boolean }>('/msd/drive/mount', { method: 'DELETE' }),
|
||||
|
||||
driveInfo: () =>
|
||||
request<{
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import { msdApi, type MsdImage, type DriveFile } from '@/api'
|
||||
import { msdApi, type MsdImage, type DriveFile, type MountedMedia, type DiskMode } from '@/api'
|
||||
import { ApiError } from '@/api/request'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
import {
|
||||
@@ -48,7 +48,6 @@ import {
|
||||
AlertCircle,
|
||||
Info,
|
||||
} from 'lucide-vue-next'
|
||||
import HelpTooltip from '@/components/HelpTooltip.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
@@ -77,8 +76,11 @@ const cdromMode = computed(() => mountMode.value === 'cdrom')
|
||||
const readOnly = computed(() => accessMode.value === 'readonly')
|
||||
|
||||
const connecting = ref(false)
|
||||
const disconnecting = ref(false)
|
||||
const deleting = ref(false)
|
||||
const modeChanging = ref(false)
|
||||
const unmountingMediaId = ref<string | null>(null)
|
||||
const pendingMountImage = ref<MsdImage | null>(null)
|
||||
const showMountOptionsDialog = ref(false)
|
||||
|
||||
const driveFiles = ref<DriveFile[]>([])
|
||||
const currentPath = ref('/')
|
||||
@@ -149,28 +151,62 @@ const downloadProgress = ref<{
|
||||
} | null>(null)
|
||||
|
||||
const TWO_POINT_TWO_GB = 2.2 * 1024 * 1024 * 1024
|
||||
const tabTriggerClass = 'h-9 rounded-md border border-transparent bg-transparent text-center text-muted-foreground shadow-none hover:text-foreground data-[state=active]:border-border data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm'
|
||||
const segmentedGroupClass = 'grid w-full grid-cols-2 items-center gap-1 rounded-md bg-muted p-1'
|
||||
const segmentedItemClass = 'h-8 w-full justify-center rounded-md border border-transparent bg-transparent px-3 text-center text-xs text-muted-foreground shadow-none hover:bg-background/60 hover:text-foreground data-[state=on]:border-border data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm'
|
||||
|
||||
const msdConnected = computed(() => systemStore.msd?.connected ?? false)
|
||||
const msdMode = computed(() => systemStore.msd?.mode ?? 'none')
|
||||
const diskMode = computed(() => systemStore.msd?.diskMode ?? 'single')
|
||||
const slotCapacity = computed(() => systemStore.msd?.slotCapacity ?? 1)
|
||||
const mountedMedia = computed<MountedMedia[]>(() => systemStore.msd?.mountedMedia ?? [])
|
||||
const mountedCount = computed(() => systemStore.msd?.mountedCount ?? mountedMedia.value.length)
|
||||
const msdConnected = computed(() => mountedCount.value > 0)
|
||||
const mediaSlotsFull = computed(() => mountedCount.value >= slotCapacity.value)
|
||||
const driveMedia = computed(() => mountedMedia.value.find(media => media.kind === 'drive') ?? null)
|
||||
// Drive is currently mounted on the target machine via USB — file ops are blocked
|
||||
const driveConnectedToTarget = computed(() => msdConnected.value && msdMode.value === 'drive')
|
||||
const driveConnectedToTarget = computed(() => !!driveMedia.value)
|
||||
|
||||
|
||||
|
||||
const operationInProgress = computed(() => {
|
||||
return connecting.value ||
|
||||
disconnecting.value ||
|
||||
deleting.value ||
|
||||
uploading.value ||
|
||||
uploadingFile.value ||
|
||||
initializingDrive.value ||
|
||||
deletingDrive.value
|
||||
deletingDrive.value ||
|
||||
modeChanging.value ||
|
||||
!!unmountingMediaId.value
|
||||
})
|
||||
|
||||
const usbReenumerating = computed(() => systemStore.msd?.usbReenumerating || modeChanging.value)
|
||||
|
||||
function isLargeFile(image: MsdImage): boolean {
|
||||
return image.size > TWO_POINT_TWO_GB
|
||||
}
|
||||
|
||||
function isIsoImage(image: MsdImage): boolean {
|
||||
return image.name.toLowerCase().endsWith('.iso')
|
||||
}
|
||||
|
||||
function mountedImage(imageId: string): MountedMedia | null {
|
||||
return mountedMedia.value.find(media => media.kind === 'image' && media.id === imageId) ?? null
|
||||
}
|
||||
|
||||
function updateMountMode(value: unknown) {
|
||||
const next = Array.isArray(value) ? value[0] : value
|
||||
if (next !== 'cdrom' && next !== 'flash') return
|
||||
mountMode.value = next
|
||||
if (next === 'cdrom') {
|
||||
accessMode.value = 'readonly'
|
||||
}
|
||||
}
|
||||
|
||||
function updateAccessMode(value: unknown) {
|
||||
const next = Array.isArray(value) ? value[0] : value
|
||||
if (next !== 'readonly' && next !== 'readwrite') return
|
||||
accessMode.value = next
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
const parts = currentPath.value.split('/').filter(Boolean)
|
||||
const crumbs = [{ name: '/', path: '/' }]
|
||||
@@ -251,12 +287,30 @@ async function connectImage(image: MsdImage) {
|
||||
return
|
||||
}
|
||||
|
||||
if (mountedImage(image.id)) return
|
||||
if (mediaSlotsFull.value) {
|
||||
toast.error(t('msd.mediaSlotsFull'))
|
||||
return
|
||||
}
|
||||
|
||||
pendingMountImage.value = image
|
||||
mountMode.value = isIsoImage(image) ? 'cdrom' : 'flash'
|
||||
accessMode.value = isIsoImage(image) ? 'readonly' : 'readwrite'
|
||||
showMountOptionsDialog.value = true
|
||||
}
|
||||
|
||||
async function confirmImageMount() {
|
||||
const image = pendingMountImage.value
|
||||
if (!image || operationInProgress.value) return
|
||||
|
||||
connecting.value = true
|
||||
try {
|
||||
await msdApi.connect('image', image.id, cdromMode.value, readOnly.value)
|
||||
await msdApi.mountImage(image.id, cdromMode.value, cdromMode.value || readOnly.value)
|
||||
await systemStore.fetchMsdState()
|
||||
showMountOptionsDialog.value = false
|
||||
pendingMountImage.value = null
|
||||
} catch (e) {
|
||||
console.error('Failed to connect image:', e)
|
||||
console.error('Failed to mount image:', e)
|
||||
} finally {
|
||||
connecting.value = false
|
||||
}
|
||||
@@ -268,30 +322,61 @@ async function connectDrive() {
|
||||
return
|
||||
}
|
||||
|
||||
if (driveConnectedToTarget.value) return
|
||||
if (mediaSlotsFull.value) {
|
||||
toast.error(t('msd.mediaSlotsFull'))
|
||||
return
|
||||
}
|
||||
|
||||
connecting.value = true
|
||||
try {
|
||||
await msdApi.connect('drive')
|
||||
await msdApi.mountDrive()
|
||||
await systemStore.fetchMsdState()
|
||||
} catch (e) {
|
||||
console.error('Failed to connect drive:', e)
|
||||
console.error('Failed to mount drive:', e)
|
||||
} finally {
|
||||
connecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
if (operationInProgress.value) {
|
||||
return
|
||||
}
|
||||
async function unmountMedia(media: MountedMedia) {
|
||||
if (operationInProgress.value) return
|
||||
|
||||
disconnecting.value = true
|
||||
unmountingMediaId.value = `${media.kind}:${media.id}`
|
||||
try {
|
||||
await msdApi.disconnect()
|
||||
if (media.kind === 'drive') {
|
||||
await msdApi.unmountDrive()
|
||||
await refreshDriveBrowser()
|
||||
} else {
|
||||
await msdApi.unmountImage(media.id)
|
||||
}
|
||||
await systemStore.fetchMsdState()
|
||||
} catch (e) {
|
||||
console.error('Failed to disconnect:', e)
|
||||
console.error('Failed to unmount media:', e)
|
||||
} finally {
|
||||
disconnecting.value = false
|
||||
unmountingMediaId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function unmountImageById(imageId: string) {
|
||||
const media = mountedImage(imageId)
|
||||
if (media) {
|
||||
await unmountMedia(media)
|
||||
}
|
||||
}
|
||||
|
||||
async function changeDiskMode(value: unknown) {
|
||||
const next = Array.isArray(value) ? value[0] : value
|
||||
if ((next !== 'single' && next !== 'multi') || next === diskMode.value || operationInProgress.value) return
|
||||
|
||||
modeChanging.value = true
|
||||
try {
|
||||
await msdApi.setDiskMode(next as DiskMode)
|
||||
await systemStore.fetchMsdState()
|
||||
} catch (e) {
|
||||
console.error('Failed to change MSD disk mode:', e)
|
||||
} finally {
|
||||
modeChanging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,35 +676,60 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<TooltipProvider>
|
||||
<Dialog :open="open" @update:open="emit('update:open', $event)">
|
||||
<DialogContent class="sm:max-w-[600px] max-h-[90vh] overflow-hidden flex flex-col p-0">
|
||||
<DialogContent class="max-h-[90vh] overflow-hidden p-0 sm:max-w-[760px] flex flex-col">
|
||||
<DialogHeader class="px-6 pt-6 shrink-0">
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<HardDrive class="h-5 w-5" />
|
||||
{{ t('msd.title') }}
|
||||
</DialogTitle>
|
||||
<DialogDescription class="flex items-center flex-wrap gap-x-2 gap-y-1 mt-1">
|
||||
<span :class="msdConnected ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'" class="flex items-center gap-1.5">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span v-if="msdConnected" class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span :class="msdConnected ? 'bg-green-500' : 'bg-muted-foreground'" class="relative inline-flex rounded-full h-2 w-2"></span>
|
||||
<DialogDescription as="div" class="mt-1 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span :class="msdConnected ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'" class="flex items-center gap-1.5">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span v-if="msdConnected" class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span :class="msdConnected ? 'bg-green-500' : 'bg-muted-foreground'" class="relative inline-flex rounded-full h-2 w-2"></span>
|
||||
</span>
|
||||
{{ msdConnected ? t('common.connected') : t('common.disconnected') }}
|
||||
</span>
|
||||
{{ msdConnected ? t('common.connected') : t('common.disconnected') }}
|
||||
</span>
|
||||
<template v-if="msdConnected">
|
||||
<span class="text-muted-foreground">·</span>
|
||||
<Badge variant="secondary" class="text-xs">{{ msdMode === 'drive' ? t('msd.drive') : t('msd.images') }}</Badge>
|
||||
<Button
|
||||
<Badge variant="secondary" class="h-6 rounded-md px-2 text-xs">{{ t('msd.mediaCount', { count: mountedCount, capacity: slotCapacity }) }}</Badge>
|
||||
<Badge
|
||||
v-if="mediaSlotsFull"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10 border-destructive/30"
|
||||
:disabled="operationInProgress"
|
||||
@click="disconnect"
|
||||
class="h-6 rounded-md border-amber-500/40 bg-amber-500/10 px-2 text-xs text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
<Unlink v-if="!disconnecting" class="h-3 w-3 mr-1" />
|
||||
<span v-if="disconnecting">{{ t('common.disconnecting') }}...</span>
|
||||
<span v-else>{{ t('msd.disconnect') }}</span>
|
||||
</Button>
|
||||
</template>
|
||||
{{ t('msd.mediaSlotsFull') }}
|
||||
</Badge>
|
||||
<span v-if="usbReenumerating" class="text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ t('msd.reenumerating') }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="flex w-full shrink-0 items-center gap-2 sm:w-auto">
|
||||
<span class="flex shrink-0 items-center gap-1.5">
|
||||
<span class="font-medium text-foreground">{{ t('msd.diskMode') }}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span class="inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground hover:text-foreground">
|
||||
<Info class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p class="max-w-xs">{{ t('msd.diskModeHint') }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<ToggleGroup
|
||||
:model-value="diskMode"
|
||||
type="single"
|
||||
size="sm"
|
||||
:class="[segmentedGroupClass, 'min-w-0 flex-1 sm:w-[200px] sm:flex-none']"
|
||||
:disabled="operationInProgress"
|
||||
@update:model-value="changeDiskMode"
|
||||
>
|
||||
<ToggleGroupItem value="single" :class="segmentedItemClass">{{ t('msd.singleDiskMode') }}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="multi" :class="segmentedItemClass">{{ t('msd.multiDiskMode') }}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -627,86 +737,51 @@ onUnmounted(() => {
|
||||
|
||||
<div class="flex-1 min-h-0 flex flex-col px-6 pb-6 pt-4">
|
||||
<Tabs v-model="activeTab" class="flex-1 flex flex-col min-h-0">
|
||||
<TabsList class="w-full grid grid-cols-2 shrink-0">
|
||||
<TabsTrigger value="images">
|
||||
<TabsList class="grid h-auto w-full shrink-0 grid-cols-2 gap-1 rounded-md border border-border bg-muted p-1">
|
||||
<TabsTrigger value="images" :class="tabTriggerClass">
|
||||
<Disc class="h-4 w-4 mr-1.5" />
|
||||
{{ t('msd.images') }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="drive">
|
||||
<TabsTrigger value="drive" :class="tabTriggerClass">
|
||||
<HardDrive class="h-4 w-4 mr-1.5" />
|
||||
{{ t('msd.drive') }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<!-- Tab Description -->
|
||||
<p class="text-xs text-muted-foreground mt-2 mb-1 shrink-0">
|
||||
{{ activeTab === 'images' ? t('msd.imagesDesc') : t('msd.driveDesc') }}
|
||||
</p>
|
||||
|
||||
<TabsContent value="images" class="flex-1 min-h-0 m-0 flex flex-col space-y-3">
|
||||
<!-- Compact Upload Toolbar -->
|
||||
<div class="shrink-0 flex items-center gap-2 min-w-0">
|
||||
<label class="flex-1">
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept=".iso,.img"
|
||||
:disabled="uploading"
|
||||
@change="handleImageUpload"
|
||||
/>
|
||||
<Button variant="outline" size="sm" as="span" class="w-full cursor-pointer">
|
||||
<Upload class="h-4 w-4 mr-1.5" />
|
||||
{{ t('msd.uploadImage') }}
|
||||
</Button>
|
||||
</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
@click="showUrlDialog = true"
|
||||
>
|
||||
<Globe class="h-4 w-4 mr-1.5" />
|
||||
{{ t('msd.downloadFromUrl') }}
|
||||
</Button>
|
||||
</div>
|
||||
<Progress v-if="uploading" :model-value="uploadProgress" class="h-1 shrink-0" />
|
||||
|
||||
<!-- Options - Vertical compact layout -->
|
||||
<div class="shrink-0 flex flex-wrap items-center gap-x-4 gap-y-2 p-2 rounded-lg bg-muted/50 text-xs min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-muted-foreground whitespace-nowrap">{{ t('msd.storageMode') }}:</span>
|
||||
<HelpTooltip :content="mountMode === 'flash' ? t('help.flashMode') : t('help.cdromMode')" icon-size="sm" />
|
||||
<ToggleGroup v-model="mountMode" type="single" variant="outline" size="sm">
|
||||
<ToggleGroupItem value="flash" class="h-6 px-2 text-xs data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
|
||||
{{ t('msd.flash') }}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="cdrom" class="h-6 px-2 text-xs data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
|
||||
{{ t('msd.cdrom') }}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-muted-foreground whitespace-nowrap">{{ t('msd.accessMode') }}:</span>
|
||||
<HelpTooltip :content="accessMode === 'readonly' ? t('help.readOnlyMode') : t('help.readWriteMode')" icon-size="sm" />
|
||||
<ToggleGroup v-model="accessMode" type="single" variant="outline" size="sm">
|
||||
<ToggleGroupItem value="readonly" class="h-6 px-2 text-xs data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
|
||||
{{ t('msd.readOnly') }}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="readwrite" class="h-6 px-2 text-xs data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
|
||||
{{ t('msd.readWrite') }}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="images" class="m-0 flex min-h-0 flex-1 flex-col space-y-3 pt-3">
|
||||
<!-- Image List -->
|
||||
<div class="flex-1 min-h-0 flex flex-col space-y-2 min-w-0">
|
||||
<div class="shrink-0 flex items-center justify-between">
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-between gap-2">
|
||||
<h4 class="text-sm font-medium">{{ t('msd.imageList') }}</h4>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="loadImages">
|
||||
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingImages }" />
|
||||
</Button>
|
||||
<div class="flex flex-wrap items-center justify-end gap-1.5">
|
||||
<label>
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept=".iso,.img"
|
||||
:disabled="uploading"
|
||||
@change="handleImageUpload"
|
||||
/>
|
||||
<Button variant="outline" size="sm" as="span" class="h-8 cursor-pointer px-2.5 text-xs">
|
||||
<Upload class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t('msd.uploadImage') }}
|
||||
</Button>
|
||||
</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 px-2.5 text-xs"
|
||||
@click="showUrlDialog = true"
|
||||
>
|
||||
<Globe class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t('msd.downloadFromUrl') }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="loadImages">
|
||||
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingImages }" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Progress v-if="uploading" :model-value="uploadProgress" class="h-1 shrink-0" />
|
||||
|
||||
<div v-if="images.length === 0" class="shrink-0 text-center py-6 text-muted-foreground text-sm">
|
||||
{{ t('msd.noImages') }}
|
||||
@@ -717,16 +792,17 @@ onUnmounted(() => {
|
||||
<div
|
||||
v-for="image in images"
|
||||
:key="image.id"
|
||||
class="p-3 rounded-lg border transition-colors"
|
||||
class="rounded-md border p-2.5 transition-colors"
|
||||
:class="[
|
||||
msdConnected && systemStore.msd?.imageId === image.id
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'hover:bg-accent/50'
|
||||
mountedImage(image.id)
|
||||
? 'border-primary/40 bg-muted/50'
|
||||
: 'border-border bg-background hover:bg-muted/40'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex items-start gap-2 w-0 flex-1">
|
||||
<Disc class="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex min-w-0 flex-1 items-start gap-2">
|
||||
<span v-if="mountedImage(image.id)" class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
<Disc v-else class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div class="w-0 flex-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
@@ -755,22 +831,25 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<template v-if="msdConnected && systemStore.msd?.imageId === image.id">
|
||||
<Badge variant="default" class="text-xs h-7 px-2">
|
||||
<span class="relative flex h-1.5 w-1.5 mr-1.5">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-1.5 w-1.5 bg-white"></span>
|
||||
</span>
|
||||
{{ t('common.connected') }}
|
||||
</Badge>
|
||||
<div class="flex shrink-0 items-center justify-end gap-1.5">
|
||||
<template v-if="mountedImage(image.id)">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
:disabled="operationInProgress"
|
||||
@click="unmountImageById(image.id)"
|
||||
>
|
||||
<Unlink class="h-3.5 w-3.5 mr-1" />
|
||||
{{ t('msd.disconnect') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
:disabled="operationInProgress"
|
||||
class="h-8 text-xs"
|
||||
:disabled="operationInProgress || mediaSlotsFull"
|
||||
@click="connectImage(image)"
|
||||
>
|
||||
<Link v-if="!connecting" class="h-3.5 w-3.5 mr-1" />
|
||||
@@ -781,8 +860,8 @@ onUnmounted(() => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-destructive hover:text-destructive"
|
||||
:disabled="operationInProgress || (msdConnected && systemStore.msd?.imageId === image.id)"
|
||||
class="h-8 w-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
:disabled="operationInProgress || !!mountedImage(image.id)"
|
||||
@click="confirmDelete('image', image.id, image.name)"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
@@ -794,15 +873,15 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- System Storage Footer -->
|
||||
<div v-if="systemStore.diskSpace" class="shrink-0 pt-2 border-t mt-2">
|
||||
<p class="text-[11px] text-muted-foreground text-center">
|
||||
<div v-if="systemStore.diskSpace" class="mt-2 shrink-0 border-t pt-2">
|
||||
<p class="text-right text-[11px] text-muted-foreground">
|
||||
{{ t('msd.systemAvailable') }}: {{ formatBytes(systemStore.diskSpace.available) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="drive" class="flex-1 min-h-0 m-0 flex flex-col space-y-4">
|
||||
<TabsContent value="drive" class="m-0 flex min-h-0 flex-1 flex-col space-y-4 pt-3">
|
||||
<template v-if="!driveInitialized">
|
||||
<div class="shrink-0 text-center py-8 space-y-4">
|
||||
<HardDrive class="h-10 w-10 mx-auto text-muted-foreground" />
|
||||
@@ -816,8 +895,8 @@ onUnmounted(() => {
|
||||
<template v-else>
|
||||
<!-- Drive Info Card -->
|
||||
<div
|
||||
class="shrink-0 p-3 rounded-lg border space-y-3"
|
||||
:class="msdConnected && msdMode === 'drive'
|
||||
class="shrink-0 space-y-3 rounded-md border p-3"
|
||||
:class="driveConnectedToTarget
|
||||
? 'border-primary bg-primary/5'
|
||||
: driveError
|
||||
? 'border-destructive/40 bg-destructive/5'
|
||||
@@ -854,28 +933,39 @@ onUnmounted(() => {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
class="h-8 text-xs"
|
||||
:disabled="operationInProgress"
|
||||
@click="initializeDrive"
|
||||
>
|
||||
{{ t('msd.reinitializeDrive') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else-if="msdConnected && msdMode === 'drive'">
|
||||
<Badge variant="default" class="text-xs h-7 px-2">
|
||||
<template v-else-if="driveConnectedToTarget">
|
||||
<Badge variant="default" class="h-8 px-2 text-xs">
|
||||
<span class="relative flex h-1.5 w-1.5 mr-1.5">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-1.5 w-1.5 bg-white"></span>
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary-foreground opacity-75"></span>
|
||||
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-primary-foreground"></span>
|
||||
</span>
|
||||
{{ t('common.connected') }}
|
||||
</Badge>
|
||||
<Button
|
||||
v-if="driveMedia"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
:disabled="operationInProgress"
|
||||
@click="unmountMedia(driveMedia)"
|
||||
>
|
||||
<Unlink class="h-3.5 w-3.5 mr-1" />
|
||||
{{ t('msd.disconnect') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
:disabled="operationInProgress"
|
||||
class="h-8 text-xs"
|
||||
:disabled="operationInProgress || mediaSlotsFull || !!driveError"
|
||||
@click="connectDrive"
|
||||
>
|
||||
<Link v-if="!connecting" class="h-3.5 w-3.5 mr-1" />
|
||||
@@ -886,8 +976,8 @@ onUnmounted(() => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-destructive hover:text-destructive"
|
||||
:disabled="operationInProgress || msdConnected"
|
||||
class="h-8 w-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
:disabled="operationInProgress || driveConnectedToTarget"
|
||||
@click="showDeleteDriveDialog = true"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
@@ -918,7 +1008,7 @@ onUnmounted(() => {
|
||||
v-if="currentPath !== '/'"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0"
|
||||
class="h-8 w-8 shrink-0"
|
||||
:disabled="driveConnectedToTarget"
|
||||
@click="navigateUp"
|
||||
>
|
||||
@@ -951,7 +1041,7 @@ onUnmounted(() => {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
as="span"
|
||||
class="h-7 w-7"
|
||||
class="h-8 w-8"
|
||||
:class="driveConnectedToTarget ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'"
|
||||
>
|
||||
<Upload class="h-3.5 w-3.5" />
|
||||
@@ -968,7 +1058,7 @@ onUnmounted(() => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
class="h-8 w-8"
|
||||
:disabled="driveConnectedToTarget"
|
||||
@click="showNewFolderDialog = true"
|
||||
>
|
||||
@@ -982,7 +1072,7 @@ onUnmounted(() => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
class="h-8 w-8"
|
||||
:disabled="driveConnectedToTarget"
|
||||
@click="loadDriveFiles"
|
||||
>
|
||||
@@ -1014,7 +1104,7 @@ onUnmounted(() => {
|
||||
<div
|
||||
v-for="file in driveFiles"
|
||||
:key="file.path"
|
||||
class="flex items-center justify-between p-2 rounded-lg hover:bg-accent/50 transition-colors"
|
||||
class="flex items-center justify-between rounded-md p-2 transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 cursor-pointer flex-1 min-w-0"
|
||||
@@ -1041,7 +1131,7 @@ onUnmounted(() => {
|
||||
v-if="!file.is_dir"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
class="h-8 w-8"
|
||||
as="a"
|
||||
:href="msdApi.downloadDriveFile(file.path)"
|
||||
download
|
||||
@@ -1052,7 +1142,7 @@ onUnmounted(() => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-destructive"
|
||||
class="h-8 w-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
:disabled="driveConnectedToTarget"
|
||||
@click="confirmDelete('file', file.path, file.name)"
|
||||
>
|
||||
@@ -1177,6 +1267,65 @@ onUnmounted(() => {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Image Mount Options Dialog -->
|
||||
<Dialog v-model:open="showMountOptionsDialog">
|
||||
<DialogContent class="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('msd.mountImage') }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ pendingMountImage?.name }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4 py-2">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('msd.storageMode') }}</Label>
|
||||
<ToggleGroup
|
||||
:model-value="mountMode"
|
||||
type="single"
|
||||
size="sm"
|
||||
:class="segmentedGroupClass"
|
||||
@update:model-value="updateMountMode"
|
||||
>
|
||||
<ToggleGroupItem value="flash" :class="segmentedItemClass">{{ t('msd.flash') }}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="cdrom" :class="segmentedItemClass">{{ t('msd.cdrom') }}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('msd.accessMode') }}</Label>
|
||||
<ToggleGroup
|
||||
:model-value="accessMode"
|
||||
type="single"
|
||||
size="sm"
|
||||
:class="segmentedGroupClass"
|
||||
@update:model-value="updateAccessMode"
|
||||
>
|
||||
<ToggleGroupItem value="readonly" :class="segmentedItemClass">{{ t('msd.readOnly') }}</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="readwrite"
|
||||
:class="segmentedItemClass"
|
||||
:disabled="cdromMode"
|
||||
>
|
||||
{{ t('msd.readWrite') }}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showMountOptionsDialog = false" :disabled="connecting">
|
||||
{{ t('common.cancel') }}
|
||||
</Button>
|
||||
<Button @click="confirmImageMount" :disabled="connecting || mediaSlotsFull">
|
||||
<Link v-if="!connecting" class="h-4 w-4 mr-1" />
|
||||
<span v-if="connecting">{{ t('common.connecting') }}...</span>
|
||||
<span v-else>{{ t('msd.connect') }}</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- New Folder Dialog -->
|
||||
<Dialog v-model:open="showNewFolderDialog">
|
||||
<DialogContent>
|
||||
@@ -1223,7 +1372,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- Download Progress -->
|
||||
<div v-if="downloadProgress" class="space-y-2 p-3 rounded-lg bg-muted/50">
|
||||
<div v-if="downloadProgress" class="space-y-2 rounded-md bg-muted/50 p-3">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="truncate">{{ downloadProgress.filename }}</span>
|
||||
<span class="text-muted-foreground shrink-0 ml-2">
|
||||
|
||||
@@ -1,610 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import { msdApi, type MsdImage, type DriveFile } from '@/api'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
HardDrive,
|
||||
Upload,
|
||||
Trash2,
|
||||
Link,
|
||||
Unlink,
|
||||
Disc,
|
||||
File,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
Download,
|
||||
RefreshCw,
|
||||
ChevronRight,
|
||||
ArrowLeft,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const systemStore = useSystemStore()
|
||||
|
||||
const activeTab = ref('images')
|
||||
|
||||
const images = ref<MsdImage[]>([])
|
||||
const loadingImages = ref(false)
|
||||
const uploadProgress = ref(0)
|
||||
const uploading = ref(false)
|
||||
const cdromMode = ref(true)
|
||||
const readOnly = ref(true)
|
||||
|
||||
const driveFiles = ref<DriveFile[]>([])
|
||||
const currentPath = ref('/')
|
||||
const loadingDrive = ref(false)
|
||||
const driveInfo = ref<{ size: number; used: number; free: number; initialized: boolean } | null>(null)
|
||||
const driveInitialized = ref(false)
|
||||
const uploadingFile = ref(false)
|
||||
const fileUploadProgress = ref(0)
|
||||
|
||||
const showDeleteDialog = ref(false)
|
||||
const deleteTarget = ref<{ type: 'image' | 'file'; id: string; name: string } | null>(null)
|
||||
const showNewFolderDialog = ref(false)
|
||||
const newFolderName = ref('')
|
||||
|
||||
const msdConnected = computed(() => systemStore.msd?.connected ?? false)
|
||||
const msdMode = computed(() => systemStore.msd?.mode ?? 'none')
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
const parts = currentPath.value.split('/').filter(Boolean)
|
||||
const crumbs = [{ name: '/', path: '/' }]
|
||||
let path = ''
|
||||
for (const part of parts) {
|
||||
path += '/' + part
|
||||
crumbs.push({ name: part, path })
|
||||
}
|
||||
return crumbs
|
||||
})
|
||||
|
||||
watch(() => props.open, async (isOpen) => {
|
||||
if (isOpen) {
|
||||
await loadData()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
await systemStore.fetchMsdState()
|
||||
await loadImages()
|
||||
await loadDriveInfo()
|
||||
if (driveInitialized.value) {
|
||||
await loadDriveFiles()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadImages() {
|
||||
loadingImages.value = true
|
||||
try {
|
||||
images.value = await msdApi.listImages()
|
||||
} catch (e) {
|
||||
console.error('Failed to load images:', e)
|
||||
} finally {
|
||||
loadingImages.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImageUpload(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
uploading.value = true
|
||||
uploadProgress.value = 0
|
||||
|
||||
try {
|
||||
const image = await msdApi.uploadImage(file, (progress) => {
|
||||
uploadProgress.value = progress
|
||||
})
|
||||
images.value.push(image)
|
||||
} catch (e) {
|
||||
console.error('Failed to upload image:', e)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
uploadProgress.value = 0
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function connectImage(image: MsdImage) {
|
||||
try {
|
||||
await msdApi.connect('image', image.id, cdromMode.value, readOnly.value)
|
||||
await systemStore.fetchMsdState()
|
||||
} catch (e) {
|
||||
console.error('Failed to connect image:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
try {
|
||||
await msdApi.disconnect()
|
||||
await systemStore.fetchMsdState()
|
||||
} catch (e) {
|
||||
console.error('Failed to disconnect:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(type: 'image' | 'file', id: string, name: string) {
|
||||
deleteTarget.value = { type, id, name }
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
async function executeDelete() {
|
||||
if (!deleteTarget.value) return
|
||||
|
||||
try {
|
||||
if (deleteTarget.value.type === 'image') {
|
||||
await msdApi.deleteImage(deleteTarget.value.id)
|
||||
images.value = images.value.filter(i => i.id !== deleteTarget.value!.id)
|
||||
} else {
|
||||
await msdApi.deleteDriveFile(deleteTarget.value.id)
|
||||
await loadDriveFiles()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to delete:', e)
|
||||
} finally {
|
||||
showDeleteDialog.value = false
|
||||
deleteTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDriveInfo() {
|
||||
try {
|
||||
driveInfo.value = await msdApi.driveInfo()
|
||||
driveInitialized.value = true
|
||||
} catch {
|
||||
driveInitialized.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeDrive() {
|
||||
try {
|
||||
await msdApi.initDrive(256)
|
||||
await loadDriveInfo()
|
||||
await loadDriveFiles()
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize drive:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDriveFiles() {
|
||||
loadingDrive.value = true
|
||||
try {
|
||||
driveFiles.value = await msdApi.listDriveFiles(currentPath.value)
|
||||
} catch (e) {
|
||||
console.error('Failed to load drive files:', e)
|
||||
} finally {
|
||||
loadingDrive.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function navigateTo(path: string) {
|
||||
currentPath.value = path
|
||||
loadDriveFiles()
|
||||
}
|
||||
|
||||
function navigateUp() {
|
||||
const parts = currentPath.value.split('/').filter(Boolean)
|
||||
parts.pop()
|
||||
currentPath.value = '/' + parts.join('/')
|
||||
loadDriveFiles()
|
||||
}
|
||||
|
||||
async function handleFileUpload(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
uploadingFile.value = true
|
||||
fileUploadProgress.value = 0
|
||||
|
||||
try {
|
||||
await msdApi.uploadDriveFile(file, currentPath.value, (progress) => {
|
||||
fileUploadProgress.value = progress
|
||||
})
|
||||
await loadDriveFiles()
|
||||
} catch (e) {
|
||||
console.error('Failed to upload file:', e)
|
||||
} finally {
|
||||
uploadingFile.value = false
|
||||
fileUploadProgress.value = 0
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
if (!newFolderName.value.trim()) return
|
||||
|
||||
try {
|
||||
const path = currentPath.value === '/'
|
||||
? '/' + newFolderName.value
|
||||
: currentPath.value + '/' + newFolderName.value
|
||||
await msdApi.createDirectory(path)
|
||||
await loadDriveFiles()
|
||||
} catch (e) {
|
||||
console.error('Failed to create folder:', e)
|
||||
} finally {
|
||||
showNewFolderDialog.value = false
|
||||
newFolderName.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function connectDrive() {
|
||||
try {
|
||||
await msdApi.connect('drive')
|
||||
await systemStore.fetchMsdState()
|
||||
} catch (e) {
|
||||
console.error('Failed to connect drive:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB'
|
||||
return (bytes / 1024 / 1024 / 1024).toFixed(1) + ' GB'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.open) {
|
||||
await loadData()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sheet :open="open" @update:open="emit('update:open', $event)">
|
||||
<SheetContent side="right" class="w-full sm:max-w-lg overflow-hidden flex flex-col h-[dvh]">
|
||||
<SheetHeader class="shrink-0">
|
||||
<div class="flex items-center justify-between pr-8">
|
||||
<div>
|
||||
<SheetTitle class="flex items-center gap-2">
|
||||
<HardDrive class="h-5 w-5" />
|
||||
{{ t('msd.title') }}
|
||||
</SheetTitle>
|
||||
<SheetDescription class="flex items-center gap-2 mt-1">
|
||||
{{ msdConnected ? t('common.connected') : t('common.disconnected') }}
|
||||
<Badge v-if="msdConnected" variant="secondary" class="text-xs">{{ msdMode }}</Badge>
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<Button v-if="msdConnected" variant="destructive" size="sm" @click="disconnect">
|
||||
<Unlink class="h-4 w-4 mr-1" />
|
||||
{{ t('msd.disconnect') }}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<Separator class="my-4 shrink-0" />
|
||||
|
||||
<Tabs v-model="activeTab" class="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<TabsList class="w-full grid grid-cols-2 shrink-0">
|
||||
<TabsTrigger value="images">
|
||||
<Disc class="h-4 w-4 mr-1.5" />
|
||||
{{ t('msd.images') }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="drive">
|
||||
<HardDrive class="h-4 w-4 mr-1.5" />
|
||||
{{ t('msd.drive') }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div class="flex-1 min-h-0 mt-4 flex flex-col">
|
||||
<!-- Images Tab -->
|
||||
<TabsContent value="images" class="flex-1 min-h-0 m-0 flex flex-col space-y-4">
|
||||
<!-- Upload Area -->
|
||||
<div class="shrink-0 space-y-3">
|
||||
<label class="block">
|
||||
<input
|
||||
type="file"
|
||||
accept=".iso,.img"
|
||||
class="hidden"
|
||||
:disabled="uploading"
|
||||
@change="handleImageUpload"
|
||||
/>
|
||||
<div class="flex items-center justify-center h-16 border-2 border-dashed rounded-lg cursor-pointer hover:border-primary transition-colors">
|
||||
<div class="text-center">
|
||||
<Upload class="h-5 w-5 mx-auto text-muted-foreground" />
|
||||
<p class="text-xs text-muted-foreground mt-1">{{ t('msd.uploadImage') }} (ISO/IMG)</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<Progress v-if="uploading" :model-value="uploadProgress" class="h-1" />
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="shrink-0 flex items-center gap-4 p-3 rounded-lg bg-muted/50">
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="cdrom" v-model:checked="cdromMode" />
|
||||
<Label for="cdrom" class="text-xs">{{ t('msd.cdromMode') }}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="readonly" v-model:checked="readOnly" />
|
||||
<Label for="readonly" class="text-xs">{{ t('msd.readOnly') }}</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image List -->
|
||||
<div class="flex-1 min-h-0 flex flex-col space-y-2">
|
||||
<div class="shrink-0 flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium">{{ t('msd.imageList') }}</h4>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="loadImages">
|
||||
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingImages }" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="images.length === 0" class="shrink-0 text-center py-6 text-muted-foreground text-sm">
|
||||
{{ t('msd.noImages') }}
|
||||
</div>
|
||||
|
||||
<ScrollArea v-else class="flex-1 min-h-0 pr-4">
|
||||
<div class="space-y-1.5">
|
||||
<div
|
||||
v-for="image in images"
|
||||
:key="image.id"
|
||||
class="flex items-center justify-between p-2.5 rounded-lg border hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<Disc class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium truncate">{{ image.name }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ formatBytes(image.size) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
v-if="!msdConnected || systemStore.msd?.imageId !== image.id"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="connectImage(image)"
|
||||
>
|
||||
<Link class="h-3.5 w-3.5 mr-1" />
|
||||
{{ t('msd.connect') }}
|
||||
</Button>
|
||||
<Badge v-else variant="default" class="text-xs">{{ t('common.connected') }}</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-destructive"
|
||||
@click="confirmDelete('image', image.id, image.name)"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Drive Tab -->
|
||||
<TabsContent value="drive" class="flex-1 min-h-0 m-0 flex flex-col space-y-4">
|
||||
<template v-if="!driveInitialized">
|
||||
<div class="shrink-0 text-center py-8 space-y-4">
|
||||
<HardDrive class="h-10 w-10 mx-auto text-muted-foreground" />
|
||||
<p class="text-sm text-muted-foreground">{{ t('msd.driveNotInitialized') }}</p>
|
||||
<Button size="sm" @click="initializeDrive">
|
||||
{{ t('msd.initializeDrive') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- Drive Info -->
|
||||
<div class="shrink-0 p-3 rounded-lg bg-muted/50 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<p class="text-xs text-muted-foreground">{{ t('msd.driveSize') }}: {{ (driveInfo?.size || 0) / 1024 / 1024 }}MB</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ formatBytes(driveInfo?.used || 0) }} / {{ formatBytes(driveInfo?.size || 0) }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
v-if="!msdConnected || msdMode !== 'drive'"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="connectDrive"
|
||||
>
|
||||
<Link class="h-3.5 w-3.5 mr-1" />
|
||||
{{ t('msd.connect') }}
|
||||
</Button>
|
||||
<Badge v-else class="text-xs">{{ t('common.connected') }}</Badge>
|
||||
</div>
|
||||
<Progress
|
||||
v-if="driveInfo"
|
||||
:model-value="driveInfo.size > 0 ? (driveInfo.used / driveInfo.size) * 100 : 0"
|
||||
class="h-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- File Browser -->
|
||||
<div class="flex-1 min-h-0 flex flex-col space-y-2">
|
||||
<!-- Toolbar -->
|
||||
<div class="shrink-0 flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<Button
|
||||
v-if="currentPath !== '/'"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0"
|
||||
@click="navigateUp"
|
||||
>
|
||||
<ArrowLeft class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<nav class="flex items-center text-xs min-w-0 overflow-hidden">
|
||||
<template v-for="(crumb, index) in breadcrumbs" :key="crumb.path">
|
||||
<ChevronRight v-if="index > 0" class="h-3 w-3 text-muted-foreground mx-0.5 shrink-0" />
|
||||
<button
|
||||
class="hover:text-primary transition-colors truncate"
|
||||
:class="index === breadcrumbs.length - 1 ? 'font-medium' : 'text-muted-foreground'"
|
||||
@click="navigateTo(crumb.path)"
|
||||
>
|
||||
{{ crumb.name }}
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="shrink-0 flex items-center gap-1 shrink-0">
|
||||
<label>
|
||||
<input type="file" class="hidden" :disabled="uploadingFile" @change="handleFileUpload" />
|
||||
<Button variant="ghost" size="icon" as="span" class="h-7 w-7 cursor-pointer">
|
||||
<Upload class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</label>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="showNewFolderDialog = true">
|
||||
<FolderPlus class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="loadDriveFiles">
|
||||
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingDrive }" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Progress v-if="uploadingFile" :model-value="fileUploadProgress" class="h-1 shrink-0" />
|
||||
|
||||
<!-- File List -->
|
||||
<div v-if="driveFiles.length === 0" class="shrink-0 text-center py-6 text-muted-foreground text-sm">
|
||||
{{ t('msd.emptyFolder') }}
|
||||
</div>
|
||||
|
||||
<ScrollArea v-else class="flex-1 min-h-0 pr-4">
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="file in driveFiles"
|
||||
:key="file.path"
|
||||
class="flex items-center justify-between p-2 rounded-lg hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 cursor-pointer flex-1 min-w-0"
|
||||
@click="file.is_dir && navigateTo(file.path)"
|
||||
>
|
||||
<Folder v-if="file.is_dir" class="h-4 w-4 text-blue-500 shrink-0" />
|
||||
<File v-else class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium truncate">{{ file.name }}</p>
|
||||
<p v-if="!file.is_dir" class="text-xs text-muted-foreground">
|
||||
{{ formatBytes(file.size) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-0.5 shrink-0">
|
||||
<Button
|
||||
v-if="!file.is_dir"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
as="a"
|
||||
:href="msdApi.downloadDriveFile(file.path)"
|
||||
download
|
||||
>
|
||||
<Download class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-destructive"
|
||||
@click="confirmDelete('file', file.path, file.name)"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</template>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<!-- Delete Dialog -->
|
||||
<Dialog v-model:open="showDeleteDialog">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('common.confirm') }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ t('msd.confirmDelete', { name: deleteTarget?.name }) }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showDeleteDialog = false">{{ t('common.cancel') }}</Button>
|
||||
<Button variant="destructive" @click="executeDelete">{{ t('common.delete') }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- New Folder Dialog -->
|
||||
<Dialog v-model:open="showNewFolderDialog">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('msd.createFolder') }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input v-model="newFolderName" :placeholder="t('msd.folderName')" @keyup.enter="createFolder" />
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showNewFolderDialog = false">{{ t('common.cancel') }}</Button>
|
||||
<Button @click="createFolder">{{ t('common.confirm') }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--muted-foreground) / 0.3);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
|
||||
/* For Firefox */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -427,15 +427,12 @@ export default {
|
||||
msd: {
|
||||
title: 'Virtual Media',
|
||||
images: 'ISO/IMG Mount',
|
||||
imagesDesc: 'Mount ISO/IMG images to target device',
|
||||
drive: 'Virtual USB',
|
||||
driveDesc: 'Transfer files to target device like a USB drive, supports Ventoy boot',
|
||||
imageList: 'Image List',
|
||||
uploadImage: 'Upload Image',
|
||||
noImages: 'No images',
|
||||
connect: 'Connect',
|
||||
disconnect: 'Disconnect',
|
||||
connectedTo: 'Connected to',
|
||||
cdrom: 'CD-ROM',
|
||||
flash: 'Flash',
|
||||
storageMode: 'Storage Mode',
|
||||
@@ -452,14 +449,10 @@ export default {
|
||||
freeSpace: 'Free',
|
||||
deleteDrive: 'Delete Drive',
|
||||
confirmDeleteDrive: 'Are you sure you want to delete the virtual drive? All files will be erased.',
|
||||
driveDeleted: 'Drive deleted',
|
||||
systemAvailable: 'System Available',
|
||||
emptyFolder: 'Empty folder',
|
||||
confirmDelete: 'Are you sure you want to delete "{name}"?',
|
||||
folderName: 'Folder name',
|
||||
uploadImageHint: 'Click to upload ISO/IMG',
|
||||
imageMounted: 'Image {name} mounted',
|
||||
imageUnmounted: 'Image unmounted',
|
||||
downloadFromUrl: 'Download from URL',
|
||||
downloadFromUrlDesc: 'Enter the URL of an image file (ISO/IMG supported)',
|
||||
url: 'URL',
|
||||
@@ -467,31 +460,28 @@ export default {
|
||||
filenameAutoDetect: 'Auto-detect from URL',
|
||||
download: 'Download',
|
||||
downloadComplete: 'Download complete',
|
||||
downloadFailed: 'Download failed',
|
||||
largeFileWarning: '>2.2GB',
|
||||
largeFileTooltip: 'File is larger than 2.2GB, please use Flash mode to mount',
|
||||
error: 'MSD Error',
|
||||
errorDesc: '{reason}',
|
||||
recovered: 'MSD Recovered',
|
||||
recoveredDesc: 'MSD operation completed successfully',
|
||||
operationInProgress: 'Operation in progress, please wait',
|
||||
driveConnected: 'Virtual USB drive connected',
|
||||
imageConnected: 'Image {name} connected',
|
||||
selectDriveSize: 'Select virtual drive size',
|
||||
customSize: 'Custom size',
|
||||
driveSpaceUnknown: 'Unable to read available space for the MSD directory filesystem',
|
||||
driveSpaceUnavailable: 'Not enough space on the MSD directory filesystem to create a virtual drive',
|
||||
driveSpaceTooSmall: 'Available space is less than {min} MB; cannot create a virtual drive',
|
||||
driveCreateFailed: 'Failed to create virtual drive. Check available space for the MSD directory',
|
||||
driveCreated: 'Virtual drive created ({size} MB)',
|
||||
fileDeleted: 'File deleted',
|
||||
imageDeleted: 'Image deleted',
|
||||
driveConnectedBlocked: 'Disconnect the drive before editing files',
|
||||
driveConnectedFilesHidden: 'Drive is connected to target machine — file list unavailable',
|
||||
uploadFailed: 'File upload failed',
|
||||
driveUnreadable: 'Format unsupported',
|
||||
driveUnreadableTooltip: 'Unable to parse the exFAT filesystem. It may have been formatted with an unsupported format.',
|
||||
reinitializeDrive: 'Re-initialize',
|
||||
diskMode: 'Drive Mode',
|
||||
singleDiskMode: 'Single',
|
||||
multiDiskMode: 'Multi',
|
||||
diskModeHint: 'Single drive provides 1 media slot; multi drive provides 8. Switching modes clears mounted media and re-enumerates USB.',
|
||||
mountImage: 'Mount Image',
|
||||
mediaCount: 'Media {count}/{capacity}',
|
||||
mediaSlotsFull: 'Media slots are full; no more media can be mounted',
|
||||
reenumerating: 'USB is re-enumerating',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
@@ -949,6 +939,7 @@ export default {
|
||||
msdStandby: 'Idle',
|
||||
msdImageMode: 'Image Mode',
|
||||
msdDriveMode: 'Virtual USB',
|
||||
msdMultiMode: 'Virtual Media: {count} mounted',
|
||||
msdMountType: 'Mount Type',
|
||||
msdCurrentImage: 'Current Image',
|
||||
msdNoImage: 'None',
|
||||
|
||||
@@ -426,15 +426,12 @@ export default {
|
||||
msd: {
|
||||
title: '虚拟媒体',
|
||||
images: 'ISO/IMG 挂载',
|
||||
imagesDesc: '将 ISO/IMG 镜像挂载到目标设备',
|
||||
drive: '虚拟U盘',
|
||||
driveDesc: '像U盘一样向目标设备传输文件,支持 Ventoy 引导',
|
||||
imageList: '镜像列表',
|
||||
uploadImage: '上传镜像',
|
||||
noImages: '暂无镜像',
|
||||
connect: '连接',
|
||||
disconnect: '断开',
|
||||
connectedTo: '已连接至',
|
||||
cdrom: 'CD-ROM',
|
||||
flash: 'Flash',
|
||||
storageMode: '存储模式',
|
||||
@@ -451,14 +448,10 @@ export default {
|
||||
freeSpace: '剩余',
|
||||
deleteDrive: '删除驱动器',
|
||||
confirmDeleteDrive: '确定要删除虚拟驱动器吗?所有文件将被清除。',
|
||||
driveDeleted: '驱动器已删除',
|
||||
systemAvailable: '系统可用',
|
||||
emptyFolder: '空文件夹',
|
||||
confirmDelete: '确定要删除 "{name}" 吗?',
|
||||
folderName: '文件夹名称',
|
||||
uploadImageHint: '点击上传 ISO/IMG 镜像',
|
||||
imageMounted: '镜像 {name} 已挂载',
|
||||
imageUnmounted: '镜像已卸载',
|
||||
downloadFromUrl: '从 URL 下载',
|
||||
downloadFromUrlDesc: '输入镜像文件的 URL 地址,支持 ISO/IMG 格式',
|
||||
url: 'URL 地址',
|
||||
@@ -466,31 +459,28 @@ export default {
|
||||
filenameAutoDetect: '自动从 URL 获取',
|
||||
download: '下载',
|
||||
downloadComplete: '下载完成',
|
||||
downloadFailed: '下载失败',
|
||||
largeFileWarning: '>2.2GB',
|
||||
largeFileTooltip: '文件大于 2.2GB,请使用 Flash 模式挂载',
|
||||
error: 'MSD 错误',
|
||||
errorDesc: '{reason}',
|
||||
recovered: 'MSD 已恢复',
|
||||
recoveredDesc: 'MSD 设备已恢复正常',
|
||||
operationInProgress: '操作进行中,请稍候',
|
||||
driveConnected: '虚拟U盘已连接',
|
||||
imageConnected: '镜像 {name} 已连接',
|
||||
selectDriveSize: '选择虚拟驱动器大小',
|
||||
customSize: '自定义大小',
|
||||
driveSpaceUnknown: '无法读取 MSD 目录所在储存空间的剩余可用空间',
|
||||
driveSpaceUnavailable: 'MSD 目录所在储存空间不足,无法创建虚拟U盘',
|
||||
driveSpaceTooSmall: '剩余可用空间不足 {min} MB,无法创建虚拟U盘',
|
||||
driveCreateFailed: '虚拟U盘创建失败,请检查 MSD 目录剩余空间',
|
||||
driveCreated: '虚拟驱动器已创建 ({size} MB)',
|
||||
fileDeleted: '文件已删除',
|
||||
imageDeleted: '镜像已删除',
|
||||
driveConnectedBlocked: '请先断开连接,再操作文件',
|
||||
driveConnectedFilesHidden: '已连接到被控机,文件列表暂不可用',
|
||||
uploadFailed: '文件上传失败',
|
||||
driveUnreadable: '格式不支持',
|
||||
driveUnreadableTooltip: '无法解析 exFAT 文件系统,可能已被格式化为不支持的格式。',
|
||||
reinitializeDrive: '重新初始化',
|
||||
diskMode: '驱动器模式',
|
||||
singleDiskMode: '单驱动器',
|
||||
multiDiskMode: '多驱动器',
|
||||
diskModeHint: '单驱动器提供 1 个介质槽位,多驱动器提供 8 个;切换模式会清空已挂载介质并重新枚举 USB。',
|
||||
mountImage: '挂载镜像',
|
||||
mediaCount: '介质 {count}/{capacity}',
|
||||
mediaSlotsFull: '介质槽已满,无法挂载更多介质',
|
||||
reenumerating: 'USB 正在重新枚举',
|
||||
},
|
||||
settings: {
|
||||
title: '系统设置',
|
||||
@@ -948,6 +938,7 @@ export default {
|
||||
msdStandby: '空闲',
|
||||
msdImageMode: '镜像模式',
|
||||
msdDriveMode: '虚拟U盘',
|
||||
msdMultiMode: '多媒体:{count} 个已挂载',
|
||||
msdMountType: '挂载类型',
|
||||
msdCurrentImage: '当前镜像',
|
||||
msdNoImage: '无',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { systemApi, streamApi, hidApi, atxApi, msdApi, type DeviceInfo, type PlatformCapabilities } from '@/api'
|
||||
import { systemApi, streamApi, hidApi, atxApi, msdApi, type DeviceInfo, type DiskMode, type MountedMedia, type PlatformCapabilities } from '@/api'
|
||||
|
||||
interface SystemCapabilities {
|
||||
video: { available: boolean; backend?: string; reason?: string }
|
||||
@@ -56,9 +56,11 @@ interface AtxState {
|
||||
|
||||
interface MsdState {
|
||||
available: boolean
|
||||
connected: boolean
|
||||
mode: 'none' | 'image' | 'drive'
|
||||
imageId: string | null
|
||||
diskMode: DiskMode
|
||||
slotCapacity: number
|
||||
mountedCount: number
|
||||
mountedMedia: MountedMedia[]
|
||||
usbReenumerating: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
@@ -111,9 +113,18 @@ export interface HidDeviceInfo {
|
||||
|
||||
export interface MsdDeviceInfo {
|
||||
available: boolean
|
||||
mode: string
|
||||
connected: boolean
|
||||
image_id: string | null
|
||||
disk_mode: DiskMode
|
||||
slot_capacity: number
|
||||
mounted_count: number
|
||||
mounted_media: Array<{
|
||||
id: string
|
||||
kind: 'drive' | 'image'
|
||||
name: string
|
||||
cdrom: boolean
|
||||
read_only: boolean
|
||||
size: number
|
||||
}>
|
||||
usb_reenumerating: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
@@ -250,9 +261,11 @@ export const useSystemStore = defineStore('system', () => {
|
||||
const result = await msdApi.status()
|
||||
msd.value = {
|
||||
available: result.available,
|
||||
connected: result.state.connected,
|
||||
mode: result.state.mode,
|
||||
imageId: result.state.current_image?.id ?? null,
|
||||
diskMode: result.state.disk_mode,
|
||||
slotCapacity: result.state.slot_capacity,
|
||||
mountedCount: result.state.mounted_count,
|
||||
mountedMedia: result.state.mounted_media,
|
||||
usbReenumerating: result.state.usb_reenumerating,
|
||||
error: null,
|
||||
}
|
||||
return result
|
||||
@@ -361,9 +374,11 @@ export const useSystemStore = defineStore('system', () => {
|
||||
if (data.msd) {
|
||||
msd.value = {
|
||||
available: data.msd.available,
|
||||
connected: data.msd.connected,
|
||||
mode: data.msd.mode as 'none' | 'image' | 'drive',
|
||||
imageId: data.msd.image_id,
|
||||
diskMode: data.msd.disk_mode,
|
||||
slotCapacity: data.msd.slot_capacity,
|
||||
mountedCount: data.msd.mounted_count,
|
||||
mountedMedia: data.msd.mounted_media,
|
||||
usbReenumerating: data.msd.usb_reenumerating,
|
||||
error: data.msd.error,
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -481,17 +481,15 @@ const msdStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'
|
||||
const msd = systemStore.msd
|
||||
if (!msd?.available) return 'disconnected'
|
||||
if (msd.error) return 'error'
|
||||
if (msd.connected) return 'connected'
|
||||
if (msd.mountedCount > 0) return 'connected'
|
||||
return 'disconnected'
|
||||
})
|
||||
|
||||
const msdQuickInfo = computed(() => {
|
||||
const msd = systemStore.msd
|
||||
if (!msd?.available) return ''
|
||||
if (msd.mode === 'none') return t('statusCard.msdStandby')
|
||||
if (msd.mode === 'image') return t('statusCard.msdImageMode')
|
||||
if (msd.mode === 'drive') return t('statusCard.msdDriveMode')
|
||||
return t('common.unknown')
|
||||
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 })}`
|
||||
})
|
||||
|
||||
const msdErrorMessage = computed(() => {
|
||||
@@ -504,7 +502,7 @@ const msdDetails = computed<StatusDetail[]>(() => {
|
||||
|
||||
const details: StatusDetail[] = []
|
||||
|
||||
if (msd.mode === 'none') {
|
||||
if (msd.mountedCount === 0) {
|
||||
details.push({
|
||||
label: t('statusCard.msdStatus'),
|
||||
value: t('statusCard.msdStandby'),
|
||||
@@ -518,22 +516,22 @@ const msdDetails = computed<StatusDetail[]>(() => {
|
||||
})
|
||||
}
|
||||
|
||||
const modeDisplay = msd.mode === 'none'
|
||||
? '-'
|
||||
: msd.mode === 'image'
|
||||
? t('statusCard.msdImageMode')
|
||||
: t('statusCard.msdDriveMode')
|
||||
details.push({
|
||||
label: t('statusCard.currentMode'),
|
||||
value: modeDisplay,
|
||||
status: msd.mode !== 'none' ? 'ok' : undefined
|
||||
value: msd.diskMode === 'single' ? t('msd.singleDiskMode') : t('msd.multiDiskMode'),
|
||||
status: msd.mountedCount > 0 ? 'ok' : undefined
|
||||
})
|
||||
|
||||
if (msd.mode === 'image') {
|
||||
details.push({
|
||||
label: t('statusCard.msdCurrentImage'),
|
||||
value: msd.imageId || t('statusCard.msdNoImage')
|
||||
})
|
||||
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