feat:移除了弹窗,服务器添加sls
This commit is contained in:
commit
6a295f9a7a
4082 files changed
+1322534
No files matched your search
@@ -0,0 +1,732 @@
|
||||
<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>
|
||||
Reference in new issue
Block a user