ci / ci (22, ubuntu-latest) (push) Canceled after 0s
文件床 - 新增 files/ 存储前缀与 media_type 索引列,图片、视频、文件三类资源独立 - 新增文件上传、列表、删除、批量删除接口,禁止 html/htm/svg - 支持公开直链与 Token 访问两种模式,后台可切换,直链强制附件下载 视频床 - 新增 videos/ 存储前缀与视频上传接口,单文件上限 500MB - 接入 ffmpeg 转码队列,并发数与单任务核心数可配,默认串行 - 五档转码质量:直接封装 / 速度 / 平衡 / 质量 / 压缩(压缩档可调码率比例) - 码率控制避免转码后体积反超源文件,音频跟随源音轨不放大不压缩 - 转码进度实时上报,成功后删除源文件,失败时保留源文件与可用链接 - 上传时自动抽帧生成封面,视频与封面各有独立链接 - 删除视频时中断转码并清理切片、封面与源文件 媒体库 - 图库、视频库、文件库三套独立统计,含趋势、占用与来源分布 - 统一筛选、多选、批量删除、批量标签、网格/列表切换,显示方式按类型分别记忆 - 视频详情对齐图片详情,视频库支持在线播放(hls.js,兼容回退原文件) 修复 - 清理无主的切片、封面与开发期遗留的 ffmpeg 进程 - 源文件按设计删除后,索引不再被启动孤儿清理误删 - 视频统计口径与列表一致,转码中的视频不计入统计 - 转码失败时回收已写入的部分切片,避免残留 - 设置页停用检查更新(与上游版本号已不对应) 其他 - Docker 镜像安装 ffmpeg - git remote 指向内网仓库
237 lines
7.0 KiB
Vue
237 lines
7.0 KiB
Vue
<script setup lang="ts">
|
|
import type { LibraryStatsData, MediaType } from '~/types/media'
|
|
|
|
const props = withDefaults(defineProps<{
|
|
stats: LibraryStatsData | null
|
|
/** 统计口径:图片 / 视频 / 文件各自独立 */
|
|
kind?: MediaType
|
|
loading?: boolean
|
|
}>(), {
|
|
kind: 'image'
|
|
})
|
|
|
|
const { t } = useI18n()
|
|
const { formatFileSize } = useFileSize()
|
|
|
|
const SOURCE_COLORS = ['var(--ui-primary)', '#10b981'] as const
|
|
|
|
const totalHint = computed(() => {
|
|
switch (props.kind) {
|
|
case 'video':
|
|
return t('library.statsTotalHintVideo')
|
|
case 'file':
|
|
return t('library.statsTotalHintFile')
|
|
default:
|
|
return t('library.statsTotalHintImage')
|
|
}
|
|
})
|
|
|
|
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">
|
|
{{ totalHint }}
|
|
</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>
|