733 lines
18 KiB
Vue
733 lines
18 KiB
Vue
<script setup lang="ts">
|
||
import { HelpCircleIcon, RefreshCwIcon, SpinnerIcon } from '@modrinth/assets'
|
||
import { useFormatBytes, useVIntl } from '@modrinth/ui'
|
||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
|
||
import { list as fetchInstances } from '@/helpers/instance'
|
||
import {
|
||
listenStorageScan,
|
||
openStoragePaths,
|
||
startStorageScan,
|
||
type StorageScanEvent,
|
||
} from '@/helpers/storage'
|
||
|
||
import type { StorageNode, StorageNodeType, StorageSize, StorageTree } from './storage/storageData'
|
||
import { sortStorageChildren } from './storage/storageData'
|
||
import { storageMessages } from './storage/storageMessages'
|
||
import StorageTreeNode from './storage/StorageTreeNode.vue'
|
||
|
||
type MessageDescriptor = (typeof storageMessages)['total']
|
||
|
||
const { formatMessage } = useVIntl()
|
||
const formatBytes = useFormatBytes()
|
||
const router = useRouter()
|
||
|
||
const tree = ref<StorageTree | null>(null)
|
||
const loading = ref(true)
|
||
const lastUpdated = ref<Date | null>(null)
|
||
let listenerUnsubscribe: (() => void) | null = null
|
||
|
||
const categories = computed(() => tree.value?.categories ?? [])
|
||
|
||
const mainCategories = computed<StorageNode[]>(() => {
|
||
const list = categories.value.filter(
|
||
(category) => category.type !== 'other' && category.size.actual + category.size.symlink > 0,
|
||
)
|
||
const rootOther = tree.value?.rootOther
|
||
if (rootOther && rootOther.size.actual + rootOther.size.symlink > 0) {
|
||
list.push(rootOther)
|
||
}
|
||
return list
|
||
})
|
||
|
||
const instancesCategory = computed(
|
||
() => categories.value.find((category) => category.type === 'instances') ?? null,
|
||
)
|
||
|
||
const categoryLabels: Record<StorageNodeType, MessageDescriptor> = {
|
||
instances: storageMessages.instanceData,
|
||
cache: storageMessages.cacheData,
|
||
meta: storageMessages.metaData,
|
||
database: storageMessages.database,
|
||
other: storageMessages.other,
|
||
}
|
||
|
||
// 醒目高对比度色彩配置
|
||
const categoryColors: Record<StorageNodeType, { actual: string; symlink: string }> = {
|
||
instances: { actual: '#10b981', symlink: '#06b6d4' }, // 翡翠绿 / 亮青
|
||
cache: { actual: '#f59e0b', symlink: '#eab308' }, // 琥珀黄 / 明黄
|
||
meta: { actual: '#8b5cf6', symlink: '#ec4899' }, // 靛紫 / 靓粉
|
||
database: { actual: '#3b82f6', symlink: '#6366f1' }, // 靛蓝 / 靛青
|
||
other: { actual: '#6b7280', symlink: '#9ca3af' }, // 中灰 / 浅灰
|
||
}
|
||
|
||
const hoveredId = ref<string | null>(null)
|
||
|
||
const symlinkHelpTooltipOptions = computed(() => ({
|
||
content: formatMessage(storageMessages.symlinkHelpTooltip),
|
||
popperClass: 'storage-tooltip',
|
||
}))
|
||
|
||
function sizeTotal(size: StorageSize) {
|
||
return size.actual + size.symlink
|
||
}
|
||
|
||
function formatSize(size: StorageSize) {
|
||
const actual = formatBytes(size.actual)
|
||
if (size.symlink <= 0) return actual
|
||
return `${actual} + ${formatBytes(size.symlink)}`
|
||
}
|
||
|
||
function emptyTree(): StorageTree {
|
||
return {
|
||
total: { actual: 0, symlink: 0 },
|
||
categories: [],
|
||
rootOther: null,
|
||
}
|
||
}
|
||
|
||
function applyStorageEvent(event: StorageScanEvent) {
|
||
switch (event.kind) {
|
||
case 'started':
|
||
loading.value = true
|
||
break
|
||
case 'category': {
|
||
const category = event.payload.category
|
||
if (!tree.value) tree.value = emptyTree()
|
||
if (category.type === 'other') {
|
||
tree.value!.rootOther = category
|
||
} else {
|
||
const index = tree.value!.categories.findIndex((child) => child.id === category.id)
|
||
if (index >= 0) tree.value!.categories[index] = category
|
||
else tree.value!.categories.push(category)
|
||
}
|
||
break
|
||
}
|
||
case 'complete': {
|
||
tree.value = event.payload.tree
|
||
lastUpdated.value = tree.value.scannedAt ? new Date(tree.value.scannedAt) : new Date()
|
||
loading.value = false
|
||
break
|
||
}
|
||
case 'error': {
|
||
loading.value = false
|
||
console.warn('[storage] Scan failed:', event.payload.message)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
async function loadStorage(force: boolean) {
|
||
loading.value = true
|
||
try {
|
||
await startStorageScan(force)
|
||
} catch (error) {
|
||
loading.value = false
|
||
console.warn('[storage] Failed to start storage scan', error)
|
||
}
|
||
}
|
||
|
||
async function updateStorage() {
|
||
await loadStorage(true)
|
||
}
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
listenerUnsubscribe = await listenStorageScan(applyStorageEvent)
|
||
} catch (error) {
|
||
console.warn('[storage] Failed to subscribe to storage scan events', error)
|
||
}
|
||
await loadStorage(false)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
listenerUnsubscribe?.()
|
||
})
|
||
|
||
function findInstanceParent(target: StorageNode): StorageNode | null {
|
||
const roots: StorageNode[] = [...(tree.value?.categories ?? [])]
|
||
if (tree.value?.rootOther) roots.push(tree.value.rootOther)
|
||
|
||
const stack: { node: StorageNode; parentInstance: StorageNode | null }[] = roots.map((node) => ({
|
||
node,
|
||
parentInstance: null,
|
||
}))
|
||
|
||
while (stack.length > 0) {
|
||
const { node, parentInstance } = stack.pop()!
|
||
if (node === target) return node.type === 'instance' ? node : parentInstance
|
||
|
||
const nextInstance = node.type === 'instance' ? node : parentInstance
|
||
for (const child of node.children ?? []) {
|
||
stack.push({ node: child, parentInstance: nextInstance })
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
async function resolveInstanceIdByName(instanceNode: StorageNode): Promise<string | null> {
|
||
const name = instanceNode.name?.trim() ?? ''
|
||
if (!name) return null
|
||
|
||
try {
|
||
const instances = await fetchInstances()
|
||
const normalizedName = name.toLowerCase()
|
||
return (
|
||
instances.find(
|
||
(instance) =>
|
||
instance.path === name || instance.name.trim().toLowerCase() === normalizedName,
|
||
)?.id ?? null
|
||
)
|
||
} catch (error) {
|
||
console.warn('[storage] Failed to resolve instance id, falling back to filesystem', error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
async function resolveInstanceId(node: StorageNode): Promise<string | null> {
|
||
const instanceNode = findInstanceParent(node)
|
||
const directId = node.instance_id ?? instanceNode?.instance_id ?? null
|
||
if (directId) return directId
|
||
return instanceNode ? resolveInstanceIdByName(instanceNode) : null
|
||
}
|
||
|
||
function launcherRouteFor(node: StorageNode, instanceId: string): string | null {
|
||
const encodedId = encodeURIComponent(instanceId)
|
||
switch (node.type) {
|
||
case 'instance':
|
||
case 'mods':
|
||
return `/instance/${encodedId}`
|
||
case 'saves':
|
||
case 'world':
|
||
return `/instance/${encodedId}/worlds`
|
||
case 'screenshots':
|
||
return `/instance/${encodedId}/screenshots`
|
||
default:
|
||
return null
|
||
}
|
||
}
|
||
|
||
async function tryNavigate(path: string): Promise<boolean> {
|
||
try {
|
||
await router.push(path)
|
||
return true
|
||
} catch (error) {
|
||
console.warn('[storage] Launcher navigation failed, falling back to filesystem', error)
|
||
return false
|
||
}
|
||
}
|
||
|
||
async function openNodePaths(node: StorageNode) {
|
||
if (node.paths.length === 0) return
|
||
try {
|
||
const result = await openStoragePaths(node.paths)
|
||
for (const failure of result.failed) {
|
||
console.warn(`[storage] Failed to open path: ${failure.path}`, failure.reason)
|
||
}
|
||
} catch (error) {
|
||
console.warn('[storage] Failed to open storage paths', error)
|
||
}
|
||
}
|
||
|
||
async function handleAction(node: StorageNode) {
|
||
const instanceId = await resolveInstanceId(node)
|
||
const route = instanceId ? launcherRouteFor(node, instanceId) : null
|
||
|
||
if (route && (await tryNavigate(route))) return
|
||
|
||
await openNodePaths(node)
|
||
}
|
||
|
||
interface ChartItem {
|
||
id: string
|
||
category: StorageNode
|
||
label: string
|
||
sizeBytes: number
|
||
formattedSize: string
|
||
color: string
|
||
isSymlink: boolean
|
||
startAngle: number
|
||
endAngle: number
|
||
pathData: string
|
||
percentText: string
|
||
}
|
||
|
||
// 极其精准的 SVG Path 圆弧/环形生成算法(彻底解决 SVG circle dashoffset 错位乱套问题)
|
||
function getRingPath(
|
||
cx: number,
|
||
cy: number,
|
||
rInner: number,
|
||
rOuter: number,
|
||
startAngle: number,
|
||
endAngle: number,
|
||
) {
|
||
// 防止 100% 比例下起点终点重合导致无法绘制
|
||
const angleDiff = endAngle - startAngle
|
||
const safeEndAngle = angleDiff >= 360 ? startAngle + 359.999 : endAngle
|
||
|
||
const rad = (deg: number) => (deg - 90) * (Math.PI / 180)
|
||
|
||
const x1 = cx + rOuter * Math.cos(rad(startAngle))
|
||
const y1 = cy + rOuter * Math.sin(rad(startAngle))
|
||
const x2 = cx + rOuter * Math.cos(rad(safeEndAngle))
|
||
const y2 = cy + rOuter * Math.sin(rad(safeEndAngle))
|
||
|
||
const x3 = cx + rInner * Math.cos(rad(safeEndAngle))
|
||
const y3 = cy + rInner * Math.sin(rad(safeEndAngle))
|
||
const x4 = cx + rInner * Math.cos(rad(startAngle))
|
||
const y4 = cy + rInner * Math.sin(rad(startAngle))
|
||
|
||
const largeArc = angleDiff > 180 ? 1 : 0
|
||
|
||
return [
|
||
`M ${x1} ${y1}`,
|
||
`A ${rOuter} ${rOuter} 0 ${largeArc} 1 ${x2} ${y2}`,
|
||
`L ${x3} ${y3}`,
|
||
`A ${rInner} ${rInner} 0 ${largeArc} 0 ${x4} ${y4}`,
|
||
'Z',
|
||
].join(' ')
|
||
}
|
||
|
||
const chartSlices = computed(() => {
|
||
const items: {
|
||
id: string
|
||
category: StorageNode
|
||
label: string
|
||
sizeBytes: number
|
||
formattedSize: string
|
||
color: string
|
||
isSymlink: boolean
|
||
}[] = []
|
||
|
||
// 拆分实体与软链接
|
||
for (const cat of mainCategories.value) {
|
||
const baseLabel = formatMessage(categoryLabels[cat.type])
|
||
|
||
if (cat.size.actual > 0) {
|
||
items.push({
|
||
id: `${cat.id}-actual`,
|
||
category: cat,
|
||
label: baseLabel,
|
||
sizeBytes: cat.size.actual,
|
||
formattedSize: formatBytes(cat.size.actual),
|
||
color: categoryColors[cat.type].actual,
|
||
isSymlink: false,
|
||
})
|
||
}
|
||
|
||
if (cat.size.symlink > 0) {
|
||
items.push({
|
||
id: `${cat.id}-symlink`,
|
||
category: cat,
|
||
label: `${baseLabel} (${formatMessage(storageMessages.symlinkLabel)})`,
|
||
sizeBytes: cat.size.symlink,
|
||
formattedSize: formatBytes(cat.size.symlink),
|
||
color: categoryColors[cat.type].symlink,
|
||
isSymlink: true,
|
||
})
|
||
}
|
||
}
|
||
|
||
const total = items.reduce((acc, item) => acc + item.sizeBytes, 0) || 1
|
||
let currentAngle = 0
|
||
|
||
return items.map((item) => {
|
||
const ratio = item.sizeBytes / total
|
||
const angle = ratio * 360
|
||
const startAngle = currentAngle
|
||
const endAngle = currentAngle + angle
|
||
currentAngle = endAngle
|
||
|
||
return {
|
||
...item,
|
||
startAngle,
|
||
endAngle,
|
||
percentText: `${(ratio * 100).toFixed(1)}%`,
|
||
pathData: getRingPath(50, 50, 26, 48, startAngle, endAngle), // 外径48,内径26,加粗环形
|
||
} as ChartItem
|
||
})
|
||
})
|
||
|
||
function formatDateTime(date: Date) {
|
||
return new Intl.DateTimeFormat(undefined, {
|
||
dateStyle: 'medium',
|
||
timeStyle: 'short',
|
||
}).format(date)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div id="settings-target-storage-overview" tabindex="-1" class="storage-page">
|
||
<div v-if="loading && !tree" class="storage-loading">
|
||
<SpinnerIcon class="size-8 animate-spin text-brand" />
|
||
<span>{{ formatMessage(storageMessages.scanning) }}</span>
|
||
</div>
|
||
|
||
<template v-else-if="tree">
|
||
<!-- 顶部面板:总览 + 更新按钮 + 饼图 -->
|
||
<section class="storage-dashboard">
|
||
<div class="storage-total-card">
|
||
<h2 class="storage-total-title">
|
||
{{ formatMessage(storageMessages.total) }}
|
||
</h2>
|
||
|
||
<div class="storage-total-value">
|
||
<span class="text-3xl font-bold leading-[1.1] text-contrast">{{ formatBytes(tree.total.actual) }}</span>
|
||
<span v-if="tree.total.symlink > 0" class="total-symlink">
|
||
+ {{ formatBytes(tree.total.symlink) }} ({{
|
||
formatMessage(storageMessages.symlinkLabel)
|
||
}})
|
||
</span>
|
||
</div>
|
||
|
||
<div class="storage-actions">
|
||
<button class="btn min-w-max" :disabled="loading" @click="updateStorage">
|
||
<SpinnerIcon v-if="loading" class="size-4 animate-spin" />
|
||
<RefreshCwIcon v-else />
|
||
{{ formatMessage(loading ? storageMessages.updating : storageMessages.update) }}
|
||
</button>
|
||
<span v-if="lastUpdated" class="storage-last-updated">
|
||
<span>{{ formatMessage(storageMessages.lastUpdatedLabel) }}</span>
|
||
<span class="tabular-nums">{{ formatDateTime(lastUpdated) }}</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="mainCategories.length > 0" class="storage-chart-section">
|
||
<div class="storage-pie-wrapper">
|
||
<svg class="storage-pie-svg" viewBox="0 0 100 100">
|
||
<path
|
||
v-for="slice in chartSlices"
|
||
:key="slice.id"
|
||
:d="slice.pathData"
|
||
:fill="slice.color"
|
||
class="pie-path"
|
||
:class="{
|
||
'is-hovered': hoveredId === slice.id,
|
||
'is-dimmed': hoveredId !== null && hoveredId !== slice.id,
|
||
}"
|
||
@mouseenter="hoveredId = slice.id"
|
||
@mouseleave="hoveredId = null"
|
||
@click="handleAction(slice.category)"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
|
||
<div class="storage-legend">
|
||
<button
|
||
v-for="slice in chartSlices"
|
||
:key="slice.id"
|
||
type="button"
|
||
class="legend-item"
|
||
:class="{
|
||
'is-hovered': hoveredId === slice.id,
|
||
'is-dimmed': hoveredId !== null && hoveredId !== slice.id,
|
||
}"
|
||
@mouseenter="hoveredId = slice.id"
|
||
@mouseleave="hoveredId = null"
|
||
@click="handleAction(slice.category)"
|
||
>
|
||
<span
|
||
class="legend-dot mt-1 h-2.5 w-2.5 shrink-0 rounded-full"
|
||
:class="{ 'is-symlink-dot': slice.isSymlink }"
|
||
:style="{ backgroundColor: slice.color }"
|
||
/>
|
||
|
||
<div class="legend-info">
|
||
<span class="whitespace-nowrap text-[0.8125rem] font-semibold text-contrast">{{ slice.label }}</span>
|
||
<span class="legend-size">{{ slice.formattedSize }}</span>
|
||
<span class="legend-percent">{{ slice.percentText }}</span>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 实例树节点列表 -->
|
||
<section v-if="instancesCategory" class="mt-7">
|
||
<div v-tooltip="symlinkHelpTooltipOptions" class="instance-help">
|
||
<HelpCircleIcon class="instance-help-icon" aria-hidden="true" />
|
||
<span>{{ formatMessage(storageMessages.symlinkHelp) }}</span>
|
||
</div>
|
||
|
||
<div class="instance-heading">
|
||
<span class="storage-section-title">
|
||
{{ formatMessage(storageMessages.instanceData) }}
|
||
</span>
|
||
|
||
<span class="storage-section-size">
|
||
{{ formatSize(instancesCategory.size) }}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="storage-tree">
|
||
<StorageTreeNode
|
||
v-for="child in sortStorageChildren(instancesCategory.children)"
|
||
:key="child.id"
|
||
:node="child"
|
||
:depth="0"
|
||
:parent-total="sizeTotal(instancesCategory.size)"
|
||
@action="handleAction"
|
||
/>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.storage-page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: 100%;
|
||
color: var(--color-contrast);
|
||
}
|
||
|
||
.storage-loading {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 0.75rem;
|
||
padding: 3rem 0;
|
||
color: var(--color-secondary);
|
||
}
|
||
|
||
/* 顶栏卡片布局 */
|
||
.storage-dashboard {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--gap-xl);
|
||
padding: var(--gap-lg);
|
||
border: 1px solid var(--surface-4);
|
||
border-radius: var(--radius-md);
|
||
background: var(--surface-2);
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 左侧总大小区域 */
|
||
.storage-total-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.375rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.storage-total-title {
|
||
margin: 0;
|
||
font-size: 0.875rem;
|
||
font-weight: 500;
|
||
color: var(--color-secondary);
|
||
}
|
||
|
||
.storage-total-value {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.25rem;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.total-symlink {
|
||
font-size: 0.8125rem;
|
||
font-weight: 500;
|
||
color: var(--color-secondary);
|
||
}
|
||
|
||
.storage-actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 0.375rem;
|
||
margin-top: 0.5rem;
|
||
}
|
||
|
||
.storage-last-updated {
|
||
display: flex;
|
||
flex-direction: column;
|
||
font-size: 0.75rem;
|
||
color: var(--color-secondary);
|
||
}
|
||
|
||
/* 右侧核心区域:强制向右对齐 */
|
||
.storage-chart-section {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1.5rem;
|
||
margin-left: auto;
|
||
}
|
||
|
||
/* SVG 饼图包裹层 (较大且加粗) */
|
||
.storage-pie-wrapper {
|
||
position: relative;
|
||
width: 130px;
|
||
height: 130px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.storage-pie-svg {
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: visible;
|
||
}
|
||
|
||
.pie-path {
|
||
cursor: pointer;
|
||
transition:
|
||
transform 150ms ease,
|
||
opacity 150ms ease,
|
||
filter 150ms ease;
|
||
transform-origin: 50px 50px;
|
||
}
|
||
|
||
.pie-path.is-hovered {
|
||
transform: scale(1.06);
|
||
opacity: 1;
|
||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.25));
|
||
}
|
||
|
||
.pie-path.is-dimmed {
|
||
opacity: 0.3;
|
||
}
|
||
|
||
/* 图例布局 */
|
||
.storage-legend {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, auto);
|
||
gap: 0.625rem 1.25rem;
|
||
}
|
||
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 0.5rem;
|
||
padding: 0.375rem 0.5rem;
|
||
border: 0;
|
||
border-radius: 0.375rem;
|
||
background: transparent;
|
||
color: inherit;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
transition:
|
||
background-color 120ms ease,
|
||
opacity 150ms ease;
|
||
}
|
||
|
||
.legend-item.is-hovered {
|
||
background: var(--surface-3);
|
||
}
|
||
|
||
.legend-item.is-dimmed {
|
||
opacity: 0.3;
|
||
}
|
||
|
||
.legend-item:focus-visible {
|
||
outline: 2px solid var(--color-brand);
|
||
outline-offset: -1px;
|
||
}
|
||
|
||
.legend-dot.is-symlink-dot {
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.legend-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
line-height: 1.25;
|
||
}
|
||
|
||
.legend-size {
|
||
font-size: 0.75rem;
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--color-secondary);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* 百分比单独一行显示,避免超过卡片 */
|
||
.legend-percent {
|
||
display: block;
|
||
font-size: 0.71875rem;
|
||
font-weight: 500;
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--color-secondary);
|
||
opacity: 0.8;
|
||
}
|
||
|
||
/* 实例树样式 */
|
||
.instance-heading {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 0.75rem;
|
||
min-width: 0;
|
||
}
|
||
|
||
.storage-section-title {
|
||
font-size: 0.9375rem;
|
||
font-weight: 600;
|
||
line-height: 1.375rem;
|
||
color: var(--color-contrast);
|
||
}
|
||
|
||
.storage-section-size {
|
||
font-size: 0.8125rem;
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--color-secondary);
|
||
}
|
||
|
||
.instance-help {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.3125rem;
|
||
margin-bottom: 0.375rem;
|
||
font-size: 0.75rem;
|
||
line-height: 1.25rem;
|
||
color: var(--color-secondary);
|
||
cursor: help;
|
||
}
|
||
|
||
.instance-help-icon {
|
||
width: 0.875rem;
|
||
height: 0.875rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.storage-tree {
|
||
display: flex;
|
||
flex-direction: column;
|
||
margin-top: 0.25rem;
|
||
}
|
||
|
||
/* 响应式支持 */
|
||
@media (max-width: 860px) {
|
||
.storage-dashboard {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 1.25rem;
|
||
}
|
||
|
||
.storage-chart-section {
|
||
margin-left: 0;
|
||
width: 100%;
|
||
justify-content: flex-start;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 520px) {
|
||
.storage-chart-section {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.storage-legend {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
/* 存储页多行 tooltip:内容换行并限制宽度 */
|
||
:global(.v-popper__popper.storage-tooltip .v-popper__inner) {
|
||
white-space: pre-line;
|
||
max-width: 22rem;
|
||
}
|
||
</style>
|