Files
FileHost/app/pages/gallery.vue
T
Yeomai 89ea2fb25d
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
feat: 新增视频床与文件床,视频支持 HLS 转码
文件床
- 新增 files/ 存储前缀与 media_type 索引列,图片、视频、文件三类资源独立
- 新增文件上传、列表、删除、批量删除接口,禁止 html/htm/svg
- 支持公开直链与 Token 访问两种模式,后台可切换,直链强制附件下载

视频床
- 新增 videos/ 存储前缀与视频上传接口,单文件上限 500MB
- 接入 ffmpeg 转码队列,并发数与单任务核心数可配,默认串行
- 五档转码质量:直接封装 / 速度 / 平衡 / 质量 / 压缩(压缩档可调码率比例)
- 码率控制避免转码后体积反超源文件,音频跟随源音轨不放大不压缩
- 转码进度实时上报,成功后删除源文件,失败时保留源文件与可用链接
- 上传时自动抽帧生成封面,视频与封面各有独立链接
- 删除视频时中断转码并清理切片、封面与源文件

媒体库
- 图库、视频库、文件库三套独立统计,含趋势、占用与来源分布
- 统一筛选、多选、批量删除、批量标签、网格/列表切换,显示方式按类型分别记忆
- 视频详情对齐图片详情,视频库支持在线播放(hls.js,兼容回退原文件)

修复
- 清理无主的切片、封面与开发期遗留的 ffmpeg 进程
- 源文件按设计删除后,索引不再被启动孤儿清理误删
- 视频统计口径与列表一致,转码中的视频不计入统计
- 转码失败时回收已写入的部分切片,避免残留
- 设置页停用检查更新(与上游版本号已不对应)

其他
- Docker 镜像安装 ffmpeg
- git remote 指向内网仓库
2026-10-17 03:14:57 +08:00

606 lines
16 KiB
Vue

