refactor: 重构设置、API、图库与存储页面前端结构。
将操作日志并入设置侧栏,补齐检查更新提示与版本接口刷新;拆分各模块组件并修复图库搜索分页变量。
This commit is contained in:
1 parent
270af4e707
commit
ac65c1eb9d
51 files changed
+5119
-2059
No files matched your search
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
searchQuery: string
|
||||
uploadSource: string
|
||||
uploadSourceItems: Array<{ label: string, value: string }>
|
||||
viewMode: 'grid' | 'list'
|
||||
selectedCount?: number
|
||||
loading?: boolean
|
||||
}>(), {
|
||||
selectedCount: 0,
|
||||
loading: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:searchQuery': [value: string]
|
||||
'update:uploadSource': [value: string]
|
||||
'update:viewMode': [mode: 'grid' | 'list']
|
||||
'search': []
|
||||
'reset': []
|
||||
'batch-delete': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function setViewMode(mode: 'grid' | 'list') {
|
||||
if (mode === props.viewMode || props.loading) return
|
||||
emit('update:viewMode', mode)
|
||||
}
|
||||
|
||||
function viewButtonClass(active: boolean) {
|
||||
return active
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-transparent bg-elevated text-muted hover:bg-muted hover:text-default'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="flex flex-nowrap items-center gap-2 overflow-x-auto pb-1"
|
||||
@submit.prevent="emit('search')"
|
||||
>
|
||||
<UInput
|
||||
:model-value="searchQuery"
|
||||
icon="i-lucide-search"
|
||||
:placeholder="t('stats.searchPlaceholder')"
|
||||
class="w-40 shrink-0 sm:w-48"
|
||||
size="sm"
|
||||
:disabled="loading"
|
||||
@update:model-value="emit('update:searchQuery', $event)"
|
||||
/>
|
||||
|
||||
<USelect
|
||||
:model-value="uploadSource"
|
||||
:items="uploadSourceItems"
|
||||
class="w-28 shrink-0"
|
||||
size="sm"
|
||||
:aria-label="t('stats.filterSource')"
|
||||
:disabled="loading"
|
||||
@update:model-value="emit('update:uploadSource', $event)"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
type="submit"
|
||||
icon="i-lucide-search"
|
||||
size="sm"
|
||||
color="primary"
|
||||
class="shrink-0"
|
||||
:aria-label="t('common.search')"
|
||||
:loading="loading"
|
||||
>
|
||||
{{ t('common.search') }}
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
type="button"
|
||||
icon="i-lucide-rotate-ccw"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
class="shrink-0"
|
||||
:aria-label="t('stats.resetFilters')"
|
||||
:disabled="loading"
|
||||
@click="emit('reset')"
|
||||
>
|
||||
{{ t('stats.resetFilters') }}
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
type="button"
|
||||
icon="i-lucide-trash-2"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
color="error"
|
||||
class="shrink-0"
|
||||
:disabled="!selectedCount || loading"
|
||||
@click="emit('batch-delete')"
|
||||
>
|
||||
{{ t('stats.batchDelete') }}
|
||||
</UButton>
|
||||
|
||||
<div
|
||||
class="inline-flex shrink-0 overflow-hidden rounded-md border border-default"
|
||||
role="group"
|
||||
:aria-label="t('stats.viewMode')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-8 items-center justify-center border-r border-default transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||
:class="viewButtonClass(viewMode === 'grid')"
|
||||
:aria-label="t('stats.viewGrid')"
|
||||
:aria-pressed="viewMode === 'grid'"
|
||||
:disabled="loading"
|
||||
@click="setViewMode('grid')"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-layout-grid"
|
||||
class="size-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-8 items-center justify-center transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||
:class="viewButtonClass(viewMode === 'list')"
|
||||
:aria-label="t('stats.viewList')"
|
||||
:aria-pressed="viewMode === 'list'"
|
||||
:disabled="loading"
|
||||
@click="setViewMode('list')"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-list"
|
||||
class="size-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImageItem } from '~/types/image'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
items: ImageItem[]
|
||||
selectedKeys: Set<string>
|
||||
selectable?: boolean
|
||||
showStorage?: boolean
|
||||
emptyText?: string
|
||||
}>(), {
|
||||
selectable: false,
|
||||
showStorage: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selectedKeys': [value: Set<string>]
|
||||
'preview': [image: ImageItem]
|
||||
'delete': [image: ImageItem]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { formatFileSize } = useFileSize()
|
||||
|
||||
const displayEmptyText = computed(() => props.emptyText ?? t('image.empty'))
|
||||
|
||||
const allSelected = computed(() =>
|
||||
props.items.length > 0 && props.items.every(item => props.selectedKeys.has(item.key))
|
||||
)
|
||||
|
||||
const someSelected = computed(() =>
|
||||
props.items.some(item => props.selectedKeys.has(item.key))
|
||||
)
|
||||
|
||||
const headerIndeterminate = computed(() => someSelected.value && !allSelected.value)
|
||||
|
||||
function updateSelection(key: string, selected: boolean) {
|
||||
const next = new Set(props.selectedKeys)
|
||||
if (selected) next.add(key)
|
||||
else next.delete(key)
|
||||
emit('update:selectedKeys', next)
|
||||
}
|
||||
|
||||
function toggleAll(value: boolean | 'indeterminate') {
|
||||
if (value === true) {
|
||||
emit('update:selectedKeys', new Set(props.items.map(item => item.key)))
|
||||
return
|
||||
}
|
||||
emit('update:selectedKeys', new Set())
|
||||
}
|
||||
|
||||
function formatUploadedAt(value: string) {
|
||||
try {
|
||||
const date = new Date(value)
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
const h = String(date.getHours()).padStart(2, '0')
|
||||
const min = String(date.getMinutes()).padStart(2, '0')
|
||||
const s = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${y}/${m}/${d} ${h}:${min}:${s}`
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLabel(image: ImageItem) {
|
||||
switch (image.uploadSource) {
|
||||
case 'web':
|
||||
return t('stats.tagWeb')
|
||||
case 'api':
|
||||
return t('stats.tagApi')
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function typeLabel(image: ImageItem) {
|
||||
if (props.showStorage && image.storage) return image.storage.name
|
||||
return '—'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="items.length"
|
||||
class="overflow-hidden rounded-xl border border-default bg-default"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="border-b border-default text-xs text-muted">
|
||||
<tr>
|
||||
<th
|
||||
v-if="selectable"
|
||||
class="w-10 px-4 py-3 text-left font-medium"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="allSelected ? true : headerIndeterminate ? 'indeterminate' : false"
|
||||
:aria-label="t('stats.selectAll')"
|
||||
@update:model-value="toggleAll"
|
||||
/>
|
||||
</th>
|
||||
<th class="min-w-[16rem] px-4 py-3 text-left font-medium">
|
||||
{{ t('stats.colFilename') }}
|
||||
</th>
|
||||
<th class="hidden w-28 px-4 py-3 text-left font-medium sm:table-cell">
|
||||
{{ t('stats.colSource') }}
|
||||
</th>
|
||||
<th class="w-24 px-4 py-3 text-left font-medium">
|
||||
{{ t('stats.colSize') }}
|
||||
</th>
|
||||
<th class="hidden w-32 px-4 py-3 text-left font-medium md:table-cell">
|
||||
{{ t('stats.colType') }}
|
||||
</th>
|
||||
<th class="hidden w-44 px-4 py-3 text-left font-medium lg:table-cell">
|
||||
{{ t('stats.colUploadedAt') }}
|
||||
</th>
|
||||
<th class="w-28 px-4 py-3 text-right font-medium">
|
||||
{{ t('stats.colActions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-default">
|
||||
<tr
|
||||
v-for="image in items"
|
||||
:key="image.key"
|
||||
class="transition-colors hover:bg-muted/20"
|
||||
>
|
||||
<td
|
||||
v-if="selectable"
|
||||
class="px-4 py-3"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="selectedKeys.has(image.key)"
|
||||
@update:model-value="updateSelection(image.key, $event === true)"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="relative size-10 shrink-0 overflow-hidden rounded-md border border-default bg-muted"
|
||||
:aria-label="t('image.openPreview')"
|
||||
@click="emit('preview', image)"
|
||||
>
|
||||
<img
|
||||
:src="image.url"
|
||||
:alt="image.originalName"
|
||||
loading="lazy"
|
||||
class="size-full object-cover"
|
||||
>
|
||||
</button>
|
||||
<p
|
||||
class="min-w-0 flex-1 truncate font-medium"
|
||||
:title="image.originalName"
|
||||
>
|
||||
{{ image.originalName }}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="hidden px-4 py-3 sm:table-cell">
|
||||
<span
|
||||
v-if="sourceLabel(image)"
|
||||
class="inline-flex rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
|
||||
>
|
||||
{{ sourceLabel(image) }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted"
|
||||
>—</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-muted">
|
||||
{{ formatFileSize(image.size) }}
|
||||
</td>
|
||||
<td class="hidden px-4 py-3 text-muted md:table-cell">
|
||||
<span class="truncate">{{ typeLabel(image) }}</span>
|
||||
</td>
|
||||
<td class="hidden px-4 py-3 text-muted lg:table-cell">
|
||||
{{ formatUploadedAt(image.uploadedAt) }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center justify-end gap-0.5 text-muted">
|
||||
<CopyButton
|
||||
icon="i-lucide-link"
|
||||
icon-only
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
:label="t('common.copy')"
|
||||
:value="image.url"
|
||||
:success-title="t('copy.copiedUrl')"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-eye"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="xs"
|
||||
:aria-label="t('image.openPreview')"
|
||||
@click="emit('preview', image)"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="xs"
|
||||
:aria-label="t('common.delete')"
|
||||
@click="emit('delete', image)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-xl border border-dashed border-default py-16 text-center text-sm text-muted"
|
||||
>
|
||||
{{ displayEmptyText }}
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImageItem } from '~/types/image'
|
||||
|
||||
const props = defineProps<{
|
||||
items: ImageItem[]
|
||||
selectedKeys: Set<string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selectedKeys': [value: Set<string>]
|
||||
'clear': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const allSelected = computed(() =>
|
||||
props.items.length > 0 && props.items.every(item => props.selectedKeys.has(item.key))
|
||||
)
|
||||
|
||||
const someSelected = computed(() =>
|
||||
props.items.some(item => props.selectedKeys.has(item.key))
|
||||
)
|
||||
|
||||
const indeterminate = computed(() => someSelected.value && !allSelected.value)
|
||||
|
||||
function toggleAll(value: boolean | 'indeterminate') {
|
||||
if (value === true) {
|
||||
emit('update:selectedKeys', new Set(props.items.map(item => item.key)))
|
||||
return
|
||||
}
|
||||
emit('update:selectedKeys', new Set())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<UCheckbox
|
||||
:model-value="allSelected ? true : indeterminate ? 'indeterminate' : false"
|
||||
:disabled="!items.length"
|
||||
:aria-label="t('stats.selectAll')"
|
||||
@update:model-value="toggleAll"
|
||||
/>
|
||||
<p class="text-sm text-muted">
|
||||
{{ t('stats.selectedItems', { n: selectedKeys.size }) }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-primary hover:underline disabled:pointer-events-none disabled:opacity-40"
|
||||
:disabled="selectedKeys.size === 0"
|
||||
@click="emit('clear')"
|
||||
>
|
||||
{{ t('stats.clear') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,238 @@
|
||||
<script setup lang="ts">
|
||||
export interface GalleryStatsData {
|
||||
uploadToday: number
|
||||
uploadYesterday: number
|
||||
uploadMonth: number
|
||||
uploadLastMonth: number
|
||||
uploadTotal: number
|
||||
storedCount: number
|
||||
uploadBytesTotal: number
|
||||
bySource: {
|
||||
web: number
|
||||
api: number
|
||||
}
|
||||
storageUsage: {
|
||||
usedBytes: number
|
||||
totalBytes: number | null
|
||||
percent: number | null
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
stats: GalleryStatsData | null
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { formatFileSize } = useFileSize()
|
||||
|
||||
const SOURCE_COLORS = ['var(--ui-primary)', '#10b981'] as const
|
||||
|
||||
const sourceItems = computed(() => {
|
||||
const bySource = props.stats?.bySource
|
||||
if (!bySource) return []
|
||||
return [
|
||||
{ key: 'web', label: t('stats.sourceWeb'), count: bySource.web, color: SOURCE_COLORS[0] },
|
||||
{ key: 'api', label: t('stats.sourceApi'), count: bySource.api, color: SOURCE_COLORS[1] }
|
||||
]
|
||||
})
|
||||
|
||||
const sourceTotal = computed(() =>
|
||||
sourceItems.value.reduce((sum, item) => sum + item.count, 0)
|
||||
)
|
||||
|
||||
function sourcePercent(count: number) {
|
||||
if (!sourceTotal.value) return 0
|
||||
return Math.round((count / sourceTotal.value) * 100)
|
||||
}
|
||||
|
||||
function formatTrend(current: number, previous: number) {
|
||||
if (previous === 0) {
|
||||
if (current === 0) {
|
||||
return { text: t('stats.trendFlat'), tone: 'muted' as const }
|
||||
}
|
||||
return { text: t('stats.trendUp', { p: 100 }), tone: 'up' as const }
|
||||
}
|
||||
const change = Math.round(((current - previous) / previous) * 100)
|
||||
if (change > 0) {
|
||||
return { text: t('stats.trendUp', { p: change }), tone: 'up' as const }
|
||||
}
|
||||
if (change < 0) {
|
||||
return { text: t('stats.trendDown', { p: Math.abs(change) }), tone: 'down' as const }
|
||||
}
|
||||
return { text: t('stats.trendFlat'), tone: 'muted' as const }
|
||||
}
|
||||
|
||||
const todayTrend = computed(() =>
|
||||
formatTrend(props.stats?.uploadToday ?? 0, props.stats?.uploadYesterday ?? 0)
|
||||
)
|
||||
|
||||
const monthTrend = computed(() =>
|
||||
formatTrend(props.stats?.uploadMonth ?? 0, props.stats?.uploadLastMonth ?? 0)
|
||||
)
|
||||
|
||||
const usageLabel = computed(() => {
|
||||
const usage = props.stats?.storageUsage
|
||||
if (!usage) return '—'
|
||||
const used = formatFileSize(usage.usedBytes)
|
||||
if (usage.totalBytes) {
|
||||
return `${used} / ${formatFileSize(usage.totalBytes)}`
|
||||
}
|
||||
return used
|
||||
})
|
||||
|
||||
const usagePercent = computed(() => {
|
||||
const percent = props.stats?.storageUsage.percent
|
||||
if (percent == null) return null
|
||||
return Math.min(percent, 100)
|
||||
})
|
||||
|
||||
const usageBarWidth = computed(() => {
|
||||
const usage = props.stats?.storageUsage
|
||||
if (!usage?.totalBytes || usagePercent.value == null) return 0
|
||||
return Math.max(usagePercent.value, usage.usedBytes > 0 ? 4 : 0)
|
||||
})
|
||||
|
||||
function displayValue(value: number | undefined) {
|
||||
if (props.loading) return '—'
|
||||
return value ?? '—'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
|
||||
<div class="rounded-xl border border-default bg-elevated p-4">
|
||||
<p class="text-xs text-muted">
|
||||
{{ t('stats.uploadToday') }}
|
||||
</p>
|
||||
<p class="mt-2 text-2xl font-semibold tabular-nums text-highlighted">
|
||||
{{ displayValue(stats?.uploadToday) }}
|
||||
</p>
|
||||
<p
|
||||
class="mt-2 text-xs"
|
||||
:class="{
|
||||
'text-primary': todayTrend.tone === 'up',
|
||||
'text-muted': todayTrend.tone === 'muted',
|
||||
'text-orange-600 dark:text-orange-400': todayTrend.tone === 'down'
|
||||
}"
|
||||
>
|
||||
{{ t('stats.vsYesterday') }} {{ todayTrend.text }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-default bg-elevated p-4">
|
||||
<p class="text-xs text-muted">
|
||||
{{ t('stats.uploadMonth') }}
|
||||
</p>
|
||||
<p class="mt-2 text-2xl font-semibold tabular-nums text-highlighted">
|
||||
{{ displayValue(stats?.uploadMonth) }}
|
||||
</p>
|
||||
<p
|
||||
class="mt-2 text-xs"
|
||||
:class="{
|
||||
'text-primary': monthTrend.tone === 'up',
|
||||
'text-muted': monthTrend.tone === 'muted',
|
||||
'text-orange-600 dark:text-orange-400': monthTrend.tone === 'down'
|
||||
}"
|
||||
>
|
||||
{{ t('stats.vsLastMonth') }} {{ monthTrend.text }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-default bg-elevated p-4">
|
||||
<p class="text-xs text-muted">
|
||||
{{ t('stats.uploadTotal') }}
|
||||
</p>
|
||||
<p class="mt-2 text-2xl font-semibold tabular-nums text-highlighted">
|
||||
{{ displayValue(stats?.uploadTotal) }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
{{ t('stats.totalFiles') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-default bg-elevated p-4">
|
||||
<p class="text-xs text-muted">
|
||||
{{ t('stats.storedCount') }}
|
||||
</p>
|
||||
<p class="mt-2 text-2xl font-semibold tabular-nums text-highlighted">
|
||||
{{ displayValue(stats?.storedCount) }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
{{ stats ? t('stats.totalOccupied', { size: formatFileSize(stats.uploadBytesTotal) }) : '—' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-default bg-elevated p-4">
|
||||
<p class="text-xs text-muted">
|
||||
{{ t('stats.storageUsage') }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm font-semibold tabular-nums text-highlighted sm:text-base">
|
||||
{{ usageLabel }}
|
||||
</p>
|
||||
<div
|
||||
v-if="stats?.storageUsage.totalBytes"
|
||||
class="mt-3 space-y-1"
|
||||
>
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-muted/50">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-all"
|
||||
:style="{ width: `${usageBarWidth}%` }"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-right text-xs tabular-nums text-muted">
|
||||
{{ usagePercent }}%
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="mt-2 text-xs text-muted"
|
||||
>
|
||||
{{ t('stats.noQuota') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 rounded-xl border border-default bg-elevated p-4 sm:col-span-1">
|
||||
<p class="text-xs text-muted">
|
||||
{{ t('stats.sourceDistribution') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="!sourceTotal"
|
||||
class="mt-4 text-xs text-muted"
|
||||
>
|
||||
{{ t('stats.noData') }}
|
||||
</div>
|
||||
<ul
|
||||
v-else
|
||||
class="mt-3 space-y-3"
|
||||
>
|
||||
<li
|
||||
v-for="item in sourceItems"
|
||||
:key="item.key"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 text-xs">
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
class="size-2 shrink-0 rounded-full"
|
||||
:style="{ background: item.color }"
|
||||
/>
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
</span>
|
||||
<span class="shrink-0 tabular-nums text-muted">
|
||||
{{ item.count }} ({{ sourcePercent(item.count) }}%)
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted/40">
|
||||
<div
|
||||
class="h-full rounded-full transition-[width]"
|
||||
:style="{
|
||||
width: `${sourcePercent(item.count)}%`,
|
||||
backgroundColor: item.color
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
Reference in new issue
Block a user