feat: 新增视频床与文件床,视频支持 HLS 转码
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
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 指向内网仓库
This commit is contained in:
1 parent
05cc3895f1
commit
89ea2fb25d
61 files changed
+6637
-154
No files matched your search
+69
-29
@@ -13,7 +13,9 @@ const mobileUserOpen = ref(false)
|
||||
const navItems = computed(() => {
|
||||
const items = [
|
||||
{ label: t('nav.api'), to: '/api', icon: 'i-lucide-code-2' },
|
||||
{ label: t('nav.gallery'), to: '/gallery', icon: 'i-lucide-images' }
|
||||
{ label: t('nav.gallery'), to: '/gallery', icon: 'i-lucide-images' },
|
||||
{ label: t('nav.videoLibrary'), to: '/video-library', icon: 'i-lucide-film' },
|
||||
{ label: t('nav.fileLibrary'), to: '/file-library', icon: 'i-lucide-folder-open' }
|
||||
]
|
||||
if (isAdmin.value) {
|
||||
items.push({ label: t('nav.storage'), to: '/storage', icon: 'i-lucide-hard-drive' })
|
||||
@@ -22,6 +24,28 @@ const navItems = computed(() => {
|
||||
return items
|
||||
})
|
||||
|
||||
/** 三个上传入口,与顶部 Logo 并列 */
|
||||
const hostItems = computed(() => [
|
||||
{
|
||||
label: t('host.video'),
|
||||
to: '/videos',
|
||||
icon: 'i-lucide-film',
|
||||
title: t('host.videoFull')
|
||||
},
|
||||
{
|
||||
label: t('host.file'),
|
||||
to: '/files',
|
||||
icon: 'i-lucide-folder-open',
|
||||
title: t('host.fileFull')
|
||||
}
|
||||
])
|
||||
|
||||
const mobileNavItems = computed(() => [...hostItems.value, ...navItems.value])
|
||||
|
||||
function isHostActive(to: string) {
|
||||
return route.path === to || route.path.startsWith(`${to}/`)
|
||||
}
|
||||
|
||||
const accountMenuItems = computed<DropdownMenuItem[]>(() => [
|
||||
{
|
||||
label: t('nav.changePassword'),
|
||||
@@ -66,34 +90,50 @@ async function handleLogout() {
|
||||
<header class="sticky top-0 z-40 border-b border-default bg-default/80 backdrop-blur">
|
||||
<div class="mx-auto max-w-7xl px-4 py-2.5 sm:px-6">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<NuxtLink
|
||||
to="/"
|
||||
:title="t('nav.backToUpload')"
|
||||
class="group flex min-w-0 shrink items-center gap-2.5 transition-opacity hover:opacity-90 sm:gap-3"
|
||||
>
|
||||
<img
|
||||
:src="logoLight"
|
||||
alt="PicHost"
|
||||
class="h-9 w-auto shrink-0 object-contain sm:h-11 dark:hidden"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
<div class="flex min-w-0 items-center gap-2 sm:gap-3">
|
||||
<NuxtLink
|
||||
to="/"
|
||||
:title="t('host.imageFull')"
|
||||
class="group flex min-w-0 shrink items-center gap-2.5 transition-opacity hover:opacity-90 sm:gap-3"
|
||||
>
|
||||
<img
|
||||
:src="logoDark"
|
||||
alt="PicHost"
|
||||
class="hidden h-9 w-auto shrink-0 object-contain sm:h-11 dark:block"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
>
|
||||
<div class="min-w-0 leading-tight">
|
||||
<p class="text-[15px] font-bold tracking-tight sm:text-base">
|
||||
<span class="text-highlighted">Pic</span><span class="text-primary">Host</span>
|
||||
</p>
|
||||
<p class="mt-0.5 hidden text-[11px] text-muted min-[380px]:block sm:text-xs">
|
||||
{{ t('app.tagline') }}
|
||||
</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<img
|
||||
:src="logoLight"
|
||||
alt="PicHost"
|
||||
class="h-9 w-auto shrink-0 object-contain sm:h-11 dark:hidden"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
>
|
||||
<img
|
||||
:src="logoDark"
|
||||
alt="PicHost"
|
||||
class="hidden h-9 w-auto shrink-0 object-contain sm:h-11 dark:block"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
>
|
||||
<div class="min-w-0 leading-tight">
|
||||
<p class="text-[15px] font-bold tracking-tight sm:text-base">
|
||||
<span class="text-highlighted">Pic</span><span class="text-primary">Host</span>
|
||||
</p>
|
||||
<p class="mt-0.5 hidden text-[11px] text-muted min-[380px]:block sm:text-xs">
|
||||
{{ t('app.tagline') }}
|
||||
</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
<nav class="hidden items-center gap-1 sm:flex">
|
||||
<UButton
|
||||
v-for="item in hostItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
:icon="item.icon"
|
||||
:label="item.label"
|
||||
:title="item.title"
|
||||
size="sm"
|
||||
:variant="isHostActive(item.to) ? 'soft' : 'ghost'"
|
||||
:color="isHostActive(item.to) ? 'primary' : 'neutral'"
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<ClientOnly>
|
||||
<div class="flex shrink-0 items-center gap-0.5 sm:gap-1">
|
||||
@@ -124,7 +164,7 @@ async function handleLogout() {
|
||||
/>
|
||||
<MobileNavMenu
|
||||
v-model:open="mobileNavOpen"
|
||||
:items="navItems"
|
||||
:items="mobileNavItems"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { MediaType } from '~/types/media'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
count: number
|
||||
loading?: boolean
|
||||
/** 删除对象的类型,决定确认文案 */
|
||||
kind?: MediaType
|
||||
}>()
|
||||
|
||||
const kind = computed<MediaType>(() => props.kind ?? 'image')
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'confirm': []
|
||||
@@ -12,7 +18,16 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const description = computed(() => t('delete.confirmDesc', { count: props.count }))
|
||||
const description = computed(() => {
|
||||
switch (kind.value) {
|
||||
case 'video':
|
||||
return t('delete.confirmDescVideo', { count: props.count })
|
||||
case 'file':
|
||||
return t('delete.confirmDescFile', { count: props.count })
|
||||
default:
|
||||
return t('delete.confirmDesc', { count: props.count })
|
||||
}
|
||||
})
|
||||
|
||||
function close() {
|
||||
emit('update:open', false)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import type { FileItem } from '~/types/file'
|
||||
import { formatFileSize as formatSize } from '~/composables/useFileSize'
|
||||
|
||||
const props = defineProps<{
|
||||
item: FileItem
|
||||
link: string
|
||||
downloadLink: string
|
||||
selected?: boolean
|
||||
selectable?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'delete': []
|
||||
'update:selected': [value: boolean]
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const uploadedLabel = computed(() => {
|
||||
try {
|
||||
return new Date(props.item.uploadedAt).toLocaleString(locale.value, { hour12: false })
|
||||
} catch {
|
||||
return props.item.uploadedAt
|
||||
}
|
||||
})
|
||||
|
||||
function fileIcon(item: FileItem): string {
|
||||
const type = item.contentType.toLowerCase()
|
||||
if (type.startsWith('image/')) return 'i-lucide-image'
|
||||
if (type.startsWith('video/')) return 'i-lucide-film'
|
||||
if (type.startsWith('audio/')) return 'i-lucide-music'
|
||||
if (type.includes('pdf')) return 'i-lucide-file-text'
|
||||
if (
|
||||
type.includes('zip')
|
||||
|| type.includes('rar')
|
||||
|| type.includes('7z')
|
||||
|| type.includes('tar')
|
||||
|| type.includes('gzip')
|
||||
) {
|
||||
return 'i-lucide-file-archive'
|
||||
}
|
||||
return 'i-lucide-file'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated">
|
||||
<div class="relative flex aspect-[4/3] shrink-0 items-center justify-center bg-muted">
|
||||
<UIcon
|
||||
:name="fileIcon(item)"
|
||||
class="size-12 text-dimmed"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="selectable"
|
||||
class="absolute top-2 left-2 z-10"
|
||||
@click.stop
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="selected"
|
||||
@update:model-value="emit('update:selected', $event === true)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-1.5 p-3">
|
||||
<p
|
||||
class="truncate text-sm font-semibold"
|
||||
:title="item.originalName"
|
||||
>
|
||||
{{ item.originalName }}
|
||||
</p>
|
||||
<p class="truncate text-xs text-muted">
|
||||
{{ uploadedLabel }} · {{ formatSize(item.size) }}
|
||||
</p>
|
||||
<div
|
||||
v-if="item.tags?.length"
|
||||
class="flex flex-wrap gap-1"
|
||||
>
|
||||
<TagBadge
|
||||
v-for="tag in item.tags"
|
||||
:key="tag.id"
|
||||
:tag="tag"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-around border-t border-default px-2 py-2">
|
||||
<CopyButton
|
||||
icon-only
|
||||
icon="i-lucide-link"
|
||||
:label="t('files.copyLink')"
|
||||
:value="link"
|
||||
/>
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
icon="i-lucide-download"
|
||||
:to="downloadLink"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
:aria-label="t('files.download')"
|
||||
/>
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
icon="i-lucide-trash-2"
|
||||
:aria-label="t('common.delete')"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,713 @@
|
||||
<script setup lang="ts">
|
||||
import type { FileItem, FileListResponse } from '~/types/file'
|
||||
import type { LibraryStatsData } from '~/types/media'
|
||||
import { formatFileSize } from '~/composables/useFileSize'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 文件库(file)与视频库结构一致,保留参数以便复用 */
|
||||
kind?: 'file' | 'video'
|
||||
}>(), {
|
||||
kind: 'file'
|
||||
})
|
||||
|
||||
const { isAuthenticated, handleAuthError } = useAuth()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useI18n()
|
||||
const { items: tagItems, fetchTags, batchUpdateTags } = useTags()
|
||||
|
||||
const items = ref<FileItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const totalPages = ref(1)
|
||||
const loading = ref(false)
|
||||
const stats = ref<LibraryStatsData | null>(null)
|
||||
const searchQuery = ref('')
|
||||
const activeSearch = ref('')
|
||||
const accessMode = ref<'public' | 'token'>('public')
|
||||
const apiToken = ref('')
|
||||
|
||||
const selectedKeys = ref<Set<string>>(new Set())
|
||||
const deleteModalOpen = ref(false)
|
||||
const deleteTargetKeys = ref<string[]>([])
|
||||
const deleting = ref(false)
|
||||
const batchTagOpen = ref(false)
|
||||
|
||||
const currentBackend = ref('all')
|
||||
const currentSource = ref('all')
|
||||
const backendOptions = ref<Array<{ id: string, name: string }>>([])
|
||||
const activeTagIds = ref<number[]>([])
|
||||
const activeTagMode = ref<'or' | 'and'>('or')
|
||||
const activeUntaggedOnly = ref(false)
|
||||
const { viewMode } = useLibraryView(props.kind)
|
||||
|
||||
const selectedCount = computed(() => selectedKeys.value.size)
|
||||
|
||||
const pageText = computed(() => props.kind === 'video'
|
||||
? {
|
||||
title: t('library.video.title'),
|
||||
subtitle: t('library.video.subtitle'),
|
||||
empty: t('library.video.empty')
|
||||
}
|
||||
: {
|
||||
title: t('library.file.title'),
|
||||
subtitle: t('library.file.subtitle'),
|
||||
empty: t('library.file.empty')
|
||||
})
|
||||
|
||||
const allSelected = computed(() =>
|
||||
items.value.length > 0 && items.value.every(item => selectedKeys.value.has(item.key))
|
||||
)
|
||||
|
||||
const backendItems = computed(() => [
|
||||
{ label: t('stats.filterStorageAll'), value: 'all' },
|
||||
...backendOptions.value.map(backend => ({ label: backend.name, value: backend.id }))
|
||||
])
|
||||
|
||||
const sourceItems = computed(() => [
|
||||
{ label: t('stats.filterSourceAll'), value: 'all' },
|
||||
{ label: t('stats.sourceWeb'), value: 'web' },
|
||||
{ label: t('stats.sourceApi'), value: 'api' }
|
||||
])
|
||||
|
||||
function withToken(raw: string | undefined): string {
|
||||
if (!raw) return ''
|
||||
if (accessMode.value !== 'token' || !apiToken.value) return raw
|
||||
const separator = raw.includes('?') ? '&' : '?'
|
||||
return `${raw}${separator}token=${encodeURIComponent(apiToken.value)}`
|
||||
}
|
||||
|
||||
function fileLink(item: FileItem): string {
|
||||
return withToken(item.url)
|
||||
}
|
||||
|
||||
function displayName(item: FileItem): string {
|
||||
return item.originalName
|
||||
}
|
||||
|
||||
function uploadedLabel(item: FileItem): string {
|
||||
try {
|
||||
return new Date(item.uploadedAt).toLocaleString(locale.value, { hour12: false })
|
||||
} catch {
|
||||
return item.uploadedAt
|
||||
}
|
||||
}
|
||||
|
||||
function fileIcon(item: FileItem): string {
|
||||
const type = item.contentType.toLowerCase()
|
||||
if (type.startsWith('image/')) return 'i-lucide-image'
|
||||
if (type.startsWith('video/')) return 'i-lucide-film'
|
||||
if (type.startsWith('audio/')) return 'i-lucide-music'
|
||||
if (type.includes('pdf')) return 'i-lucide-file-text'
|
||||
if (
|
||||
type.includes('zip')
|
||||
|| type.includes('rar')
|
||||
|| type.includes('7z')
|
||||
|| type.includes('tar')
|
||||
|| type.includes('gzip')
|
||||
) {
|
||||
return 'i-lucide-file-archive'
|
||||
}
|
||||
return 'i-lucide-file'
|
||||
}
|
||||
|
||||
async function loadApiToken() {
|
||||
try {
|
||||
const data = await $fetch<{ apiUploadToken: string }>('/api/user/api-token', {
|
||||
credentials: 'include'
|
||||
})
|
||||
apiToken.value = data.apiUploadToken ?? ''
|
||||
} catch {
|
||||
apiToken.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBackendOptions() {
|
||||
try {
|
||||
const data = await $fetch<{ backends: Array<{ id: string, name: string }> }>(
|
||||
'/api/storage-backends',
|
||||
{ credentials: 'include' }
|
||||
)
|
||||
backendOptions.value = data.backends ?? []
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
if (!isAuthenticated.value) return
|
||||
try {
|
||||
stats.value = await $fetch<LibraryStatsData>('/api/library/stats', {
|
||||
credentials: 'include',
|
||||
query: { kind: props.kind }
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchList(targetPage = page.value) {
|
||||
if (!isAuthenticated.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await $fetch<FileListResponse>('/api/files', {
|
||||
credentials: 'include',
|
||||
query: {
|
||||
limit: pageSize.value,
|
||||
page: targetPage,
|
||||
kind: props.kind,
|
||||
...(activeSearch.value ? { q: activeSearch.value } : {}),
|
||||
...(currentBackend.value !== 'all' ? { backendId: currentBackend.value } : {}),
|
||||
...(currentSource.value !== 'all' ? { uploadSource: currentSource.value } : {}),
|
||||
...(activeTagIds.value.length ? { tagIds: activeTagIds.value.join(',') } : {}),
|
||||
...(activeUntaggedOnly.value ? { untagged: '1' } : {}),
|
||||
...(activeTagMode.value === 'and' ? { tagMode: 'and' } : {})
|
||||
}
|
||||
})
|
||||
|
||||
items.value = response.items
|
||||
total.value = response.total
|
||||
page.value = response.page
|
||||
totalPages.value = response.totalPages
|
||||
accessMode.value = response.accessMode
|
||||
|
||||
const visible = new Set(response.items.map(item => item.key))
|
||||
selectedKeys.value = new Set([...selectedKeys.value].filter(key => visible.has(key)))
|
||||
|
||||
if (response.accessMode === 'token' && !apiToken.value) {
|
||||
await loadApiToken()
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
if (isAuthenticated.value) {
|
||||
toast.add({ title: t('files.loadFailed'), color: 'error' })
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await Promise.all([fetchList(1), fetchStats(), fetchTags()])
|
||||
}
|
||||
|
||||
async function submitSearch() {
|
||||
if (!isAuthenticated.value) return
|
||||
activeSearch.value = searchQuery.value.trim()
|
||||
await fetchList(1)
|
||||
}
|
||||
|
||||
async function handleBackendChange(value: string) {
|
||||
currentBackend.value = value
|
||||
selectedKeys.value = new Set()
|
||||
await fetchList(1)
|
||||
}
|
||||
|
||||
async function handleSourceChange(value: string) {
|
||||
currentSource.value = value
|
||||
selectedKeys.value = new Set()
|
||||
await fetchList(1)
|
||||
}
|
||||
|
||||
async function handleTagIdsChange(tagIds: number[]) {
|
||||
activeTagIds.value = tagIds
|
||||
selectedKeys.value = new Set()
|
||||
await fetchList(1)
|
||||
}
|
||||
|
||||
async function handleTagModeChange(tagMode: 'or' | 'and') {
|
||||
activeTagMode.value = tagMode
|
||||
await fetchList(1)
|
||||
}
|
||||
|
||||
async function handleUntaggedChange(untaggedOnly: boolean) {
|
||||
activeUntaggedOnly.value = untaggedOnly
|
||||
selectedKeys.value = new Set()
|
||||
await fetchList(1)
|
||||
}
|
||||
|
||||
async function resetFilters() {
|
||||
searchQuery.value = ''
|
||||
activeSearch.value = ''
|
||||
currentBackend.value = 'all'
|
||||
currentSource.value = 'all'
|
||||
activeTagIds.value = []
|
||||
activeTagMode.value = 'or'
|
||||
activeUntaggedOnly.value = false
|
||||
selectedKeys.value = new Set()
|
||||
await fetchList(1)
|
||||
}
|
||||
|
||||
function toggleSelected(key: string, value: boolean) {
|
||||
const next = new Set(selectedKeys.value)
|
||||
if (value) next.add(key)
|
||||
else next.delete(key)
|
||||
selectedKeys.value = next
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
selectedKeys.value = allSelected.value
|
||||
? new Set()
|
||||
: new Set(items.value.map(item => item.key))
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedKeys.value = new Set()
|
||||
}
|
||||
|
||||
function setViewMode(mode: 'grid' | 'list') {
|
||||
viewMode.value = mode
|
||||
}
|
||||
|
||||
function openBatchTags() {
|
||||
batchTagOpen.value = true
|
||||
}
|
||||
|
||||
function requestDelete(item: FileItem) {
|
||||
if (!isAuthenticated.value) return
|
||||
deleteTargetKeys.value = [item.key]
|
||||
deleteModalOpen.value = true
|
||||
}
|
||||
|
||||
function requestBatchDelete() {
|
||||
if (!isAuthenticated.value || !selectedCount.value) return
|
||||
deleteTargetKeys.value = [...selectedKeys.value]
|
||||
deleteModalOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const keys = [...deleteTargetKeys.value]
|
||||
if (!keys.length || !isAuthenticated.value) return
|
||||
|
||||
deleting.value = true
|
||||
try {
|
||||
if (keys.length === 1) {
|
||||
await $fetch('/api/files', {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
query: { key: keys[0] }
|
||||
})
|
||||
} else {
|
||||
const response = await $fetch<{ deleted: string[], failed: unknown[] }>(
|
||||
'/api/files/batch-delete',
|
||||
{ method: 'POST', credentials: 'include', body: { keys } }
|
||||
)
|
||||
if (response.failed.length) {
|
||||
toast.add({
|
||||
title: t('library.deletePartialFail', { n: response.failed.length }),
|
||||
color: 'warning'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
toast.add({ title: t('library.fileDeleted', { n: keys.length }), color: 'success' })
|
||||
deleteModalOpen.value = false
|
||||
deleteTargetKeys.value = []
|
||||
selectedKeys.value = new Set()
|
||||
await refresh()
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
if (isAuthenticated.value) {
|
||||
toast.add({ title: t('files.deleteFailed'), color: 'error' })
|
||||
}
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmBatchTags(tagIds: number[], action: 'add' | 'remove') {
|
||||
const keys = [...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([fetchList(1), fetchStats(), fetchTags(true)])
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
toast.add({ title: t('tags.batchFailed'), color: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePageChange(targetPage: number) {
|
||||
selectedKeys.value = new Set()
|
||||
await fetchList(targetPage)
|
||||
}
|
||||
|
||||
async function handlePageSizeChange(size: number) {
|
||||
pageSize.value = size
|
||||
selectedKeys.value = new Set()
|
||||
await fetchList(1)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadBackendOptions()
|
||||
})
|
||||
|
||||
defineExpose({ refresh })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">
|
||||
{{ pageText.title }}
|
||||
</h1>
|
||||
<p class="text-sm text-muted">
|
||||
{{ pageText.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="loading"
|
||||
@click="refresh"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LibraryStatsPanel
|
||||
:kind="props.kind"
|
||||
:stats="stats"
|
||||
:loading="loading"
|
||||
/>
|
||||
|
||||
<section class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<UCheckbox
|
||||
:model-value="allSelected"
|
||||
:disabled="!items.length"
|
||||
@update:model-value="toggleSelectAll"
|
||||
/>
|
||||
<span class="text-muted">
|
||||
{{ t('library.selectedCount', { n: selectedCount }) }}
|
||||
</span>
|
||||
<UButton
|
||||
v-if="selectedCount > 0"
|
||||
variant="link"
|
||||
color="primary"
|
||||
size="xs"
|
||||
:label="t('library.clearSelection')"
|
||||
@click="clearSelection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UInput
|
||||
v-model="searchQuery"
|
||||
icon="i-lucide-search"
|
||||
:placeholder="t('library.searchPlaceholder')"
|
||||
size="sm"
|
||||
class="w-44"
|
||||
@keydown.enter.prevent="submitSearch"
|
||||
/>
|
||||
<USelect
|
||||
:model-value="currentBackend"
|
||||
:items="backendItems"
|
||||
value-key="value"
|
||||
size="sm"
|
||||
class="w-32"
|
||||
:aria-label="t('stats.filterStorageAll')"
|
||||
@update:model-value="handleBackendChange"
|
||||
/>
|
||||
<USelect
|
||||
:model-value="currentSource"
|
||||
:items="sourceItems"
|
||||
value-key="value"
|
||||
size="sm"
|
||||
class="w-28"
|
||||
:aria-label="t('stats.filterSourceAll')"
|
||||
@update:model-value="handleSourceChange"
|
||||
/>
|
||||
<TagFilterSelect
|
||||
:model-value="activeTagIds"
|
||||
:tag-mode="activeTagMode"
|
||||
:untagged-only="activeUntaggedOnly"
|
||||
:tags="tagItems"
|
||||
:disabled="loading"
|
||||
@update:model-value="handleTagIdsChange"
|
||||
@update:tag-mode="handleTagModeChange"
|
||||
@update:untagged-only="handleUntaggedChange"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-search"
|
||||
size="sm"
|
||||
:label="t('common.search')"
|
||||
:loading="loading"
|
||||
@click="submitSearch"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-rotate-ccw"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
:label="t('library.reset')"
|
||||
@click="resetFilters"
|
||||
/>
|
||||
<UButton
|
||||
v-if="selectedCount > 0"
|
||||
icon="i-lucide-tags"
|
||||
variant="outline"
|
||||
color="primary"
|
||||
size="sm"
|
||||
:disabled="!selectedCount || loading"
|
||||
:label="t('tags.batchAdd')"
|
||||
@click="openBatchTags"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
variant="outline"
|
||||
color="error"
|
||||
size="sm"
|
||||
:disabled="!selectedCount || loading"
|
||||
:label="t('library.batchDelete')"
|
||||
@click="requestBatchDelete"
|
||||
/>
|
||||
<div class="flex gap-0.5 rounded-lg border border-default p-0.5">
|
||||
<UButton
|
||||
icon="i-lucide-layout-grid"
|
||||
size="xs"
|
||||
:variant="viewMode === 'grid' ? 'solid' : 'ghost'"
|
||||
:color="viewMode === 'grid' ? 'primary' : 'neutral'"
|
||||
:aria-label="t('stats.viewGrid')"
|
||||
@click="setViewMode('grid')"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-list"
|
||||
size="xs"
|
||||
:variant="viewMode === 'list' ? 'solid' : 'ghost'"
|
||||
:color="viewMode === 'list' ? 'primary' : 'neutral'"
|
||||
:aria-label="t('stats.viewList')"
|
||||
@click="setViewMode('list')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading && !items.length"
|
||||
class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
>
|
||||
<USkeleton
|
||||
v-for="n in 8"
|
||||
:key="n"
|
||||
class="aspect-[4/3] rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-else-if="!items.length"
|
||||
class="rounded-lg border border-default py-10 text-center text-sm text-muted"
|
||||
>
|
||||
{{ activeSearch ? t('library.searchEmpty') : pageText.empty }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-else-if="viewMode === 'grid'"
|
||||
class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
>
|
||||
<FileCard
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
:item="item"
|
||||
:link="fileLink(item)"
|
||||
:download-link="fileLink(item)"
|
||||
selectable
|
||||
:selected="selectedKeys.has(item.key)"
|
||||
@update:selected="toggleSelected(item.key, $event)"
|
||||
@delete="requestDelete(item)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="overflow-hidden rounded-xl border border-default bg-default"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-xs">
|
||||
<thead class="border-b border-default bg-elevated/40 text-muted">
|
||||
<tr>
|
||||
<th class="w-10 px-3 py-2.5 text-left font-medium">
|
||||
<UCheckbox
|
||||
:model-value="allSelected"
|
||||
:aria-label="t('stats.selectAll')"
|
||||
@update:model-value="toggleSelectAll"
|
||||
/>
|
||||
</th>
|
||||
<th class="min-w-[14rem] px-3 py-2.5 text-left font-medium">
|
||||
{{ t('stats.colFilename') }}
|
||||
</th>
|
||||
<th class="hidden w-24 px-3 py-2.5 text-left font-medium sm:table-cell">
|
||||
{{ t('stats.colSource') }}
|
||||
</th>
|
||||
<th class="w-20 px-3 py-2.5 text-left font-medium">
|
||||
{{ t('stats.colSize') }}
|
||||
</th>
|
||||
<th class="hidden w-28 px-3 py-2.5 text-left font-medium md:table-cell">
|
||||
{{ t('stats.colType') }}
|
||||
</th>
|
||||
<th class="hidden w-40 px-3 py-2.5 text-left font-medium lg:table-cell">
|
||||
{{ t('stats.colUploadedAt') }}
|
||||
</th>
|
||||
<th class="hidden min-w-[9rem] px-3 py-2.5 text-left font-medium md:table-cell">
|
||||
{{ t('tags.column') }}
|
||||
</th>
|
||||
<th class="w-24 px-3 py-2.5 text-right font-medium">
|
||||
{{ t('stats.colActions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-default">
|
||||
<tr
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
class="transition-colors hover:bg-elevated/50"
|
||||
:class="selectedKeys.has(item.key) ? 'bg-primary/5' : ''"
|
||||
>
|
||||
<td class="px-3 py-2">
|
||||
<UCheckbox
|
||||
:model-value="selectedKeys.has(item.key)"
|
||||
@update:model-value="toggleSelected(item.key, $event === true)"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-3 py-2.5">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span class="flex size-10 shrink-0 items-center justify-center rounded-md border border-default bg-muted">
|
||||
<UIcon
|
||||
:name="fileIcon(item)"
|
||||
class="size-4 text-dimmed"
|
||||
/>
|
||||
</span>
|
||||
<p
|
||||
class="min-w-0 flex-1 truncate text-highlighted"
|
||||
:title="displayName(item)"
|
||||
>
|
||||
{{ displayName(item) }}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="hidden px-3 py-2 sm:table-cell">
|
||||
<span
|
||||
v-if="item.uploadSource"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-1.5 py-0.5 text-[11px] leading-4 text-muted"
|
||||
>
|
||||
<UIcon
|
||||
:name="item.uploadSource === 'api' ? 'i-lucide-code-xml' : 'i-lucide-globe'"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
{{ item.uploadSource === 'api' ? t('stats.tagApi') : t('stats.tagWeb') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted"
|
||||
>—</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 tabular-nums text-muted">
|
||||
{{ formatFileSize(item.size) }}
|
||||
</td>
|
||||
<td class="hidden px-3 py-2 md:table-cell">
|
||||
<span
|
||||
v-if="item.storage"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-1.5 py-0.5 text-[11px] leading-4 text-muted"
|
||||
>
|
||||
<UIcon
|
||||
:name="item.storage.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
{{ item.storage.name }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted"
|
||||
>—</span>
|
||||
</td>
|
||||
<td class="hidden px-3 py-2 text-muted lg:table-cell">
|
||||
{{ uploadedLabel(item) }}
|
||||
</td>
|
||||
<td class="hidden px-3 py-2 md:table-cell">
|
||||
<div
|
||||
v-if="item.tags?.length"
|
||||
class="flex flex-wrap gap-1"
|
||||
>
|
||||
<TagBadge
|
||||
v-for="tag in item.tags"
|
||||
:key="tag.id"
|
||||
:tag="tag"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted"
|
||||
>{{ t('tags.unlabeled') }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<CopyButton
|
||||
icon-only
|
||||
icon="i-lucide-link"
|
||||
:label="t('files.copyLink')"
|
||||
:value="fileLink(item)"
|
||||
/>
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
icon="i-lucide-download"
|
||||
:to="fileLink(item)"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
:aria-label="t('files.download')"
|
||||
/>
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="error"
|
||||
icon="i-lucide-trash-2"
|
||||
:aria-label="t('common.delete')"
|
||||
@click="requestDelete(item)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaginationBar
|
||||
v-if="!loading && total > 0"
|
||||
:page="page"
|
||||
:total-pages="totalPages"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
unit=""
|
||||
:loading="loading"
|
||||
@update:page="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<DeleteConfirmModal
|
||||
v-model:open="deleteModalOpen"
|
||||
:count="deleteTargetKeys.length"
|
||||
:loading="deleting"
|
||||
:kind="props.kind"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
|
||||
<BatchTagModal
|
||||
v-model:open="batchTagOpen"
|
||||
:tags="tagItems"
|
||||
:selected-count="selectedCount"
|
||||
@confirm="confirmBatchTags"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+20
-22
@@ -1,33 +1,31 @@
|
||||
<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
|
||||
}
|
||||
}
|
||||
import type { LibraryStatsData, MediaType } from '~/types/media'
|
||||
|
||||
const props = defineProps<{
|
||||
stats: GalleryStatsData | null
|
||||
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 []
|
||||
@@ -147,7 +145,7 @@ function displayValue(value: number | undefined) {
|
||||
{{ displayValue(stats?.uploadTotal) }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
{{ t('stats.totalFiles') }}
|
||||
{{ totalHint }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
<script setup lang="ts">
|
||||
import type { FileItem } from '~/types/file'
|
||||
import type { CopyFormat, UploadMediaKind } from '~/composables/useUploadPreferences'
|
||||
|
||||
const props = defineProps<{
|
||||
item: FileItem
|
||||
kind: UploadMediaKind
|
||||
/** 已按访问模式拼接好的直链 */
|
||||
link: string
|
||||
/** 封面缩略图地址 */
|
||||
thumbLink?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const { formatFileSize } = useFileSize()
|
||||
const { t, locale } = useI18n()
|
||||
const { copyFormat } = useUploadPreferences(props.kind)
|
||||
|
||||
const copyFormatItems = computed(() => [
|
||||
{ label: t('copy.url'), value: 'url' as const },
|
||||
{ label: t('copy.markdown'), value: 'markdown' as const },
|
||||
{ label: t('copy.html'), value: 'html' as const },
|
||||
{ label: t('copy.bbcode'), value: 'bbcode' as const }
|
||||
])
|
||||
|
||||
const uploadedLabel = computed(() => {
|
||||
try {
|
||||
return new Date(props.item.uploadedAt).toLocaleString(locale.value, { hour12: false })
|
||||
} catch {
|
||||
return props.item.uploadedAt
|
||||
}
|
||||
})
|
||||
|
||||
const previewValue = computed(() => {
|
||||
switch (copyFormat.value) {
|
||||
case 'markdown':
|
||||
return `[${props.item.originalName}](${props.link})`
|
||||
case 'html':
|
||||
return `<a href="${props.link}">${props.item.originalName}</a>`
|
||||
case 'bbcode':
|
||||
return `[url=${props.link}]${props.item.originalName}[/url]`
|
||||
default:
|
||||
return props.link
|
||||
}
|
||||
})
|
||||
|
||||
const copySuccessTitle = computed(() => {
|
||||
switch (copyFormat.value) {
|
||||
case 'markdown':
|
||||
return t('copy.copiedMarkdown')
|
||||
case 'html':
|
||||
return t('copy.copiedHtml')
|
||||
case 'bbcode':
|
||||
return t('copy.copiedBbcode')
|
||||
default:
|
||||
return t('copy.copiedUrl')
|
||||
}
|
||||
})
|
||||
|
||||
function selectCopyFormat(value: CopyFormat) {
|
||||
copyFormat.value = value
|
||||
}
|
||||
|
||||
const pending = computed(() => props.item.transcodeStatus === 'pending')
|
||||
const running = computed(() => props.item.transcodeStatus === 'running')
|
||||
/** 转码中源文件只是临时文件,链接还不能对外使用 */
|
||||
const transcoding = computed(() => pending.value || running.value)
|
||||
|
||||
/** 转码完成后按 HLS 播放列表展示 */
|
||||
const displayName = computed(() => {
|
||||
if (!props.item.hlsUrl) return props.item.originalName
|
||||
const dot = props.item.originalName.lastIndexOf('.')
|
||||
const base = dot > 0 ? props.item.originalName.slice(0, dot) : props.item.originalName
|
||||
return `${base}.m3u8`
|
||||
})
|
||||
|
||||
/** 该视频使用的转码档位(等待/转码中时展示) */
|
||||
const presetLabel = computed(() => {
|
||||
const [name, value] = (props.item.transcodePreset ?? '').split(':')
|
||||
switch (name) {
|
||||
case 'copy':
|
||||
return t('preferences.presetCopy')
|
||||
case 'veryfast':
|
||||
return t('preferences.presetSpeed')
|
||||
case 'medium':
|
||||
return t('preferences.presetBalanced')
|
||||
case 'slow':
|
||||
return t('preferences.presetQuality')
|
||||
case 'compress':
|
||||
return value
|
||||
? `${t('preferences.presetCompress')} ${value}%`
|
||||
: t('preferences.presetCompress')
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated shadow-sm">
|
||||
<div class="relative aspect-video shrink-0 overflow-hidden bg-muted">
|
||||
<img
|
||||
v-if="kind === 'video' && item.thumbUrl"
|
||||
:src="thumbLink"
|
||||
:alt="item.originalName"
|
||||
loading="lazy"
|
||||
class="size-full object-cover"
|
||||
>
|
||||
<div
|
||||
v-else-if="kind === 'video'"
|
||||
class="flex size-full items-center justify-center"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-film"
|
||||
class="size-10 text-dimmed"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex size-full items-center justify-center"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-file"
|
||||
class="size-10 text-dimmed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-2 right-2">
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
variant="solid"
|
||||
color="neutral"
|
||||
size="xs"
|
||||
class="bg-black/55 text-white hover:bg-black/70"
|
||||
:aria-label="t('common.delete')"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-2 p-3">
|
||||
<p
|
||||
class="truncate text-sm font-medium"
|
||||
:title="displayName"
|
||||
>
|
||||
{{ displayName }}
|
||||
</p>
|
||||
|
||||
<p class="flex items-center gap-2 text-xs text-muted">
|
||||
<span class="truncate">{{ uploadedLabel }} · {{ formatFileSize(item.size) }}</span>
|
||||
<UBadge
|
||||
v-if="pending"
|
||||
color="neutral"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
class="shrink-0"
|
||||
>
|
||||
{{ t('library.transcodePending') }}
|
||||
</UBadge>
|
||||
<UBadge
|
||||
v-else-if="running"
|
||||
color="primary"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
class="shrink-0"
|
||||
>
|
||||
{{ t('library.transcoding') }} {{ item.transcodeProgress ?? 0 }}%
|
||||
</UBadge>
|
||||
<UBadge
|
||||
v-else-if="item.transcodeStatus === 'failed'"
|
||||
color="error"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
class="shrink-0"
|
||||
>
|
||||
{{ t('library.transcodeFailed') }}
|
||||
</UBadge>
|
||||
<UBadge
|
||||
v-else-if="item.hlsUrl"
|
||||
color="success"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
class="shrink-0"
|
||||
>
|
||||
m3u8
|
||||
</UBadge>
|
||||
</p>
|
||||
|
||||
<UProgress
|
||||
v-if="running"
|
||||
:model-value="item.transcodeProgress ?? 0"
|
||||
size="xs"
|
||||
color="primary"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="!transcoding"
|
||||
class="flex gap-1 rounded-lg border border-default p-0.5"
|
||||
>
|
||||
<UButton
|
||||
v-for="option in copyFormatItems"
|
||||
:key="option.value"
|
||||
size="xs"
|
||||
class="flex-1 justify-center"
|
||||
:variant="copyFormat === option.value ? 'solid' : 'ghost'"
|
||||
:color="copyFormat === option.value ? 'primary' : 'neutral'"
|
||||
:label="option.label"
|
||||
@click="selectCopyFormat(option.value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!transcoding"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<UInput
|
||||
:model-value="previewValue"
|
||||
readonly
|
||||
size="xs"
|
||||
class="min-w-0 flex-1 font-mono text-xs"
|
||||
/>
|
||||
<CopyButton
|
||||
:label="t('common.copy')"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
:value="previewValue"
|
||||
:success-title="copySuccessTitle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-else
|
||||
class="flex flex-wrap items-center gap-1.5"
|
||||
>
|
||||
<UBadge
|
||||
v-if="presetLabel"
|
||||
color="primary"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ presetLabel }}
|
||||
</UBadge>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import type { FileItem } from '~/types/file'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 视频床或文件床的上传页 */
|
||||
kind: 'video' | 'file'
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const {
|
||||
uploading,
|
||||
progressItems,
|
||||
sessionItems,
|
||||
linkUrl,
|
||||
thumbLink,
|
||||
loadActiveTranscodes,
|
||||
uploadFiles
|
||||
} = useMediaUpload(props.kind)
|
||||
|
||||
onMounted(() => {
|
||||
void loadActiveTranscodes()
|
||||
})
|
||||
|
||||
const text = computed(() => props.kind === 'video'
|
||||
? {
|
||||
uploadTitle: t('library.video.uploadTitle'),
|
||||
uploadHint: t('library.video.uploadHint'),
|
||||
uploadFormats: t('library.video.uploadFormats')
|
||||
}
|
||||
: {
|
||||
uploadTitle: t('library.file.uploadTitle'),
|
||||
uploadHint: t('library.file.uploadHint'),
|
||||
uploadFormats: t('library.file.uploadFormats')
|
||||
})
|
||||
|
||||
const showProgress = computed(() =>
|
||||
uploading.value
|
||||
|| progressItems.value.some(
|
||||
item =>
|
||||
item.status === 'pending'
|
||||
|| item.status === 'uploading'
|
||||
|| item.status === 'error'
|
||||
)
|
||||
)
|
||||
|
||||
async function removeItem(item: FileItem) {
|
||||
try {
|
||||
await $fetch('/api/files', {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
query: { key: item.key }
|
||||
})
|
||||
sessionItems.value = sessionItems.value.filter(entry => entry.key !== item.key)
|
||||
toast.add({ title: t('files.deleted'), color: 'success' })
|
||||
} catch {
|
||||
toast.add({ title: t('files.deleteFailed'), color: 'error' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<UploadFlipCard
|
||||
:kind="kind"
|
||||
:title="text.uploadTitle"
|
||||
:hint="text.uploadHint"
|
||||
:formats="text.uploadFormats"
|
||||
:disabled="uploading"
|
||||
@upload="uploadFiles"
|
||||
/>
|
||||
|
||||
<UploadResult
|
||||
v-if="showProgress"
|
||||
:items="progressItems"
|
||||
/>
|
||||
|
||||
<section
|
||||
v-if="sessionItems.length"
|
||||
class="space-y-4"
|
||||
>
|
||||
<h2 class="text-lg font-medium">
|
||||
{{ t('upload.sessionTitle') }}
|
||||
<span class="ml-1 text-sm font-normal text-muted">({{ sessionItems.length }})</span>
|
||||
</h2>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<MediaSessionCard
|
||||
v-for="item in sessionItems"
|
||||
:key="item.key"
|
||||
:item="item"
|
||||
:kind="kind"
|
||||
:link="linkUrl(item)"
|
||||
:thumb-link="thumbLink(item)"
|
||||
@delete="removeItem(item)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,9 +2,24 @@
|
||||
import uploadCardBg from '~/assets/image/upload-card-bg.webp'
|
||||
import uploadCardHero from '~/assets/image/upload-card-hero.webp'
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
disabled?: boolean
|
||||
}>()
|
||||
title?: string
|
||||
hint?: string
|
||||
formats?: string
|
||||
accept?: string
|
||||
heroImage?: string
|
||||
backgroundImage?: string
|
||||
showSettings?: boolean
|
||||
pasteEnabled?: boolean
|
||||
/** 拖拽/选择时的类型限制;图片页与视频页各自限定,文件页放开 */
|
||||
mediaKind?: 'image' | 'video' | 'any'
|
||||
}>(), {
|
||||
accept: 'image/jpeg,image/png,image/webp,image/gif,image/svg+xml,image/x-icon,.svg,.ico',
|
||||
showSettings: true,
|
||||
pasteEnabled: true,
|
||||
mediaKind: 'image'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'upload': [files: File[]]
|
||||
@@ -18,9 +33,22 @@ const { t } = useI18n()
|
||||
const { bindPaste } = useClipboardImage(ref(null))
|
||||
|
||||
bindPaste((files) => {
|
||||
if (!props.disabled) emit('upload', files)
|
||||
if (props.disabled || !props.pasteEnabled) return
|
||||
emit('upload', files)
|
||||
})
|
||||
|
||||
const heroSource = computed(() => props.heroImage ?? uploadCardHero)
|
||||
const backgroundSource = computed(() => props.backgroundImage ?? uploadCardBg)
|
||||
const titleText = computed(() => props.title ?? t('upload.title'))
|
||||
const hintText = computed(() => props.hint ?? t('upload.hint', { ctrl: 'Ctrl', v: 'V' }))
|
||||
const formatsText = computed(() => props.formats ?? t('upload.formats'))
|
||||
|
||||
function matchesMediaKind(file: File): boolean {
|
||||
if (props.mediaKind === 'any') return true
|
||||
if (!file.type) return true
|
||||
return file.type.startsWith(`${props.mediaKind}/`)
|
||||
}
|
||||
|
||||
function openFilePicker() {
|
||||
if (props.disabled) return
|
||||
fileInput.value?.click()
|
||||
@@ -38,8 +66,7 @@ function onDrop(event: DragEvent) {
|
||||
dragging.value = false
|
||||
if (props.disabled) return
|
||||
|
||||
const files = Array.from(event.dataTransfer?.files ?? [])
|
||||
.filter(file => file.type.startsWith('image/'))
|
||||
const files = Array.from(event.dataTransfer?.files ?? []).filter(matchesMediaKind)
|
||||
if (files.length) emit('upload', files)
|
||||
}
|
||||
|
||||
@@ -83,7 +110,7 @@ function onKeydown(event: KeyboardEvent) {
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml,image/x-icon,.svg,.ico"
|
||||
:accept="accept"
|
||||
multiple
|
||||
class="hidden"
|
||||
:disabled="disabled"
|
||||
@@ -91,7 +118,7 @@ function onKeydown(event: KeyboardEvent) {
|
||||
>
|
||||
|
||||
<img
|
||||
:src="uploadCardBg"
|
||||
:src="backgroundSource"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 size-full object-cover opacity-100 dark:opacity-[0.12]"
|
||||
@@ -99,7 +126,10 @@ function onKeydown(event: KeyboardEvent) {
|
||||
draggable="false"
|
||||
>
|
||||
|
||||
<div class="absolute top-3 right-3 z-20">
|
||||
<div
|
||||
v-if="showSettings"
|
||||
class="absolute top-3 right-3 z-20"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="upload-settings-btn inline-flex size-8 items-center justify-center rounded-md bg-default/70 text-muted backdrop-blur-sm transition-colors hover:bg-elevated hover:text-highlighted disabled:pointer-events-none disabled:opacity-40"
|
||||
@@ -117,7 +147,7 @@ function onKeydown(event: KeyboardEvent) {
|
||||
<div class="relative z-10 mx-auto flex max-w-lg flex-col items-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<img
|
||||
:src="uploadCardHero"
|
||||
:src="heroSource"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-24 w-auto -mb-3 select-none transition-transform duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] sm:-mb-4 sm:h-28 md:h-32"
|
||||
@@ -128,16 +158,16 @@ function onKeydown(event: KeyboardEvent) {
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-xl font-semibold tracking-tight text-highlighted sm:text-2xl">
|
||||
{{ t('upload.title') }}
|
||||
{{ titleText }}
|
||||
</p>
|
||||
<p class="text-sm text-muted sm:text-base">
|
||||
{{ t('upload.hint', { ctrl: 'Ctrl', v: 'V' }) }}
|
||||
{{ hintText }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 max-w-md text-xs leading-relaxed text-dimmed sm:text-sm">
|
||||
{{ t('upload.formats') }}
|
||||
{{ formatsText }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,7 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
import type { UploadMediaKind } from '~/composables/useUploadPreferences'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
disabled?: boolean
|
||||
}>()
|
||||
/** 上传页类型:决定默认可选文件类型、粘贴支持与偏好面板内容 */
|
||||
kind?: UploadMediaKind
|
||||
title?: string
|
||||
hint?: string
|
||||
formats?: string
|
||||
accept?: string
|
||||
heroImage?: string
|
||||
backgroundImage?: string
|
||||
pasteEnabled?: boolean
|
||||
}>(), {
|
||||
kind: 'image'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
upload: [files: File[]]
|
||||
@@ -9,6 +22,15 @@ const emit = defineEmits<{
|
||||
|
||||
const flipped = ref(false)
|
||||
|
||||
const mediaKind = computed(() => (props.kind === 'file' ? 'any' : props.kind))
|
||||
const pasteEnabled = computed(() => props.pasteEnabled ?? props.kind === 'image')
|
||||
const defaultAccept = computed(() => {
|
||||
if (props.kind === 'video') return 'video/*,.mp4,.webm,.mov,.mkv,.avi,.m4v'
|
||||
if (props.kind === 'file') return ''
|
||||
return undefined
|
||||
})
|
||||
const accept = computed(() => props.accept ?? defaultAccept.value)
|
||||
|
||||
function onOpenSettings() {
|
||||
flipped.value = true
|
||||
}
|
||||
@@ -25,14 +47,25 @@ function onBack() {
|
||||
>
|
||||
<div class="upload-card-flip-inner">
|
||||
<div class="upload-card-face upload-card-face--front">
|
||||
<ImageUploader
|
||||
<UploadCard
|
||||
:disabled="disabled"
|
||||
:title="title"
|
||||
:hint="hint"
|
||||
:formats="formats"
|
||||
:accept="accept"
|
||||
:hero-image="heroImage"
|
||||
:background-image="backgroundImage"
|
||||
:media-kind="mediaKind"
|
||||
:paste-enabled="pasteEnabled"
|
||||
@upload="emit('upload', $event)"
|
||||
@open-settings="onOpenSettings"
|
||||
/>
|
||||
</div>
|
||||
<div class="upload-card-face upload-card-face--back">
|
||||
<UploadPreferencesPanel @back="onBack" />
|
||||
<UploadPreferencesPanel
|
||||
:kind="kind"
|
||||
@back="onBack"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { UploadMediaKind } from '~/composables/useUploadPreferences'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 每个上传页各自一份偏好 */
|
||||
kind?: UploadMediaKind
|
||||
}>(), {
|
||||
kind: 'image'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
}>()
|
||||
@@ -8,8 +17,18 @@ const {
|
||||
autoCopyMarkdown,
|
||||
copyFormat,
|
||||
clientWebpQuality,
|
||||
transcodeToHls,
|
||||
loadPreferences
|
||||
} = useUploadPreferences()
|
||||
} = useUploadPreferences(props.kind)
|
||||
|
||||
/** 视频床 / 文件床没有图片压缩相关选项 */
|
||||
const isImageKind = computed(() => props.kind === 'image')
|
||||
const isVideoKind = computed(() => props.kind === 'video')
|
||||
const gridClass = computed(() => {
|
||||
if (isImageKind.value) return 'sm:grid-cols-3 sm:gap-6'
|
||||
if (isVideoKind.value) return 'sm:grid-cols-2 sm:gap-6'
|
||||
return 'mx-auto w-full max-w-md'
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -20,6 +39,32 @@ const {
|
||||
load: loadUserSettings
|
||||
} = useUserAutoDeleteSettings()
|
||||
|
||||
const { isAdmin } = useAuth()
|
||||
const {
|
||||
concurrency: transcodeConcurrency,
|
||||
threads: transcodeThreads,
|
||||
maxConcurrency: maxTranscodeConcurrency,
|
||||
maxThreads: maxTranscodeThreads,
|
||||
preset: transcodePreset,
|
||||
bitratePercent: transcodeBitratePercent,
|
||||
minBitratePercent,
|
||||
maxBitratePercent,
|
||||
load: loadTranscodeSettings
|
||||
} = useTranscodeSettings()
|
||||
|
||||
const presetItems = computed(() => [
|
||||
{ value: 'copy', label: t('preferences.presetCopy') },
|
||||
{ value: 'veryfast', label: t('preferences.presetSpeed') },
|
||||
{ value: 'medium', label: t('preferences.presetBalanced') },
|
||||
{ value: 'slow', label: t('preferences.presetQuality') },
|
||||
{ value: 'compress', label: t('preferences.presetCompress') }
|
||||
])
|
||||
|
||||
function onBitrateInput(event: Event) {
|
||||
const value = Number((event.target as HTMLInputElement).value)
|
||||
transcodeBitratePercent.value = Number.isFinite(value) ? value : 80
|
||||
}
|
||||
|
||||
const copyFormatItems = computed(() => [
|
||||
{ label: t('copy.url'), value: 'url' as const },
|
||||
{ label: t('copy.markdown'), value: 'markdown' as const },
|
||||
@@ -42,9 +87,14 @@ function onClientQualityInput(event: Event) {
|
||||
clientWebpQuality.value = Number.isFinite(value) ? value : 80
|
||||
}
|
||||
|
||||
function selectPreset(value: string) {
|
||||
transcodePreset.value = value
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPreferences()
|
||||
void loadUserSettings()
|
||||
void loadTranscodeSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -78,8 +128,14 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col justify-center p-5 sm:p-6">
|
||||
<div class="grid gap-5 sm:grid-cols-3 sm:gap-6">
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="grid gap-5"
|
||||
:class="gridClass"
|
||||
>
|
||||
<div
|
||||
v-if="isImageKind"
|
||||
class="space-y-4"
|
||||
>
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ t('preferences.processing') }}
|
||||
</h3>
|
||||
@@ -137,7 +193,103 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="isVideoKind"
|
||||
class="space-y-4"
|
||||
>
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ t('preferences.videoProcessing') }}
|
||||
</h3>
|
||||
|
||||
<label class="flex cursor-pointer items-start gap-2.5">
|
||||
<UCheckbox
|
||||
v-model="transcodeToHls"
|
||||
class="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
<span class="block text-sm">{{ t('preferences.transcodeToHls') }}</span>
|
||||
<span class="mt-1 block text-xs leading-relaxed text-muted">
|
||||
{{ t('preferences.transcodeToHlsHint') }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-if="transcodeToHls"
|
||||
class="ml-7 space-y-3 border-t border-default pt-4"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="shrink-0">{{ t('preferences.transcodeConcurrency') }}</span>
|
||||
<UInput
|
||||
v-model.number="transcodeConcurrency"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="maxTranscodeConcurrency"
|
||||
size="xs"
|
||||
class="w-16 tabular-nums"
|
||||
:disabled="!isAdmin"
|
||||
/>
|
||||
<span class="text-muted">{{ t('preferences.unitCount') }}</span>
|
||||
<span class="text-xs text-muted">{{ t('preferences.concurrentHint') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="shrink-0">{{ t('preferences.transcodeThreads') }}</span>
|
||||
<UInput
|
||||
v-model.number="transcodeThreads"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="maxTranscodeThreads"
|
||||
size="xs"
|
||||
class="w-16 tabular-nums"
|
||||
:disabled="!isAdmin"
|
||||
/>
|
||||
<span class="text-muted">{{ t('preferences.unitCount') }}</span>
|
||||
<span class="text-xs text-muted">{{ t('preferences.threadsHint') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="shrink-0">{{ t('preferences.transcodeQuality') }}</span>
|
||||
<div class="flex gap-1 rounded-lg border border-default p-0.5">
|
||||
<UButton
|
||||
v-for="item in presetItems"
|
||||
:key="item.value"
|
||||
size="xs"
|
||||
class="flex-1 justify-center"
|
||||
:variant="transcodePreset === item.value ? 'solid' : 'ghost'"
|
||||
:color="transcodePreset === item.value ? 'primary' : 'neutral'"
|
||||
:label="item.label"
|
||||
:disabled="!isAdmin"
|
||||
@click="selectPreset(item.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-2 text-sm"
|
||||
:class="transcodePreset === 'compress' ? '' : 'opacity-40'"
|
||||
>
|
||||
<span class="shrink-0">{{ t('preferences.compressBitrate') }}</span>
|
||||
<input
|
||||
type="range"
|
||||
:min="minBitratePercent"
|
||||
:max="maxBitratePercent"
|
||||
:value="transcodeBitratePercent"
|
||||
:disabled="!isAdmin || transcodePreset !== 'compress'"
|
||||
class="h-2 min-w-24 flex-1 cursor-pointer appearance-none rounded-full bg-default accent-primary disabled:opacity-40"
|
||||
@input="onBitrateInput"
|
||||
>
|
||||
<span class="w-12 shrink-0 text-right tabular-nums text-muted">
|
||||
{{ transcodeBitratePercent }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isImageKind"
|
||||
class="space-y-4"
|
||||
>
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ t('preferences.webpQuality') }}
|
||||
</h3>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import type { FileItem } from '~/types/file'
|
||||
|
||||
const props = defineProps<{
|
||||
item: FileItem
|
||||
/** 已按访问模式拼接好的视频直链 */
|
||||
link: string
|
||||
/** 缩略图直链 */
|
||||
thumbLink: string
|
||||
/** 原始视频下载地址 */
|
||||
downloadLink: string
|
||||
selected?: boolean
|
||||
selectable?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'play': []
|
||||
'delete': []
|
||||
'update:selected': [value: boolean]
|
||||
}>()
|
||||
|
||||
const { formatFileSize } = useFileSize()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
/** 转码完成后按 HLS 播放列表展示 */
|
||||
const displayName = computed(() => {
|
||||
if (!props.item.hlsUrl) return props.item.originalName
|
||||
const dot = props.item.originalName.lastIndexOf('.')
|
||||
const base = dot > 0 ? props.item.originalName.slice(0, dot) : props.item.originalName
|
||||
return `${base}.m3u8`
|
||||
})
|
||||
|
||||
const uploadedLabel = computed(() => {
|
||||
try {
|
||||
return new Date(props.item.uploadedAt).toLocaleString(locale.value, { hour12: false })
|
||||
} catch {
|
||||
return props.item.uploadedAt
|
||||
}
|
||||
})
|
||||
|
||||
const thumbFailed = ref(false)
|
||||
|
||||
watch(() => props.item.thumbUrl, () => {
|
||||
thumbFailed.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated">
|
||||
<div class="relative aspect-video shrink-0 overflow-hidden bg-muted">
|
||||
<img
|
||||
v-if="item.thumbUrl && !thumbFailed"
|
||||
:src="thumbLink"
|
||||
:alt="displayName"
|
||||
loading="lazy"
|
||||
class="size-full object-cover object-center"
|
||||
@error="thumbFailed = true"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="flex size-full items-center justify-center"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-film"
|
||||
class="size-10 text-dimmed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 flex items-center justify-center transition-colors hover:bg-black/20"
|
||||
:aria-label="t('library.play')"
|
||||
@click="emit('play')"
|
||||
>
|
||||
<span class="rounded-full bg-black/55 p-3 text-white backdrop-blur-sm">
|
||||
<UIcon
|
||||
name="i-lucide-play"
|
||||
class="size-6"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="selectable"
|
||||
class="absolute top-2 left-2 z-10"
|
||||
@click.stop
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="selected"
|
||||
@update:model-value="emit('update:selected', $event === true)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute top-2 right-2 z-10"
|
||||
@click.stop
|
||||
>
|
||||
<UBadge
|
||||
v-if="item.hlsUrl"
|
||||
color="success"
|
||||
variant="solid"
|
||||
size="xs"
|
||||
>
|
||||
m3u8
|
||||
</UBadge>
|
||||
<UBadge
|
||||
v-else-if="item.transcodeStatus === 'failed'"
|
||||
color="error"
|
||||
variant="solid"
|
||||
size="xs"
|
||||
>
|
||||
{{ t('library.transcodeFailed') }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-1.5 p-3">
|
||||
<p
|
||||
class="truncate text-sm font-semibold"
|
||||
:title="displayName"
|
||||
>
|
||||
{{ displayName }}
|
||||
</p>
|
||||
<p class="truncate text-xs text-muted">
|
||||
{{ uploadedLabel }} · {{ formatFileSize(item.size) }}
|
||||
</p>
|
||||
<div
|
||||
v-if="item.tags?.length"
|
||||
class="flex flex-wrap gap-1"
|
||||
>
|
||||
<TagBadge
|
||||
v-for="tag in item.tags"
|
||||
:key="tag.id"
|
||||
:tag="tag"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-around border-t border-default px-2 py-2">
|
||||
<CopyButton
|
||||
icon="i-lucide-link"
|
||||
icon-only
|
||||
:label="t('library.copyVideoLink')"
|
||||
:value="link"
|
||||
:success-title="t('copy.copiedUrl')"
|
||||
/>
|
||||
<CopyButton
|
||||
icon="i-lucide-image"
|
||||
icon-only
|
||||
:label="t('library.copyThumbLink')"
|
||||
:value="thumbLink"
|
||||
:success-title="t('copy.copiedUrl')"
|
||||
/>
|
||||
<UButton
|
||||
v-if="!item.sourceDeleted"
|
||||
icon="i-lucide-download"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
:to="downloadLink"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
:aria-label="t('files.download')"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
:aria-label="t('common.delete')"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,752 @@
|
||||
<script setup lang="ts">
|
||||
import type { FileItem, FileListResponse } from '~/types/file'
|
||||
import type { LibraryStatsData } from '~/types/media'
|
||||
import { formatFileSize } from '~/composables/useFileSize'
|
||||
|
||||
const { isAuthenticated, handleAuthError } = useAuth()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useI18n()
|
||||
const { items: tagItems, fetchTags, batchUpdateTags } = useTags()
|
||||
|
||||
const items = ref<FileItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const totalPages = ref(1)
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const activeSearch = ref('')
|
||||
const accessMode = ref<'public' | 'token'>('public')
|
||||
const apiToken = ref('')
|
||||
const stats = ref<LibraryStatsData | null>(null)
|
||||
|
||||
const selectedKeys = ref<Set<string>>(new Set())
|
||||
const deleteModalOpen = ref(false)
|
||||
const batchTagOpen = ref(false)
|
||||
const deleteTargetKeys = ref<string[]>([])
|
||||
const deleting = ref(false)
|
||||
const previewOpen = ref(false)
|
||||
const previewItem = ref<FileItem | null>(null)
|
||||
|
||||
const currentBackend = ref('all')
|
||||
const currentSource = ref('all')
|
||||
const backendOptions = ref<Array<{ id: string, name: string }>>([])
|
||||
const activeTagIds = ref<number[]>([])
|
||||
const activeTagMode = ref<'or' | 'and'>('or')
|
||||
const activeUntaggedOnly = ref(false)
|
||||
const { viewMode } = useLibraryView('video')
|
||||
|
||||
const selectedCount = computed(() => selectedKeys.value.size)
|
||||
|
||||
const backendItems = computed(() => [
|
||||
{ label: t('stats.filterStorageAll'), value: 'all' },
|
||||
...backendOptions.value.map(backend => ({ label: backend.name, value: backend.id }))
|
||||
])
|
||||
|
||||
const sourceItems = computed(() => [
|
||||
{ label: t('stats.filterSourceAll'), value: 'all' },
|
||||
{ label: t('stats.sourceWeb'), value: 'web' },
|
||||
{ label: t('stats.sourceApi'), value: 'api' }
|
||||
])
|
||||
|
||||
const allSelected = computed(() =>
|
||||
items.value.length > 0 && items.value.every(item => selectedKeys.value.has(item.key))
|
||||
)
|
||||
|
||||
function withToken(raw: string | undefined): string {
|
||||
if (!raw) return ''
|
||||
if (accessMode.value !== 'token' || !apiToken.value) return raw
|
||||
const separator = raw.includes('?') ? '&' : '?'
|
||||
return `${raw}${separator}token=${encodeURIComponent(apiToken.value)}`
|
||||
}
|
||||
|
||||
/** 转码完成后主直链指向 m3u8 播放列表 */
|
||||
function videoLink(item: FileItem): string {
|
||||
return withToken(item.hlsUrl ?? item.url)
|
||||
}
|
||||
|
||||
function thumbLink(item: FileItem): string {
|
||||
return withToken(item.thumbUrl)
|
||||
}
|
||||
|
||||
function downloadUrl(item: FileItem): string {
|
||||
return withToken(item.url)
|
||||
}
|
||||
|
||||
/** 转码完成后按 HLS 播放列表展示名称 */
|
||||
function displayName(item: FileItem): string {
|
||||
if (!item.hlsUrl) return item.originalName
|
||||
const dot = item.originalName.lastIndexOf('.')
|
||||
const base = dot > 0 ? item.originalName.slice(0, dot) : item.originalName
|
||||
return `${base}.m3u8`
|
||||
}
|
||||
|
||||
function uploadedLabel(item: FileItem): string {
|
||||
try {
|
||||
return new Date(item.uploadedAt).toLocaleString(locale.value, { hour12: false })
|
||||
} catch {
|
||||
return item.uploadedAt
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApiToken() {
|
||||
try {
|
||||
const data = await $fetch<{ apiUploadToken: string }>('/api/user/api-token', {
|
||||
credentials: 'include'
|
||||
})
|
||||
apiToken.value = data.apiUploadToken ?? ''
|
||||
} catch {
|
||||
apiToken.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBackendOptions() {
|
||||
try {
|
||||
const data = await $fetch<{ backends: Array<{ id: string, name: string }> }>(
|
||||
'/api/storage-backends',
|
||||
{ credentials: 'include' }
|
||||
)
|
||||
backendOptions.value = data.backends ?? []
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
if (!isAuthenticated.value) return
|
||||
try {
|
||||
stats.value = await $fetch<LibraryStatsData>('/api/library/stats', {
|
||||
credentials: 'include',
|
||||
query: { kind: 'video' }
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchList(targetPage = page.value) {
|
||||
if (!isAuthenticated.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await $fetch<FileListResponse>('/api/files', {
|
||||
credentials: 'include',
|
||||
query: {
|
||||
limit: pageSize.value,
|
||||
page: targetPage,
|
||||
kind: 'video',
|
||||
...(activeSearch.value ? { q: activeSearch.value } : {}),
|
||||
...(currentBackend.value !== 'all' ? { backendId: currentBackend.value } : {}),
|
||||
...(currentSource.value !== 'all' ? { uploadSource: currentSource.value } : {}),
|
||||
...(activeTagIds.value.length ? { tagIds: activeTagIds.value.join(',') } : {}),
|
||||
...(activeUntaggedOnly.value ? { untagged: '1' } : {}),
|
||||
...(activeTagMode.value === 'and' ? { tagMode: 'and' } : {})
|
||||
}
|
||||
})
|
||||
|
||||
items.value = response.items
|
||||
total.value = response.total
|
||||
page.value = response.page
|
||||
totalPages.value = response.totalPages
|
||||
accessMode.value = response.accessMode
|
||||
|
||||
const visible = new Set(response.items.map(item => item.key))
|
||||
selectedKeys.value = new Set(
|
||||
[...selectedKeys.value].filter(key => visible.has(key))
|
||||
)
|
||||
|
||||
if (response.accessMode === 'token' && !apiToken.value) {
|
||||
await loadApiToken()
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
if (isAuthenticated.value) {
|
||||
toast.add({ title: t('files.loadFailed'), color: 'error' })
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await Promise.all([fetchList(1), fetchStats(), fetchTags()])
|
||||
}
|
||||
|
||||
async function submitSearch() {
|
||||
if (!isAuthenticated.value) return
|
||||
activeSearch.value = searchQuery.value.trim()
|
||||
await fetchList(1)
|
||||
}
|
||||
|
||||
async function handleBackendChange(value: string) {
|
||||
currentBackend.value = value
|
||||
selectedKeys.value = new Set()
|
||||
await fetchList(1)
|
||||
}
|
||||
|
||||
async function handleSourceChange(value: string) {
|
||||
currentSource.value = value
|
||||
selectedKeys.value = new Set()
|
||||
await fetchList(1)
|
||||
}
|
||||
|
||||
async function handleTagIdsChange(tagIds: number[]) {
|
||||
activeTagIds.value = tagIds
|
||||
selectedKeys.value = new Set()
|
||||
await fetchList(1)
|
||||
}
|
||||
|
||||
async function handleTagModeChange(tagMode: 'or' | 'and') {
|
||||
activeTagMode.value = tagMode
|
||||
await fetchList(1)
|
||||
}
|
||||
|
||||
async function handleUntaggedChange(untaggedOnly: boolean) {
|
||||
activeUntaggedOnly.value = untaggedOnly
|
||||
selectedKeys.value = new Set()
|
||||
await fetchList(1)
|
||||
}
|
||||
|
||||
async function resetFilters() {
|
||||
searchQuery.value = ''
|
||||
activeSearch.value = ''
|
||||
currentBackend.value = 'all'
|
||||
currentSource.value = 'all'
|
||||
activeTagIds.value = []
|
||||
activeTagMode.value = 'or'
|
||||
activeUntaggedOnly.value = false
|
||||
selectedKeys.value = new Set()
|
||||
await fetchList(1)
|
||||
}
|
||||
|
||||
function toggleSelected(key: string, value: boolean) {
|
||||
const next = new Set(selectedKeys.value)
|
||||
if (value) next.add(key)
|
||||
else next.delete(key)
|
||||
selectedKeys.value = next
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
selectedKeys.value = allSelected.value
|
||||
? new Set()
|
||||
: new Set(items.value.map(item => item.key))
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedKeys.value = new Set()
|
||||
}
|
||||
|
||||
function setViewMode(mode: 'grid' | 'list') {
|
||||
viewMode.value = mode
|
||||
}
|
||||
|
||||
function openBatchTags() {
|
||||
batchTagOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmBatchTags(tagIds: number[], action: 'add' | 'remove') {
|
||||
const keys = [...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([fetchList(1), fetchStats(), fetchTags(true)])
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
toast.add({ title: t('tags.batchFailed'), color: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
function openPreview(item: FileItem) {
|
||||
previewItem.value = item
|
||||
previewOpen.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadBackendOptions()
|
||||
})
|
||||
|
||||
function requestDelete(item: FileItem) {
|
||||
if (!isAuthenticated.value) return
|
||||
deleteTargetKeys.value = [item.key]
|
||||
deleteModalOpen.value = true
|
||||
}
|
||||
|
||||
function requestBatchDelete() {
|
||||
if (!isAuthenticated.value) return
|
||||
if (!selectedCount.value) {
|
||||
toast.add({ title: t('library.selectFirst'), color: 'warning' })
|
||||
return
|
||||
}
|
||||
deleteTargetKeys.value = [...selectedKeys.value]
|
||||
deleteModalOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const keys = [...deleteTargetKeys.value]
|
||||
if (!keys.length || !isAuthenticated.value) return
|
||||
|
||||
deleting.value = true
|
||||
try {
|
||||
if (keys.length === 1) {
|
||||
await $fetch('/api/files', {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
query: { key: keys[0] }
|
||||
})
|
||||
} else {
|
||||
const response = await $fetch<{ deleted: string[], failed: unknown[] }>(
|
||||
'/api/files/batch-delete',
|
||||
{ method: 'POST', credentials: 'include', body: { keys } }
|
||||
)
|
||||
if (response.failed.length) {
|
||||
toast.add({
|
||||
title: t('library.deletePartialFail', { n: response.failed.length }),
|
||||
color: 'warning'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
toast.add({ title: t('library.deleted', { n: keys.length }), color: 'success' })
|
||||
deleteModalOpen.value = false
|
||||
deleteTargetKeys.value = []
|
||||
selectedKeys.value = new Set()
|
||||
await refresh()
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
if (isAuthenticated.value) {
|
||||
toast.add({ title: t('files.deleteFailed'), color: 'error' })
|
||||
}
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePageChange(targetPage: number) {
|
||||
selectedKeys.value = new Set()
|
||||
await fetchList(targetPage)
|
||||
}
|
||||
|
||||
async function handlePageSizeChange(size: number) {
|
||||
pageSize.value = size
|
||||
selectedKeys.value = new Set()
|
||||
await fetchList(1)
|
||||
}
|
||||
|
||||
defineExpose({ refresh })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">
|
||||
{{ t('library.video.title') }}
|
||||
</h1>
|
||||
<p class="text-sm text-muted">
|
||||
{{ t('library.video.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="loading"
|
||||
@click="refresh"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LibraryStatsPanel
|
||||
kind="video"
|
||||
:stats="stats"
|
||||
:loading="loading"
|
||||
/>
|
||||
|
||||
<section class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<UCheckbox
|
||||
:model-value="allSelected"
|
||||
:disabled="!items.length"
|
||||
@update:model-value="toggleSelectAll"
|
||||
/>
|
||||
<span class="text-muted">
|
||||
{{ t('library.selectedCount', { n: selectedCount }) }}
|
||||
</span>
|
||||
<UButton
|
||||
v-if="selectedCount > 0"
|
||||
variant="link"
|
||||
color="primary"
|
||||
size="xs"
|
||||
:label="t('library.clearSelection')"
|
||||
@click="clearSelection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UInput
|
||||
v-model="searchQuery"
|
||||
icon="i-lucide-search"
|
||||
:placeholder="t('library.searchPlaceholder')"
|
||||
size="sm"
|
||||
class="w-44"
|
||||
@keydown.enter.prevent="submitSearch"
|
||||
/>
|
||||
<USelect
|
||||
:model-value="currentBackend"
|
||||
:items="backendItems"
|
||||
value-key="value"
|
||||
size="sm"
|
||||
class="w-32"
|
||||
:aria-label="t('stats.filterStorageAll')"
|
||||
@update:model-value="handleBackendChange"
|
||||
/>
|
||||
<USelect
|
||||
:model-value="currentSource"
|
||||
:items="sourceItems"
|
||||
value-key="value"
|
||||
size="sm"
|
||||
class="w-28"
|
||||
:aria-label="t('stats.filterSourceAll')"
|
||||
@update:model-value="handleSourceChange"
|
||||
/>
|
||||
<TagFilterSelect
|
||||
:model-value="activeTagIds"
|
||||
:tag-mode="activeTagMode"
|
||||
:untagged-only="activeUntaggedOnly"
|
||||
:tags="tagItems"
|
||||
:disabled="loading"
|
||||
@update:model-value="handleTagIdsChange"
|
||||
@update:tag-mode="handleTagModeChange"
|
||||
@update:untagged-only="handleUntaggedChange"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-search"
|
||||
size="sm"
|
||||
:label="t('common.search')"
|
||||
:loading="loading"
|
||||
@click="submitSearch"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-rotate-ccw"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
:label="t('library.reset')"
|
||||
@click="resetFilters"
|
||||
/>
|
||||
<UButton
|
||||
v-if="selectedCount > 0"
|
||||
icon="i-lucide-tags"
|
||||
variant="outline"
|
||||
color="primary"
|
||||
size="sm"
|
||||
:disabled="!selectedCount || loading"
|
||||
:label="t('tags.batchAdd')"
|
||||
@click="openBatchTags"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
variant="outline"
|
||||
color="error"
|
||||
size="sm"
|
||||
:disabled="!selectedCount || loading"
|
||||
:label="t('library.batchDelete')"
|
||||
@click="requestBatchDelete"
|
||||
/>
|
||||
<div class="flex gap-0.5 rounded-lg border border-default p-0.5">
|
||||
<UButton
|
||||
icon="i-lucide-layout-grid"
|
||||
size="xs"
|
||||
:variant="viewMode === 'grid' ? 'solid' : 'ghost'"
|
||||
:color="viewMode === 'grid' ? 'primary' : 'neutral'"
|
||||
:aria-label="t('stats.viewGrid')"
|
||||
@click="setViewMode('grid')"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-list"
|
||||
size="xs"
|
||||
:variant="viewMode === 'list' ? 'solid' : 'ghost'"
|
||||
:color="viewMode === 'list' ? 'primary' : 'neutral'"
|
||||
:aria-label="t('stats.viewList')"
|
||||
@click="setViewMode('list')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading && !items.length"
|
||||
class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
>
|
||||
<USkeleton
|
||||
v-for="n in 8"
|
||||
:key="n"
|
||||
class="aspect-[4/3] rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-else-if="!items.length"
|
||||
class="rounded-lg border border-default py-10 text-center text-sm text-muted"
|
||||
>
|
||||
{{ activeSearch ? t('library.searchEmpty') : t('library.video.empty') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-else-if="viewMode === 'grid'"
|
||||
class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
>
|
||||
<VideoCard
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
:item="item"
|
||||
:link="videoLink(item)"
|
||||
:thumb-link="thumbLink(item)"
|
||||
:download-link="downloadUrl(item)"
|
||||
selectable
|
||||
:selected="selectedKeys.has(item.key)"
|
||||
@update:selected="toggleSelected(item.key, $event)"
|
||||
@play="openPreview(item)"
|
||||
@delete="requestDelete(item)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="overflow-hidden rounded-xl border border-default bg-default"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-xs">
|
||||
<thead class="border-b border-default bg-elevated/40 text-muted">
|
||||
<tr>
|
||||
<th class="w-10 px-3 py-2.5 text-left font-medium">
|
||||
<UCheckbox
|
||||
:model-value="allSelected"
|
||||
:aria-label="t('stats.selectAll')"
|
||||
@update:model-value="toggleSelectAll"
|
||||
/>
|
||||
</th>
|
||||
<th class="min-w-[14rem] px-3 py-2.5 text-left font-medium">
|
||||
{{ t('stats.colFilename') }}
|
||||
</th>
|
||||
<th class="hidden w-24 px-3 py-2.5 text-left font-medium sm:table-cell">
|
||||
{{ t('stats.colSource') }}
|
||||
</th>
|
||||
<th class="w-20 px-3 py-2.5 text-left font-medium">
|
||||
{{ t('stats.colSize') }}
|
||||
</th>
|
||||
<th class="hidden w-28 px-3 py-2.5 text-left font-medium md:table-cell">
|
||||
{{ t('stats.colType') }}
|
||||
</th>
|
||||
<th class="hidden w-40 px-3 py-2.5 text-left font-medium lg:table-cell">
|
||||
{{ t('stats.colUploadedAt') }}
|
||||
</th>
|
||||
<th class="hidden min-w-[9rem] px-3 py-2.5 text-left font-medium md:table-cell">
|
||||
{{ t('tags.column') }}
|
||||
</th>
|
||||
<th class="w-32 px-3 py-2.5 text-right font-medium">
|
||||
{{ t('stats.colActions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-default">
|
||||
<tr
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
class="transition-colors hover:bg-elevated/50"
|
||||
:class="selectedKeys.has(item.key) ? 'bg-primary/5' : ''"
|
||||
>
|
||||
<td class="px-3 py-2">
|
||||
<UCheckbox
|
||||
:model-value="selectedKeys.has(item.key)"
|
||||
@update:model-value="toggleSelected(item.key, $event === true)"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-3 py-2.5">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="relative aspect-video w-12 shrink-0 overflow-hidden rounded-md border border-default bg-muted"
|
||||
:aria-label="t('library.play')"
|
||||
@click="openPreview(item)"
|
||||
>
|
||||
<img
|
||||
v-if="item.thumbUrl"
|
||||
:src="thumbLink(item)"
|
||||
:alt="displayName(item)"
|
||||
loading="lazy"
|
||||
class="size-full object-cover"
|
||||
>
|
||||
<UIcon
|
||||
v-else
|
||||
name="i-lucide-film"
|
||||
class="size-5 text-dimmed"
|
||||
/>
|
||||
</button>
|
||||
<p
|
||||
class="min-w-0 flex-1 truncate text-highlighted"
|
||||
:title="displayName(item)"
|
||||
>
|
||||
{{ displayName(item) }}
|
||||
</p>
|
||||
<UBadge
|
||||
v-if="item.hlsUrl"
|
||||
color="success"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
class="shrink-0"
|
||||
>
|
||||
m3u8
|
||||
</UBadge>
|
||||
<UBadge
|
||||
v-else-if="item.transcodeStatus === 'failed'"
|
||||
color="error"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
class="shrink-0"
|
||||
>
|
||||
{{ t('library.transcodeFailed') }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</td>
|
||||
<td class="hidden px-3 py-2 sm:table-cell">
|
||||
<span
|
||||
v-if="item.uploadSource"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-1.5 py-0.5 text-[11px] leading-4 text-muted"
|
||||
>
|
||||
<UIcon
|
||||
:name="item.uploadSource === 'api' ? 'i-lucide-code-xml' : 'i-lucide-globe'"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
{{ item.uploadSource === 'api' ? t('stats.tagApi') : t('stats.tagWeb') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted"
|
||||
>—</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 tabular-nums text-muted">
|
||||
{{ formatFileSize(item.size) }}
|
||||
</td>
|
||||
<td class="hidden px-3 py-2 md:table-cell">
|
||||
<span
|
||||
v-if="item.storage"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-1.5 py-0.5 text-[11px] leading-4 text-muted"
|
||||
>
|
||||
<UIcon
|
||||
:name="item.storage.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
{{ item.storage.name }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted"
|
||||
>—</span>
|
||||
</td>
|
||||
<td class="hidden px-3 py-2 text-muted lg:table-cell">
|
||||
{{ uploadedLabel(item) }}
|
||||
</td>
|
||||
<td class="hidden px-3 py-2 md:table-cell">
|
||||
<div
|
||||
v-if="item.tags?.length"
|
||||
class="flex flex-wrap gap-1"
|
||||
>
|
||||
<TagBadge
|
||||
v-for="tag in item.tags"
|
||||
:key="tag.id"
|
||||
:tag="tag"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted"
|
||||
>{{ t('tags.unlabeled') }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<CopyButton
|
||||
icon-only
|
||||
icon="i-lucide-link"
|
||||
:label="t('library.copyVideoLink')"
|
||||
:value="videoLink(item)"
|
||||
/>
|
||||
<CopyButton
|
||||
icon-only
|
||||
icon="i-lucide-image"
|
||||
:label="t('library.copyThumbLink')"
|
||||
:value="thumbLink(item)"
|
||||
/>
|
||||
<UButton
|
||||
v-if="!item.sourceDeleted"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
icon="i-lucide-download"
|
||||
:to="downloadUrl(item)"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
:aria-label="t('files.download')"
|
||||
/>
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="error"
|
||||
icon="i-lucide-trash-2"
|
||||
:aria-label="t('common.delete')"
|
||||
@click="requestDelete(item)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaginationBar
|
||||
v-if="!loading && total > 0"
|
||||
:page="page"
|
||||
:total-pages="totalPages"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
unit=""
|
||||
:loading="loading"
|
||||
@update:page="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<DeleteConfirmModal
|
||||
v-model:open="deleteModalOpen"
|
||||
:count="deleteTargetKeys.length"
|
||||
:loading="deleting"
|
||||
kind="video"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
|
||||
<BatchTagModal
|
||||
v-model:open="batchTagOpen"
|
||||
:tags="tagItems"
|
||||
:selected-count="selectedCount"
|
||||
@confirm="confirmBatchTags"
|
||||
/>
|
||||
|
||||
<VideoPreviewModal
|
||||
v-model:open="previewOpen"
|
||||
:item="previewItem"
|
||||
:link="previewItem ? videoLink(previewItem) : ''"
|
||||
:thumb-link="previewItem ? thumbLink(previewItem) : ''"
|
||||
:download-link="previewItem ? downloadUrl(previewItem) : ''"
|
||||
:deleting="deleting"
|
||||
@delete="previewItem && requestDelete(previewItem)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,522 @@
|
||||
<script setup lang="ts">
|
||||
import type { FileItem } from '~/types/file'
|
||||
import type { CopyFormat } from '~/composables/useUploadPreferences'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
open: boolean
|
||||
item: FileItem | null
|
||||
/** 已按访问模式拼接好的视频直链 */
|
||||
link: string
|
||||
/** 缩略图直链 */
|
||||
thumbLink: string
|
||||
/** 原始视频下载地址 */
|
||||
downloadLink: string
|
||||
deleting?: boolean
|
||||
allowDelete?: boolean
|
||||
}>(), {
|
||||
allowDelete: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'delete': []
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
const { formatFileSize } = useFileSize()
|
||||
const { t, locale } = useI18n()
|
||||
const { copyFormat } = useUploadPreferences('video')
|
||||
|
||||
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||
const copyingUrl = ref(false)
|
||||
const embedFormat = ref<CopyFormat>('url')
|
||||
|
||||
let hlsInstance: { destroy: () => void } | null = null
|
||||
|
||||
const embedFormatItems = computed(() => [
|
||||
{ label: t('copy.url'), value: 'url' as const },
|
||||
{ label: t('copy.markdown'), value: 'markdown' as const },
|
||||
{ label: t('copy.html'), value: 'html' as const },
|
||||
{ label: t('copy.bbcode'), value: 'bbcode' as const }
|
||||
])
|
||||
|
||||
/** 转码完成后按 HLS 播放列表展示 */
|
||||
const displayName = computed(() => {
|
||||
const item = props.item
|
||||
if (!item) return '—'
|
||||
if (!item.hlsUrl) return item.originalName
|
||||
const dot = item.originalName.lastIndexOf('.')
|
||||
const base = dot > 0 ? item.originalName.slice(0, dot) : item.originalName
|
||||
return `${base}.m3u8`
|
||||
})
|
||||
|
||||
const uploadedLabel = computed(() => {
|
||||
const item = props.item
|
||||
if (!item) return '—'
|
||||
try {
|
||||
return new Date(item.uploadedAt).toLocaleString(locale.value, { hour12: false })
|
||||
} catch {
|
||||
return item.uploadedAt
|
||||
}
|
||||
})
|
||||
|
||||
const sizeLabel = computed(() => {
|
||||
const item = props.item
|
||||
if (!item) return '—'
|
||||
return t('image.previewSizeDetail', {
|
||||
size: formatFileSize(item.size),
|
||||
bytes: item.size.toLocaleString(locale.value)
|
||||
})
|
||||
})
|
||||
|
||||
const transcodeLabel = computed(() => {
|
||||
const item = props.item
|
||||
if (!item) return '—'
|
||||
switch (item.transcodeStatus) {
|
||||
case 'pending':
|
||||
return t('library.transcodePending')
|
||||
case 'running':
|
||||
return `${t('library.transcoding')} ${item.transcodeProgress ?? 0}%`
|
||||
case 'done':
|
||||
return t('library.video.transcodeDone')
|
||||
case 'failed':
|
||||
return t('library.transcodeFailed')
|
||||
default:
|
||||
return t('library.video.transcodeNone')
|
||||
}
|
||||
})
|
||||
|
||||
/** 转码完成后条目实际代表 HLS,路径与类型都按播放列表展示 */
|
||||
const storagePath = computed(() => props.item?.hlsKey ?? props.item?.key ?? '—')
|
||||
const contentTypeLabel = computed(() =>
|
||||
props.item?.hlsKey ? 'application/vnd.apple.mpegurl' : props.item?.contentType ?? '—'
|
||||
)
|
||||
|
||||
const previewValue = computed(() => {
|
||||
if (!props.item) return ''
|
||||
switch (embedFormat.value) {
|
||||
case 'markdown':
|
||||
return `[${displayName.value}](${props.link})`
|
||||
case 'html':
|
||||
return `<a href="${props.link}">${displayName.value}</a>`
|
||||
case 'bbcode':
|
||||
return `[url=${props.link}]${displayName.value}[/url]`
|
||||
default:
|
||||
return props.link
|
||||
}
|
||||
})
|
||||
|
||||
const copySuccessTitle = computed(() => {
|
||||
switch (embedFormat.value) {
|
||||
case 'markdown':
|
||||
return t('copy.copiedMarkdown')
|
||||
case 'html':
|
||||
return t('copy.copiedHtml')
|
||||
case 'bbcode':
|
||||
return t('copy.copiedBbcode')
|
||||
default:
|
||||
return t('copy.copiedUrl')
|
||||
}
|
||||
})
|
||||
|
||||
const storageIcon = computed(() =>
|
||||
props.item?.storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
|
||||
)
|
||||
|
||||
const uploadSourceTag = computed(() => {
|
||||
switch (props.item?.uploadSource) {
|
||||
case 'web':
|
||||
return t('stats.tagWeb')
|
||||
case 'api':
|
||||
return t('stats.tagApi')
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const uploadSourceIcon = computed(() => {
|
||||
switch (props.item?.uploadSource) {
|
||||
case 'web':
|
||||
return 'i-lucide-globe'
|
||||
case 'api':
|
||||
return 'i-lucide-code-xml'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
function destroyPlayer() {
|
||||
if (hlsInstance) {
|
||||
hlsInstance.destroy()
|
||||
hlsInstance = null
|
||||
}
|
||||
if (videoEl.value) {
|
||||
videoEl.value.removeAttribute('src')
|
||||
videoEl.value.load()
|
||||
}
|
||||
}
|
||||
|
||||
async function attachPlayerSource() {
|
||||
const item = props.item
|
||||
const element = videoEl.value
|
||||
if (!item || !element) return
|
||||
|
||||
element.src = props.link
|
||||
if (!item.hlsUrl || element.canPlayType('application/vnd.apple.mpegurl')) return
|
||||
|
||||
const { default: Hls } = await import('hls.js')
|
||||
if (!Hls.isSupported()) return
|
||||
|
||||
const hls = new Hls()
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (!data.fatal) return
|
||||
// 切片缺失(例如从备份恢复)时回退播放原视频
|
||||
hls.destroy()
|
||||
hlsInstance = null
|
||||
element.src = props.downloadLink
|
||||
})
|
||||
hls.loadSource(props.link)
|
||||
hls.attachMedia(element)
|
||||
hlsInstance = hls
|
||||
}
|
||||
|
||||
watch(() => props.open, async (open) => {
|
||||
if (!open) {
|
||||
destroyPlayer()
|
||||
return
|
||||
}
|
||||
embedFormat.value = copyFormat.value
|
||||
await nextTick()
|
||||
await attachPlayerSource()
|
||||
})
|
||||
|
||||
onUnmounted(destroyPlayer)
|
||||
|
||||
function selectEmbedFormat(value: CopyFormat) {
|
||||
embedFormat.value = value
|
||||
copyFormat.value = value
|
||||
}
|
||||
|
||||
async function copyText(text: string, successTitle: string) {
|
||||
if (!text) return
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toast.add({ title: successTitle, color: 'success' })
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// 回退方案见下
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.left = '-9999px'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
let ok = false
|
||||
try {
|
||||
ok = document.execCommand('copy')
|
||||
} catch {
|
||||
ok = false
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
|
||||
toast.add({
|
||||
title: ok ? successTitle : t('copy.failedManual'),
|
||||
color: ok ? 'success' : 'error'
|
||||
})
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
if (!props.link || copyingUrl.value) return
|
||||
copyingUrl.value = true
|
||||
try {
|
||||
await copyText(props.link, t('copy.copiedUrl'))
|
||||
} finally {
|
||||
copyingUrl.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openInNewTab() {
|
||||
if (!props.link) return
|
||||
window.open(props.link, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function downloadOriginal() {
|
||||
if (!props.downloadLink) return
|
||||
const link = document.createElement('a')
|
||||
link.href = props.downloadLink
|
||||
link.download = props.item?.originalName || 'video'
|
||||
link.rel = 'noopener'
|
||||
link.click()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
:open="open && !!item"
|
||||
:title="t('library.video.previewTitle')"
|
||||
:description="t('library.video.previewSubtitle')"
|
||||
:ui="{ content: 'max-w-5xl' }"
|
||||
@update:open="emit('update:open', $event)"
|
||||
>
|
||||
<template
|
||||
v-if="item"
|
||||
#body
|
||||
>
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] lg:items-start">
|
||||
<div class="flex items-center justify-center overflow-hidden rounded-xl border border-default bg-black p-2">
|
||||
<video
|
||||
ref="videoEl"
|
||||
controls
|
||||
autoplay
|
||||
playsinline
|
||||
class="max-h-[min(60vh,24rem)] w-full rounded-lg bg-black"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-5">
|
||||
<dl class="divide-y divide-default text-sm">
|
||||
<div class="flex gap-4 py-2.5 first:pt-0">
|
||||
<dt class="w-20 shrink-0 text-muted">
|
||||
{{ t('image.previewOriginalName') }}
|
||||
</dt>
|
||||
<dd class="min-w-0 flex-1 break-all font-medium">
|
||||
{{ displayName }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex gap-4 py-2.5">
|
||||
<dt class="w-20 shrink-0 text-muted">
|
||||
{{ t('image.previewUploadedAt') }}
|
||||
</dt>
|
||||
<dd class="min-w-0 flex-1">
|
||||
{{ uploadedLabel }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex gap-4 py-2.5">
|
||||
<dt class="w-20 shrink-0 text-muted">
|
||||
{{ t('image.previewSize') }}
|
||||
</dt>
|
||||
<dd class="min-w-0 flex-1">
|
||||
{{ sizeLabel }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex gap-4 py-2.5">
|
||||
<dt class="w-20 shrink-0 text-muted">
|
||||
{{ t('image.previewContentType') }}
|
||||
</dt>
|
||||
<dd class="min-w-0 flex-1">
|
||||
{{ contentTypeLabel }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex gap-4 py-2.5">
|
||||
<dt class="w-20 shrink-0 text-muted">
|
||||
{{ t('library.video.previewTranscode') }}
|
||||
</dt>
|
||||
<dd class="min-w-0 flex-1">
|
||||
<UBadge
|
||||
:color="item.hlsUrl ? 'success' : item.transcodeStatus === 'failed' ? 'error' : 'neutral'"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ transcodeLabel }}
|
||||
</UBadge>
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.storage"
|
||||
class="flex gap-4 py-2.5"
|
||||
>
|
||||
<dt class="w-20 shrink-0 text-muted">
|
||||
{{ t('image.previewStorage') }}
|
||||
</dt>
|
||||
<dd class="min-w-0 flex-1">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<UIcon
|
||||
:name="storageIcon"
|
||||
class="size-3.5 shrink-0 text-muted"
|
||||
/>
|
||||
{{ item.storage.name }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
v-if="uploadSourceTag"
|
||||
class="flex gap-4 py-2.5"
|
||||
>
|
||||
<dt class="w-20 shrink-0 text-muted">
|
||||
{{ t('image.previewUploadSource') }}
|
||||
</dt>
|
||||
<dd class="min-w-0 flex-1">
|
||||
<span
|
||||
v-if="uploadSourceIcon"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-0.5 text-xs text-muted"
|
||||
>
|
||||
<UIcon
|
||||
:name="uploadSourceIcon"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
{{ uploadSourceTag }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.owner"
|
||||
class="flex gap-4 py-2.5"
|
||||
>
|
||||
<dt class="w-20 shrink-0 text-muted">
|
||||
{{ t('image.previewOwner') }}
|
||||
</dt>
|
||||
<dd class="min-w-0 flex-1">
|
||||
{{ item.owner.username }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex gap-4 py-2.5">
|
||||
<dt class="w-20 shrink-0 text-muted">
|
||||
{{ t('tags.column') }}
|
||||
</dt>
|
||||
<dd class="min-w-0 flex-1">
|
||||
<div
|
||||
v-if="item.tags?.length"
|
||||
class="flex flex-wrap gap-1.5"
|
||||
>
|
||||
<TagBadge
|
||||
v-for="tag in item.tags"
|
||||
:key="tag.id"
|
||||
:tag="tag"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex rounded-md border border-dashed border-default px-2 py-0.5 text-xs text-muted"
|
||||
>
|
||||
{{ t('tags.unlabeled') }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex gap-4 py-2.5 last:pb-0">
|
||||
<dt class="w-20 shrink-0 text-muted">
|
||||
{{ t('library.video.previewKey') }}
|
||||
</dt>
|
||||
<dd class="flex min-w-0 flex-1 items-start gap-2">
|
||||
<span class="min-w-0 flex-1 break-all font-mono text-xs text-dimmed">
|
||||
{{ storagePath }}
|
||||
</span>
|
||||
<CopyButton
|
||||
icon="i-lucide-copy"
|
||||
icon-only
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
class="shrink-0"
|
||||
:label="t('common.copy')"
|
||||
:value="storagePath"
|
||||
:success-title="t('copy.copied')"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="border-t border-default pt-4">
|
||||
<p class="mb-2.5 text-sm font-medium">
|
||||
{{ t('image.previewLinks') }}
|
||||
</p>
|
||||
<div class="flex gap-1 rounded-lg border border-default bg-default p-0.5">
|
||||
<UButton
|
||||
v-for="option in embedFormatItems"
|
||||
:key="option.value"
|
||||
size="xs"
|
||||
class="flex-1 justify-center"
|
||||
:variant="embedFormat === option.value ? 'solid' : 'ghost'"
|
||||
:color="embedFormat === option.value ? 'primary' : 'neutral'"
|
||||
:label="option.label"
|
||||
@click="selectEmbedFormat(option.value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<UInput
|
||||
:model-value="previewValue"
|
||||
readonly
|
||||
size="sm"
|
||||
class="min-w-0 flex-1 font-mono text-xs"
|
||||
/>
|
||||
<CopyButton
|
||||
:label="t('common.copy')"
|
||||
:value="previewValue"
|
||||
:success-title="copySuccessTitle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="thumbLink"
|
||||
class="mt-3"
|
||||
>
|
||||
<p class="mb-1.5 text-xs text-muted">
|
||||
{{ t('library.video.previewThumbLink') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<UInput
|
||||
:model-value="thumbLink"
|
||||
readonly
|
||||
size="sm"
|
||||
class="min-w-0 flex-1 font-mono text-xs"
|
||||
/>
|
||||
<CopyButton
|
||||
:label="t('common.copy')"
|
||||
:value="thumbLink"
|
||||
:success-title="t('copy.copiedUrl')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-if="item"
|
||||
#footer
|
||||
>
|
||||
<div class="flex w-full flex-wrap justify-end gap-1.5">
|
||||
<UButton
|
||||
:label="t('image.previewOpen')"
|
||||
icon="i-lucide-external-link"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="openInNewTab"
|
||||
/>
|
||||
<UButton
|
||||
v-if="!item.sourceDeleted"
|
||||
:label="t('image.previewDownload')"
|
||||
icon="i-lucide-download"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="downloadOriginal"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('image.previewCopyLink')"
|
||||
icon="i-lucide-link"
|
||||
color="primary"
|
||||
size="sm"
|
||||
:loading="copyingUrl"
|
||||
@click="copyLink"
|
||||
/>
|
||||
<UButton
|
||||
v-if="allowDelete"
|
||||
:label="t('common.delete')"
|
||||
icon="i-lucide-trash-2"
|
||||
color="error"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:loading="deleting"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -7,7 +7,8 @@ defineProps<{
|
||||
checking: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
// 「检查更新」已停用(改动较大,与上游版本号已不对应),暂时保留事件定义
|
||||
defineEmits<{
|
||||
checkUpdate: []
|
||||
}>()
|
||||
|
||||
@@ -28,6 +29,7 @@ const { t } = useI18n()
|
||||
<span class="text-sm font-medium tabular-nums text-highlighted">
|
||||
v{{ appVersion }}
|
||||
</span>
|
||||
<!-- 检查更新功能已停用,需要时恢复下面这段
|
||||
<UBadge
|
||||
v-if="updateAvailable && latestVersion"
|
||||
color="warning"
|
||||
@@ -63,6 +65,7 @@ const { t } = useI18n()
|
||||
>
|
||||
{{ t('settings.viewReleases') }}
|
||||
</a>
|
||||
-->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
const STORAGE_KEY = 'pichost.library-view'
|
||||
export type LibraryViewKind = 'image' | 'video' | 'file'
|
||||
|
||||
export type LibraryViewMode = 'grid' | 'list'
|
||||
|
||||
function storageKeyFor(kind: LibraryViewKind): string {
|
||||
return `${STORAGE_KEY}.${kind}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 图库 / 视频库 / 文件库各自的显示方式。
|
||||
* 三个库分别记住(互不影响),且页面切换后保持,不用重新点。
|
||||
*/
|
||||
export function useLibraryView(kind: LibraryViewKind = 'image') {
|
||||
const viewMode = useState<LibraryViewMode>(`library-view-mode-${kind}`, () => 'grid')
|
||||
const loaded = useState(`library-view-loaded-${kind}`, () => false)
|
||||
|
||||
if (import.meta.client && !loaded.value) {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKeyFor(kind))
|
||||
if (stored === 'grid' || stored === 'list') {
|
||||
viewMode.value = stored
|
||||
}
|
||||
} catch {
|
||||
// 读取失败保持默认
|
||||
}
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
watch(viewMode, (value) => {
|
||||
if (!import.meta.client) return
|
||||
try {
|
||||
localStorage.setItem(storageKeyFor(kind), value)
|
||||
} catch {
|
||||
// 写入失败不影响使用
|
||||
}
|
||||
})
|
||||
|
||||
return { viewMode }
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import type { UploadProgressItem } from '~/types/image'
|
||||
import type { FileItem, FileListResponse, FileUploadResponse } from '~/types/file'
|
||||
import type { CopyFormat, UploadMediaKind } from './useUploadPreferences'
|
||||
import { isUnauthorizedError } from './useAuth'
|
||||
|
||||
const MAX_FILES = 10
|
||||
|
||||
function buildCopyText(item: FileItem, format: CopyFormat): string {
|
||||
switch (format) {
|
||||
case 'url':
|
||||
return item.url
|
||||
case 'html':
|
||||
return `<a href="${item.url}">${item.originalName}</a>`
|
||||
case 'bbcode':
|
||||
return `[url=${item.url}]${item.originalName}[/url]`
|
||||
default:
|
||||
return `[${item.originalName}](${item.url})`
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// 内网 http:// 等非安全上下文继续走回退方案
|
||||
}
|
||||
|
||||
try {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.left = '-9999px'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
const ok = document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
return ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 视频床 / 文件床的上传:走文件床接口,偏好按类型独立 */
|
||||
export function useMediaUpload(kind: UploadMediaKind) {
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
autoCopyMarkdown,
|
||||
copyFormat,
|
||||
transcodeToHls,
|
||||
loadPreferences
|
||||
} = useUploadPreferences(kind)
|
||||
|
||||
const uploading = ref(false)
|
||||
const progressItems = ref<UploadProgressItem[]>([])
|
||||
const lastResult = ref<FileUploadResponse | null>(null)
|
||||
/** 本次会话内上传的条目,页面刷新后清空 */
|
||||
const sessionItems = ref<FileItem[]>([])
|
||||
const accessMode = ref<'public' | 'token'>('public')
|
||||
const apiToken = ref('')
|
||||
|
||||
const endpoint = kind === 'video' ? '/api/videos/upload' : '/api/files/upload'
|
||||
|
||||
loadPreferences()
|
||||
|
||||
async function loadApiToken() {
|
||||
try {
|
||||
const data = await $fetch<{ apiUploadToken: string }>('/api/user/api-token', {
|
||||
credentials: 'include'
|
||||
})
|
||||
apiToken.value = data.apiUploadToken ?? ''
|
||||
} catch {
|
||||
apiToken.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Token 模式下复制出的链接需要带上令牌 */
|
||||
function linkUrl(item: FileItem): string {
|
||||
// 转码完成后优先给出 m3u8 播放地址
|
||||
const base = item.hlsUrl ?? item.url
|
||||
if (accessMode.value !== 'token' || !apiToken.value) return base
|
||||
const separator = base.includes('?') ? '&' : '?'
|
||||
return `${base}${separator}token=${encodeURIComponent(apiToken.value)}`
|
||||
}
|
||||
|
||||
/** 封面缩略图地址(同样按访问模式拼接) */
|
||||
function thumbLink(item: FileItem): string {
|
||||
const base = item.thumbUrl
|
||||
if (!base) return ''
|
||||
if (accessMode.value !== 'token' || !apiToken.value) return base
|
||||
const separator = base.includes('?') ? '&' : '?'
|
||||
return `${base}${separator}token=${encodeURIComponent(apiToken.value)}`
|
||||
}
|
||||
|
||||
let statusTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function stopStatusPolling() {
|
||||
if (statusTimer) {
|
||||
clearInterval(statusTimer)
|
||||
statusTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function hasPendingTranscode(): boolean {
|
||||
return sessionItems.value.some(
|
||||
item => item.transcodeStatus === 'pending' || item.transcodeStatus === 'running'
|
||||
)
|
||||
}
|
||||
|
||||
/** 转码是异步的,轮询列表把状态与 m3u8 地址同步回来 */
|
||||
async function refreshTranscodeStatus() {
|
||||
if (!hasPendingTranscode()) {
|
||||
stopStatusPolling()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const [active, finished] = await Promise.all([
|
||||
$fetch<FileListResponse>('/api/files', {
|
||||
credentials: 'include',
|
||||
query: { kind: 'video', limit: 100, processing: '1' }
|
||||
}),
|
||||
$fetch<FileListResponse>('/api/files', {
|
||||
credentials: 'include',
|
||||
query: { kind: 'video', limit: 100 }
|
||||
})
|
||||
])
|
||||
const latest = new Map(
|
||||
[...active.items, ...finished.items].map(item => [item.key, item])
|
||||
)
|
||||
sessionItems.value = sessionItems.value.map(item => latest.get(item.key) ?? item)
|
||||
} catch {
|
||||
// 轮询失败保持现状,下个周期重试
|
||||
}
|
||||
|
||||
if (!hasPendingTranscode()) stopStatusPolling()
|
||||
}
|
||||
|
||||
function startStatusPolling() {
|
||||
if (kind !== 'video' || statusTimer) return
|
||||
statusTimer = setInterval(() => {
|
||||
void refreshTranscodeStatus()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
onUnmounted(stopStatusPolling)
|
||||
|
||||
/** 刷新或换页后仍要显示还在排队/转码中的视频 */
|
||||
async function loadActiveTranscodes() {
|
||||
if (kind !== 'video') return
|
||||
|
||||
try {
|
||||
const response = await $fetch<FileListResponse>('/api/files', {
|
||||
credentials: 'include',
|
||||
query: { kind: 'video', limit: 100, processing: '1' }
|
||||
})
|
||||
if (!response.items.length) return
|
||||
|
||||
const known = new Set(sessionItems.value.map(item => item.key))
|
||||
const missing = response.items.filter(item => !known.has(item.key))
|
||||
const latest = new Map(response.items.map(item => [item.key, item]))
|
||||
|
||||
sessionItems.value = [
|
||||
...missing,
|
||||
...sessionItems.value.map(item => latest.get(item.key) ?? item)
|
||||
]
|
||||
startStatusPolling()
|
||||
} catch {
|
||||
// 拉取失败时保持现状
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFiles(files: File[]) {
|
||||
if (!files.length || uploading.value) return
|
||||
|
||||
if (files.length > MAX_FILES) {
|
||||
toast.add({ title: t('files.maxFiles', { n: MAX_FILES }), color: 'warning' })
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
progressItems.value = files.map(file => ({
|
||||
name: file.name,
|
||||
status: 'pending',
|
||||
progress: 0
|
||||
}))
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
for (const file of files) {
|
||||
formData.append('files', file, file.name)
|
||||
}
|
||||
|
||||
if (kind === 'video' && transcodeToHls.value) {
|
||||
formData.append('transcode', '1')
|
||||
}
|
||||
|
||||
progressItems.value = progressItems.value.map(item => ({
|
||||
...item,
|
||||
status: 'uploading',
|
||||
progress: 50
|
||||
}))
|
||||
|
||||
const response = await $fetch<FileUploadResponse>(endpoint, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
lastResult.value = response
|
||||
accessMode.value = response.accessMode
|
||||
if (response.accessMode === 'token' && !apiToken.value) {
|
||||
await loadApiToken()
|
||||
}
|
||||
|
||||
progressItems.value = files.map((file) => {
|
||||
const failed = response.errors.find(error => error.name === file.name)
|
||||
return {
|
||||
name: file.name,
|
||||
status: failed ? 'error' : 'success',
|
||||
progress: 100,
|
||||
error: failed?.error.message
|
||||
}
|
||||
})
|
||||
|
||||
if (response.items.length) {
|
||||
const wantsTranscode = kind === 'video' && transcodeToHls.value
|
||||
const uploaded = wantsTranscode
|
||||
? response.items.map(item => ({ ...item, transcodeStatus: 'pending' as const }))
|
||||
: response.items
|
||||
|
||||
sessionItems.value = [...uploaded, ...sessionItems.value]
|
||||
if (wantsTranscode) startStatusPolling()
|
||||
|
||||
toast.add({
|
||||
title: t('files.uploadSuccess', { n: response.items.length }),
|
||||
color: 'success'
|
||||
})
|
||||
|
||||
const first = response.items[0]
|
||||
if (autoCopyMarkdown.value && first) {
|
||||
const text = buildCopyText(first, copyFormat.value)
|
||||
if (text && await copyToClipboard(text)) {
|
||||
toast.add({ title: t('copy.copied'), color: 'success' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const failure of response.errors.slice(0, 3)) {
|
||||
toast.add({
|
||||
title: `${failure.name}:${failure.error.message}`,
|
||||
color: 'error'
|
||||
})
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error: unknown) {
|
||||
if (!isUnauthorizedError(error)) {
|
||||
progressItems.value = progressItems.value.map(item => ({
|
||||
...item,
|
||||
status: 'error',
|
||||
progress: 100,
|
||||
error: t('files.uploadFailed')
|
||||
}))
|
||||
toast.add({ title: t('files.uploadFailed'), color: 'error' })
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uploading,
|
||||
progressItems,
|
||||
lastResult,
|
||||
sessionItems,
|
||||
accessMode,
|
||||
linkUrl,
|
||||
thumbLink,
|
||||
loadActiveTranscodes,
|
||||
uploadFiles
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
interface TranscodeSettingsResponse {
|
||||
concurrency: number
|
||||
threads: number
|
||||
preset: string
|
||||
bitratePercent: number
|
||||
maxConcurrency: number
|
||||
maxThreads: number
|
||||
minBitratePercent: number
|
||||
maxBitratePercent: number
|
||||
presets: string[]
|
||||
}
|
||||
|
||||
/** 服务端转码参数:并发任务数与单任务 CPU 核心数(仅管理员可改) */
|
||||
export function useTranscodeSettings() {
|
||||
const { isAuthenticated, isAdmin, handleAuthError } = useAuth()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const concurrency = ref(1)
|
||||
const threads = ref(1)
|
||||
const maxConcurrency = ref(8)
|
||||
const maxThreads = ref(64)
|
||||
const preset = ref('veryfast')
|
||||
const presets = ref<string[]>(['veryfast', 'medium', 'slow'])
|
||||
const bitratePercent = ref(80)
|
||||
const minBitratePercent = ref(10)
|
||||
const maxBitratePercent = ref(100)
|
||||
const loaded = ref(false)
|
||||
const hydrated = ref(false)
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function apply(data: TranscodeSettingsResponse) {
|
||||
hydrated.value = false
|
||||
concurrency.value = data.concurrency
|
||||
threads.value = data.threads
|
||||
preset.value = data.preset
|
||||
bitratePercent.value = data.bitratePercent
|
||||
minBitratePercent.value = data.minBitratePercent ?? 10
|
||||
maxBitratePercent.value = data.maxBitratePercent ?? 100
|
||||
if (data.presets?.length) presets.value = data.presets
|
||||
maxConcurrency.value = data.maxConcurrency
|
||||
maxThreads.value = data.maxThreads
|
||||
loaded.value = true
|
||||
nextTick(() => {
|
||||
hydrated.value = true
|
||||
})
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!isAuthenticated.value) return
|
||||
try {
|
||||
apply(await $fetch<TranscodeSettingsResponse>('/api/videos/transcode-settings', {
|
||||
credentials: 'include'
|
||||
}))
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
if (isAuthenticated.value) {
|
||||
toast.add({ title: t('preferences.transcodeLoadFailed'), color: 'error' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!hydrated.value || !isAdmin.value) return
|
||||
try {
|
||||
const data = await $fetch<TranscodeSettingsResponse>('/api/videos/transcode-settings', {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
body: {
|
||||
concurrency: concurrency.value,
|
||||
threads: threads.value,
|
||||
preset: preset.value,
|
||||
bitratePercent: bitratePercent.value
|
||||
}
|
||||
})
|
||||
hydrated.value = false
|
||||
concurrency.value = data.concurrency
|
||||
threads.value = data.threads
|
||||
preset.value = data.preset
|
||||
bitratePercent.value = data.bitratePercent
|
||||
nextTick(() => {
|
||||
hydrated.value = true
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
if (isAuthenticated.value) {
|
||||
toast.add({ title: t('preferences.transcodeSaveFailed'), color: 'error' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch([concurrency, threads, preset, bitratePercent], () => {
|
||||
if (!hydrated.value || !isAdmin.value) return
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => {
|
||||
void save()
|
||||
}, 600)
|
||||
})
|
||||
|
||||
return {
|
||||
concurrency,
|
||||
threads,
|
||||
preset,
|
||||
bitratePercent,
|
||||
minBitratePercent,
|
||||
maxBitratePercent,
|
||||
presets,
|
||||
maxConcurrency,
|
||||
maxThreads,
|
||||
loaded,
|
||||
load
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
const STORAGE_KEY = 'pichost.upload-preferences'
|
||||
|
||||
export type CopyFormat = 'url' | 'markdown' | 'html' | 'bbcode'
|
||||
export type UploadMediaKind = 'image' | 'video' | 'file'
|
||||
|
||||
/** 每种上传页各存一份偏好,互不影响 */
|
||||
function storageKeyFor(kind: UploadMediaKind): string {
|
||||
return kind === 'image' ? STORAGE_KEY : `${STORAGE_KEY}.${kind}`
|
||||
}
|
||||
|
||||
export interface UploadPreferences {
|
||||
compressEnabled: boolean
|
||||
@@ -8,6 +14,8 @@ export interface UploadPreferences {
|
||||
copyFormat: CopyFormat
|
||||
clientWebpQuality: number
|
||||
lastTagIds: number[]
|
||||
/** 视频床专属:上传后转码为 HLS(m3u8 + 分片) */
|
||||
transcodeToHls: boolean
|
||||
}
|
||||
|
||||
const DEFAULTS: UploadPreferences = {
|
||||
@@ -15,7 +23,8 @@ const DEFAULTS: UploadPreferences = {
|
||||
autoCopyMarkdown: false,
|
||||
copyFormat: 'markdown',
|
||||
clientWebpQuality: 80,
|
||||
lastTagIds: []
|
||||
lastTagIds: [],
|
||||
transcodeToHls: false
|
||||
}
|
||||
|
||||
function clampQuality(value: unknown): number {
|
||||
@@ -28,11 +37,11 @@ function isCopyFormat(value: unknown): value is CopyFormat {
|
||||
return value === 'url' || value === 'markdown' || value === 'html' || value === 'bbcode'
|
||||
}
|
||||
|
||||
function readStored(): UploadPreferences {
|
||||
function readStored(kind: UploadMediaKind): UploadPreferences {
|
||||
if (import.meta.server) return { ...DEFAULTS }
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
const raw = localStorage.getItem(storageKeyFor(kind))
|
||||
if (!raw) return { ...DEFAULTS }
|
||||
const parsed = JSON.parse(raw) as Partial<UploadPreferences>
|
||||
return {
|
||||
@@ -42,33 +51,36 @@ function readStored(): UploadPreferences {
|
||||
clientWebpQuality: clampQuality(parsed.clientWebpQuality),
|
||||
lastTagIds: Array.isArray(parsed.lastTagIds)
|
||||
? parsed.lastTagIds.filter(id => Number.isInteger(id) && id > 0)
|
||||
: DEFAULTS.lastTagIds
|
||||
: DEFAULTS.lastTagIds,
|
||||
transcodeToHls: parsed.transcodeToHls === true
|
||||
}
|
||||
} catch {
|
||||
return { ...DEFAULTS }
|
||||
}
|
||||
}
|
||||
|
||||
function writeStored(prefs: UploadPreferences) {
|
||||
function writeStored(kind: UploadMediaKind, prefs: UploadPreferences) {
|
||||
if (import.meta.server) return
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs))
|
||||
localStorage.setItem(storageKeyFor(kind), JSON.stringify(prefs))
|
||||
}
|
||||
|
||||
export function useUploadPreferences() {
|
||||
const compressEnabled = useState('upload-pref-compress', () => DEFAULTS.compressEnabled)
|
||||
const autoCopyMarkdown = useState('upload-pref-auto-md', () => DEFAULTS.autoCopyMarkdown)
|
||||
const copyFormat = useState<CopyFormat>('upload-pref-copy-format', () => DEFAULTS.copyFormat)
|
||||
const clientWebpQuality = useState('upload-pref-webp-quality', () => DEFAULTS.clientWebpQuality)
|
||||
const lastTagIds = useState<number[]>('upload-pref-last-tag-ids', () => DEFAULTS.lastTagIds)
|
||||
const loaded = useState('upload-pref-loaded', () => false)
|
||||
export function useUploadPreferences(kind: UploadMediaKind = 'image') {
|
||||
const compressEnabled = useState(`upload-pref-compress-${kind}`, () => DEFAULTS.compressEnabled)
|
||||
const autoCopyMarkdown = useState(`upload-pref-auto-md-${kind}`, () => DEFAULTS.autoCopyMarkdown)
|
||||
const copyFormat = useState<CopyFormat>(`upload-pref-copy-format-${kind}`, () => DEFAULTS.copyFormat)
|
||||
const clientWebpQuality = useState(`upload-pref-webp-quality-${kind}`, () => DEFAULTS.clientWebpQuality)
|
||||
const lastTagIds = useState<number[]>(`upload-pref-last-tag-ids-${kind}`, () => DEFAULTS.lastTagIds)
|
||||
const transcodeToHls = useState(`upload-pref-transcode-${kind}`, () => DEFAULTS.transcodeToHls)
|
||||
const loaded = useState(`upload-pref-loaded-${kind}`, () => false)
|
||||
|
||||
function loadPreferences() {
|
||||
const stored = readStored()
|
||||
const stored = readStored(kind)
|
||||
compressEnabled.value = stored.compressEnabled
|
||||
autoCopyMarkdown.value = stored.autoCopyMarkdown
|
||||
copyFormat.value = stored.copyFormat
|
||||
clientWebpQuality.value = stored.clientWebpQuality
|
||||
lastTagIds.value = stored.lastTagIds
|
||||
transcodeToHls.value = stored.transcodeToHls
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
@@ -77,12 +89,13 @@ export function useUploadPreferences() {
|
||||
}
|
||||
|
||||
function savePreferences() {
|
||||
writeStored({
|
||||
writeStored(kind, {
|
||||
compressEnabled: compressEnabled.value,
|
||||
autoCopyMarkdown: autoCopyMarkdown.value,
|
||||
copyFormat: copyFormat.value,
|
||||
clientWebpQuality: clampQuality(clientWebpQuality.value),
|
||||
lastTagIds: lastTagIds.value
|
||||
lastTagIds: lastTagIds.value,
|
||||
transcodeToHls: transcodeToHls.value
|
||||
})
|
||||
}
|
||||
|
||||
@@ -91,21 +104,27 @@ export function useUploadPreferences() {
|
||||
autoCopyMarkdown.value = DEFAULTS.autoCopyMarkdown
|
||||
copyFormat.value = DEFAULTS.copyFormat
|
||||
clientWebpQuality.value = DEFAULTS.clientWebpQuality
|
||||
transcodeToHls.value = DEFAULTS.transcodeToHls
|
||||
savePreferences()
|
||||
}
|
||||
|
||||
watch([compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality, lastTagIds], () => {
|
||||
if (loaded.value) {
|
||||
savePreferences()
|
||||
watch(
|
||||
[compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality, lastTagIds, transcodeToHls],
|
||||
() => {
|
||||
if (loaded.value) {
|
||||
savePreferences()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
kind,
|
||||
compressEnabled,
|
||||
autoCopyMarkdown,
|
||||
copyFormat,
|
||||
clientWebpQuality,
|
||||
lastTagIds,
|
||||
transcodeToHls,
|
||||
loaded,
|
||||
loadPreferences,
|
||||
savePreferences,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import FileLibraryPanel from '~/components/FileLibraryPanel.vue'
|
||||
|
||||
const { isChecking, isAuthenticated, checkSession, fetchStatus } = useAuth()
|
||||
const { t } = useI18n()
|
||||
|
||||
const panelRef = ref<{ refresh: () => Promise<void> } | null>(null)
|
||||
|
||||
async function loadPanel() {
|
||||
if (isAuthenticated.value) {
|
||||
await panelRef.value?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const status = await fetchStatus()
|
||||
if (!status.initialized && !status.legacyMode) {
|
||||
await navigateTo('/setup')
|
||||
return
|
||||
}
|
||||
await checkSession()
|
||||
await loadPanel()
|
||||
})
|
||||
|
||||
watch(isAuthenticated, async (authed, prev) => {
|
||||
if (authed && prev === false) {
|
||||
await nextTick()
|
||||
await loadPanel()
|
||||
}
|
||||
})
|
||||
</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>
|
||||
<FileLibraryPanel
|
||||
ref="panelRef"
|
||||
kind="file"
|
||||
/>
|
||||
</AppShell>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
const { isChecking, isAuthenticated, checkSession, fetchStatus } = useAuth()
|
||||
const { t } = useI18n()
|
||||
|
||||
onMounted(async () => {
|
||||
const status = await fetchStatus()
|
||||
if (!status.initialized && !status.legacyMode) {
|
||||
await navigateTo('/setup')
|
||||
return
|
||||
}
|
||||
await checkSession()
|
||||
})
|
||||
</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>
|
||||
<MediaUploadPanel kind="file" />
|
||||
</AppShell>
|
||||
</div>
|
||||
</template>
|
||||
@@ -51,8 +51,7 @@ const {
|
||||
activeTagMode,
|
||||
activeUntaggedOnly,
|
||||
setActiveTagFilter,
|
||||
toggleTagFilter,
|
||||
listQueryParams
|
||||
toggleTagFilter
|
||||
} = useImageList()
|
||||
|
||||
const { items: tagItems, fetchTags, batchUpdateTags } = useTags()
|
||||
@@ -73,7 +72,7 @@ const batchDeleting = ref(false)
|
||||
const showScrollTop = ref(false)
|
||||
const previewOpen = ref(false)
|
||||
const previewImage = ref<ImageItem | null>(null)
|
||||
const viewMode = ref<'grid' | 'list'>('grid')
|
||||
const { viewMode } = useLibraryView('image')
|
||||
const batchTagOpen = ref(false)
|
||||
|
||||
const currentUploadSource = ref('all')
|
||||
@@ -104,9 +103,9 @@ function scrollToTop() {
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
stats.value = await $fetch<StatsResponse>('/api/stats', {
|
||||
stats.value = await $fetch<StatsResponse>('/api/library/stats', {
|
||||
credentials: 'include',
|
||||
query: listQueryParams()
|
||||
query: { kind: 'image' }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -466,7 +465,8 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GalleryStatsOverview
|
||||
<LibraryStatsPanel
|
||||
kind="image"
|
||||
:stats="stats"
|
||||
:loading="statsLoading"
|
||||
/>
|
||||
|
||||
@@ -61,6 +61,7 @@ interface SettingsResponse {
|
||||
loginRateWindowMinutesSource: PolicySource
|
||||
preserveOriginalUpload: boolean
|
||||
preserveOriginalUploadSource: PolicySource
|
||||
fileBedAccess: 'public' | 'token'
|
||||
mimeTypeOptions: ReadonlyArray<{ mime: MimeType, label: string }>
|
||||
}
|
||||
|
||||
@@ -171,6 +172,20 @@ const uploadRateWindowMinutesDraft = ref(15)
|
||||
const loginRateMaxDraft = ref(10)
|
||||
const loginRateWindowMinutesDraft = ref(15)
|
||||
const preserveOriginalUploadDraft = ref(false)
|
||||
const fileBedAccessDraft = ref<'public' | 'token'>('public')
|
||||
|
||||
const fileBedAccessOptions = computed(() => [
|
||||
{
|
||||
value: 'public',
|
||||
label: t('settings.fileBedPublic'),
|
||||
description: t('settings.fileBedPublicHint')
|
||||
},
|
||||
{
|
||||
value: 'token',
|
||||
label: t('settings.fileBedToken'),
|
||||
description: t('settings.fileBedTokenHint')
|
||||
}
|
||||
])
|
||||
|
||||
interface ReleaseCheckResponse {
|
||||
currentVersion: string
|
||||
@@ -207,6 +222,7 @@ const hasServerChanges = computed(() => {
|
||||
|| loginRateMaxDraft.value !== settings.value.loginRateMax
|
||||
|| loginRateWindowMinutesDraft.value !== settings.value.loginRateWindowMinutes
|
||||
|| preserveOriginalUploadDraft.value !== settings.value.preserveOriginalUpload
|
||||
|| fileBedAccessDraft.value !== settings.value.fileBedAccess
|
||||
})
|
||||
|
||||
const domainSeparationWouldActivate = computed(() =>
|
||||
@@ -270,6 +286,7 @@ function applySettings(data: SettingsResponse) {
|
||||
loginRateMaxDraft.value = data.loginRateMax
|
||||
loginRateWindowMinutesDraft.value = data.loginRateWindowMinutes
|
||||
preserveOriginalUploadDraft.value = data.preserveOriginalUpload
|
||||
fileBedAccessDraft.value = data.fileBedAccess
|
||||
}
|
||||
|
||||
async function patchSettings(body: Record<string, unknown>) {
|
||||
@@ -370,7 +387,8 @@ async function performSaveServerSettings() {
|
||||
uploadRateWindowMinutes: uploadRateWindowMinutesDraft.value,
|
||||
loginRateMax: loginRateMaxDraft.value,
|
||||
loginRateWindowMinutes: loginRateWindowMinutesDraft.value,
|
||||
preserveOriginalUpload: preserveOriginalUploadDraft.value
|
||||
preserveOriginalUpload: preserveOriginalUploadDraft.value,
|
||||
fileBedAccess: fileBedAccessDraft.value
|
||||
})
|
||||
toast.add({ title: t('settings.saved'), color: 'success' })
|
||||
} catch (error: unknown) {
|
||||
@@ -923,6 +941,18 @@ watch(isAdmin, () => {
|
||||
</div>
|
||||
</SettingsPlainSection>
|
||||
|
||||
<SettingsSection
|
||||
:title="t('settings.fileBed')"
|
||||
:hint="t('settings.fileBedHint')"
|
||||
>
|
||||
<SettingsGroup>
|
||||
<SettingsRadioGroup
|
||||
v-model="fileBedAccessDraft"
|
||||
:items="fileBedAccessOptions"
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
:title="t('settings.loginVerification')"
|
||||
:hint="t('settings.loginVerificationHint')"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import VideoLibraryPanel from '~/components/VideoLibraryPanel.vue'
|
||||
|
||||
const { isChecking, isAuthenticated, checkSession, fetchStatus } = useAuth()
|
||||
const { t } = useI18n()
|
||||
|
||||
const panelRef = ref<{ refresh: () => Promise<void> } | null>(null)
|
||||
|
||||
async function loadPanel() {
|
||||
if (isAuthenticated.value) {
|
||||
await panelRef.value?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const status = await fetchStatus()
|
||||
if (!status.initialized && !status.legacyMode) {
|
||||
await navigateTo('/setup')
|
||||
return
|
||||
}
|
||||
await checkSession()
|
||||
await loadPanel()
|
||||
})
|
||||
|
||||
watch(isAuthenticated, async (authed, prev) => {
|
||||
if (authed && prev === false) {
|
||||
await nextTick()
|
||||
await loadPanel()
|
||||
}
|
||||
})
|
||||
</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>
|
||||
<VideoLibraryPanel ref="panelRef" />
|
||||
</AppShell>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
const { isChecking, isAuthenticated, checkSession, fetchStatus } = useAuth()
|
||||
const { t } = useI18n()
|
||||
|
||||
onMounted(async () => {
|
||||
const status = await fetchStatus()
|
||||
if (!status.initialized && !status.legacyMode) {
|
||||
await navigateTo('/setup')
|
||||
return
|
||||
}
|
||||
await checkSession()
|
||||
})
|
||||
</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>
|
||||
<MediaUploadPanel kind="video" />
|
||||
</AppShell>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ImageTag, UploadErrorItem } from './image'
|
||||
|
||||
export interface FileItem {
|
||||
key: string
|
||||
url: string
|
||||
originalName: string
|
||||
contentType: string
|
||||
size: number
|
||||
uploadedAt: string
|
||||
markdown: string
|
||||
/** 视频转码状态(仅视频有值) */
|
||||
transcodeStatus?: 'none' | 'pending' | 'running' | 'done' | 'failed'
|
||||
/** 转码进度 0–100 */
|
||||
transcodeProgress?: number
|
||||
/** 本视频使用的转码档位 */
|
||||
transcodePreset?: string
|
||||
/** HLS 播放列表地址(转码完成后有值) */
|
||||
hlsUrl?: string
|
||||
/** HLS 播放列表在存储中的 key */
|
||||
hlsKey?: string
|
||||
/** 视频封面缩略图地址 */
|
||||
thumbUrl?: string
|
||||
/** 上传来源(来自活动日志) */
|
||||
uploadSource?: 'web' | 'api'
|
||||
/** 转码成功后源视频已删除,不再提供原文件下载 */
|
||||
sourceDeleted?: boolean
|
||||
/** 视频标签 */
|
||||
tags?: ImageTag[]
|
||||
/** 管理员列表可见:上传者信息 */
|
||||
owner?: {
|
||||
userId: number | null
|
||||
username: string
|
||||
}
|
||||
/** 文件所在存储后端 */
|
||||
storage?: {
|
||||
id: string
|
||||
name: string
|
||||
type: 'local' | 's3' | 'telegram'
|
||||
}
|
||||
}
|
||||
|
||||
export interface FileListResponse {
|
||||
items: FileItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
/** 文件床直链访问方式(后台可切换) */
|
||||
accessMode: 'public' | 'token'
|
||||
}
|
||||
|
||||
export interface FileUploadResponse {
|
||||
success: boolean
|
||||
items: FileItem[]
|
||||
errors: UploadErrorItem[]
|
||||
/** 直链访问方式,token 模式下复制链接需要附带令牌 */
|
||||
accessMode: 'public' | 'token'
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export type MediaType = 'image' | 'video' | 'file'
|
||||
|
||||
/** 图库 / 视频库 / 文件库共用的统计数据结构 */
|
||||
export interface LibraryStatsData {
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user