<script setup lang="ts">
import type { ImageItem } from '~/types/image'
interface StatsResponse {
uploadToday: number
uploadYesterday: number
uploadMonth: number
uploadLastMonth: number
deleteToday: number
deleteMonth: number
uploadTotal: number
deleteTotal: number
uploadBytesTotal: number
storedCount: number
userCount?: number
bySource: {
web: number
api: number
}
storageUsage: {
usedBytes: number
totalBytes: number | null
percent: number | null
}
}
const {
items,
page: galleryPage,
pageSize: galleryPageSize,
totalPages: galleryTotalPages,
total: galleryTotal,
loading: galleryLoading,
searchQuery,
activeSearch,
fetchList,
fetchSearch,
fetchTotal,
submitSearch,
goToPage,
setPageSize,
removeItems,
activeStorageBackend,
setActiveStorageBackend,
storageBackendOptions,
activeUploadSource,
setActiveUploadSource,
resetFilters,
loadStorageBackendOptions,
activeTagIds,
activeTagMode,
activeUntaggedOnly,
setActiveTagFilter,
toggleTagFilter
} = useImageList()
const { items: tagItems, fetchTags, batchUpdateTags } = useTags()
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, getFetchErrorMessage } = useAuth()
const toast = useToast()
const { t } = useI18n()
const stats = ref<StatsResponse | null>(null)
const statsLoading = ref(false)
const pageRefreshing = computed(() => statsLoading.value || galleryLoading.value)
const selectedKeys = ref<Set<string>>(new Set())
const deletingKeys = ref<Set<string>>(new Set())
const deleteModalOpen = ref(false)
const deleteTargetKeys = ref<string[]>([])
const batchDeleting = ref(false)
const showScrollTop = ref(false)
const previewOpen = ref(false)
const previewImage = ref<ImageItem | null>(null)
const { viewMode } = useLibraryView('image')
const batchTagOpen = ref(false)
const currentUploadSource = ref('all')
const currentStorageBackend = ref('all')
const uploadSourceItems = computed(() => [
{ label: t('stats.filterSourceAll'), value: 'all' },
{ label: t('stats.sourceWeb'), value: 'web' },
{ label: t('stats.sourceApi'), value: 'api' }
])
const storageBackendItems = computed(() => [
{ label: t('stats.filterStorageAll'), value: 'all' },
...storageBackendOptions.value.map(backend => ({
label: backend.name,
value: backend.id
}))
])
const selectedCount = computed(() => selectedKeys.value.size)
function onWindowScroll() {
showScrollTop.value = window.scrollY > 400
}
function scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function fetchStats() {
stats.value = await $fetch<StatsResponse>('/api/library/stats', {
credentials: 'include',
query: { kind: 'image' }
})
}
async function refreshStats() {
statsLoading.value = true
try {
await fetchStats()
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('stats.loadFailed'), color: 'error' })
}
} finally {
statsLoading.value = false
}
}
async function reloadGallery() {
if (activeSearch.value) {
await fetchSearch(galleryPage.value)
} else {
await fetchList(galleryPage.value)
}
await fetchTotal()
}
async function refreshAll() {
if (!isAuthenticated.value) return
selectedKeys.value = new Set()
statsLoading.value = true
try {
await Promise.all([
fetchStats(),
reloadGallery(),
loadStorageBackendOptions(),
fetchTags()
])
currentUploadSource.value = activeUploadSource.value
currentStorageBackend.value = activeStorageBackend.value
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('stats.loadFailed'), color: 'error' })
}
} finally {
statsLoading.value = false
}
}
async function handleStorageBackendChange(backendId: string) {
if (!isAuthenticated.value) return
currentStorageBackend.value = backendId
selectedKeys.value = new Set()
try {
await setActiveStorageBackend(backendId)
} catch (error: unknown) {
handleAuthError(error)
}
}
async function handleUploadSourceChange(uploadSource: string) {
if (!isAuthenticated.value) return
currentUploadSource.value = uploadSource
selectedKeys.value = new Set()
try {
await setActiveUploadSource(uploadSource)
} catch (error: unknown) {
handleAuthError(error)
}
}
async function handleResetFilters() {
if (!isAuthenticated.value) return
selectedKeys.value = new Set()
currentUploadSource.value = 'all'
currentStorageBackend.value = 'all'
try {
await resetFilters()
await fetchStats()
} catch (error: unknown) {
handleAuthError(error)
}
}
async function handleTagIdsChange(tagIds: number[]) {
if (!isAuthenticated.value) return
selectedKeys.value = new Set()
try {
await setActiveTagFilter({ tagIds, untaggedOnly: false })
await fetchStats()
} catch (error: unknown) {
handleAuthError(error)
}
}
async function handleTagModeChange(tagMode: 'or' | 'and') {
if (!isAuthenticated.value) return
try {
await setActiveTagFilter({ tagMode })
await fetchStats()
} catch (error: unknown) {
handleAuthError(error)
}
}
async function handleUntaggedOnlyChange(untaggedOnly: boolean) {
if (!isAuthenticated.value) return
selectedKeys.value = new Set()
try {
await setActiveTagFilter({ untaggedOnly, tagIds: [] })
await fetchStats()
} catch (error: unknown) {
handleAuthError(error)
}
}
async function handleTagFilterClick(tagId: number) {
if (!isAuthenticated.value) return
selectedKeys.value = new Set()
try {
await toggleTagFilter(tagId)
await fetchStats()
} catch (error: unknown) {
handleAuthError(error)
}
}
async function handleUntaggedFilterClick() {
if (!isAuthenticated.value) return
selectedKeys.value = new Set()
try {
await setActiveTagFilter({ untaggedOnly: true, tagIds: [] })
await fetchStats()
} catch (error: unknown) {
handleAuthError(error)
}
}
async function confirmBatchTags(tagIds: number[], action: 'add' | 'remove') {
const keys = Array.from(selectedKeys.value)
if (!keys.length || !tagIds.length) return
try {
await batchUpdateTags(keys, tagIds, action)
batchTagOpen.value = false
selectedKeys.value = new Set()
toast.add({ title: t('tags.batchSuccess'), color: 'success' })
await Promise.all([reloadGallery(), fetchStats(), fetchTags(true)])
} catch (error: unknown) {
handleAuthError(error)
toast.add({
title: getFetchErrorMessage(error, t('tags.batchFailed')),
color: 'error'
})
}
}
function goToTagSettings() {
void navigateTo({ path: '/settings', query: { tab: 'tags' } })
}
function updateSelectedKeys(keys: Set<string>) {
selectedKeys.value = keys
}
async function handleSearch() {
if (!isAuthenticated.value) return
selectedKeys.value = new Set()
try {
await submitSearch()
} catch (error: unknown) {
handleAuthError(error)
}
}
function requestDelete(key: string) {
if (!isAuthenticated.value) return
previewOpen.value = false
previewImage.value = null
deleteTargetKeys.value = [key]
deleteModalOpen.value = true
}
function requestBatchDelete() {
if (!isAuthenticated.value) return
if (!selectedCount.value) {
toast.add({ title: t('stats.selectFirst'), color: 'warning' })
return
}
deleteTargetKeys.value = Array.from(selectedKeys.value)
deleteModalOpen.value = true
}
function clearSelection() {
selectedKeys.value = new Set()
}
async function confirmDelete() {
const keys = [...deleteTargetKeys.value]
if (!keys.length || !isAuthenticated.value) return
batchDeleting.value = true
for (const key of keys) {
deletingKeys.value.add(key)
}
const deleted: string[] = []
const failed: string[] = []
try {
const response = await $fetch<{
deleted: string[]
failed: Array<{ key: string }>
}>('/api/images/batch-delete', {
method: 'POST',
body: { keys }
})
deleted.push(...response.deleted)
failed.push(...response.failed.map(item => item.key))
if (deleted.length) {
removeItems(deleted)
for (const key of deleted) {
selectedKeys.value.delete(key)
}
selectedKeys.value = new Set(selectedKeys.value)
toast.add({
title: t('stats.deleted', { n: deleted.length }),
color: 'success'
})
await refreshStats()
await reloadGallery()
if (items.value.length === 0 && galleryPage.value > 1) {
await goToPage(galleryPage.value - 1)
}
}
if (failed.length) {
toast.add({
title: t('stats.deletePartialFail', { n: failed.length }),
color: 'error'
})
}
deleteModalOpen.value = false
deleteTargetKeys.value = []
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('stats.deleteFailed'), color: 'error' })
}
} finally {
batchDeleting.value = false
deletingKeys.value = new Set()
}
}
async function handleGalleryPageChange(targetPage: number) {
selectedKeys.value = new Set()
previewOpen.value = false
previewImage.value = null
try {
await goToPage(targetPage)
} catch (error: unknown) {
handleAuthError(error)
}
}
async function handleGalleryPageSizeChange(size: number) {
selectedKeys.value = new Set()
previewOpen.value = false
previewImage.value = null
try {
await setPageSize(size)
} catch (error: unknown) {
handleAuthError(error)
}
}
function openPreview(image: ImageItem) {
previewImage.value = image
previewOpen.value = true
}
function requestPreviewDelete() {
if (!previewImage.value) return
requestDelete(previewImage.value.key)
}
onMounted(async () => {
window.addEventListener('scroll', onWindowScroll, { passive: true })
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
if (isAuthenticated.value) {
await refreshAll()
}
})
onUnmounted(() => {
window.removeEventListener('scroll', onWindowScroll)
})
watch(isAuthenticated, async (authed, prev) => {
if (authed && prev === false) {
await nextTick()
await refreshAll()
} else if (!authed) {
stats.value = null
items.value = []
selectedKeys.value = new Set()
}
})
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<div class="flex flex-col items-center gap-3 text-muted">
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin"
/>
<p class="text-sm">
{{ t('common.loadingSession') }}
</p>
</div>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h1 class="text-2xl font-semibold tracking-tight">
{{ t('stats.title') }}
</h1>
<p class="text-sm text-muted">
{{ t('stats.subtitle') }}
</p>
</div>
<UButton
icon="i-lucide-refresh-cw"
variant="ghost"
color="neutral"
size="sm"
class="mt-0.5 shrink-0"
:aria-label="t('common.refresh')"
:loading="pageRefreshing"
@click="refreshAll"
/>
</div>
<LibraryStatsPanel
kind="image"
:stats="stats"
:loading="statsLoading"
/>
<section class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<GallerySelectionBar
:items="items"
:selected-keys="selectedKeys"
@update:selected-keys="updateSelectedKeys"
@clear="clearSelection"
/>
<GalleryFilterBar
v-model:search-query="searchQuery"
:storage-backend="currentStorageBackend"
:storage-backend-items="storageBackendItems"
:upload-source="currentUploadSource"
:upload-source-items="uploadSourceItems"
:tag-ids="activeTagIds"
:tag-mode="activeTagMode"
:untagged-only="activeUntaggedOnly"
:tags="tagItems"
:view-mode="viewMode"
:selected-count="selectedCount"
:loading="galleryLoading"
:show-batch-delete="true"
:show-batch-tags="selectedCount > 0"
@update:storage-backend="handleStorageBackendChange"
@update:upload-source="handleUploadSourceChange"
@update:tag-ids="handleTagIdsChange"
@update:tag-mode="handleTagModeChange"
@update:untagged-only="handleUntaggedOnlyChange"
@update:view-mode="viewMode = $event"
@search="handleSearch"
@reset="handleResetFilters"
@batch-delete="requestBatchDelete"
@batch-tags="batchTagOpen = true"
@manage-tags="goToTagSettings"
/>
</div>
<div
v-if="galleryLoading"
:class="viewMode === 'grid'
? 'grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5'
: 'space-y-2'"
>
<USkeleton
v-for="n in 10"
:key="n"
:class="viewMode === 'grid' ? 'aspect-[4/3] rounded-lg' : 'h-16 rounded-lg'"
/>
</div>
<ImageGrid
v-else-if="viewMode === 'grid'"
gallery
:show-key="false"
show-storage
show-tags
:items="items"
:selected-keys="selectedKeys"
:selectable="true"
:allow-delete="true"
@update:selected-keys="updateSelectedKeys"
@preview="openPreview"
@delete="(image) => requestDelete(image.key)"
@tag-click="handleTagFilterClick"
@untagged-click="handleUntaggedFilterClick"
/>
<GalleryImageList
v-else
show-storage
show-tags
:items="items"
:selected-keys="selectedKeys"
:selectable="true"
:show-delete="true"
@update:selected-keys="updateSelectedKeys"
@preview="openPreview"
@delete="(image) => requestDelete(image.key)"
@tag-click="handleTagFilterClick"
@untagged-click="handleUntaggedFilterClick"
/>
<ImagePreviewModal
v-model:open="previewOpen"
:image="previewImage"
show-storage
show-tags
:deleting="previewImage ? deletingKeys.has(previewImage.key) : false"
:allow-delete="true"
@delete="requestPreviewDelete"
/>
<PaginationBar
v-if="!galleryLoading && galleryTotal > 0"
:page="galleryPage"
:total-pages="galleryTotalPages"
:total="galleryTotal"
:page-size="galleryPageSize"
:unit="t('common.imagesUnit')"
:loading="galleryLoading"
@update:page="handleGalleryPageChange"
@update:page-size="handleGalleryPageSizeChange"
/>
</section>
<DeleteConfirmModal
v-model:open="deleteModalOpen"
:count="deleteTargetKeys.length"
:loading="batchDeleting"
@confirm="confirmDelete"
/>
<BatchTagModal
v-model:open="batchTagOpen"
:tags="tagItems"
:selected-count="selectedCount"
@confirm="confirmBatchTags"
/>
<UButton
v-show="showScrollTop"
icon="i-lucide-arrow-up"
:aria-label="t('stats.scrollTop')"
color="primary"
class="fixed bottom-6 right-6 z-50 shadow-lg"
size="lg"
@click="scrollToTop"
/>
</AppShell>
</div>
</template>