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
@@ -18,6 +18,9 @@ FROM node:22-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 视频床转码依赖 ffmpeg(单码率 HLS 切片)
|
||||||
|
RUN apk add --no-cache ffmpeg
|
||||||
|
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
NITRO_PORT=6892 \
|
NITRO_PORT=6892 \
|
||||||
DATA_DIR=/data
|
DATA_DIR=/data
|
||||||
|
|||||||
+69
-29
@@ -13,7 +13,9 @@ const mobileUserOpen = ref(false)
|
|||||||
const navItems = computed(() => {
|
const navItems = computed(() => {
|
||||||
const items = [
|
const items = [
|
||||||
{ label: t('nav.api'), to: '/api', icon: 'i-lucide-code-2' },
|
{ 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) {
|
if (isAdmin.value) {
|
||||||
items.push({ label: t('nav.storage'), to: '/storage', icon: 'i-lucide-hard-drive' })
|
items.push({ label: t('nav.storage'), to: '/storage', icon: 'i-lucide-hard-drive' })
|
||||||
@@ -22,6 +24,28 @@ const navItems = computed(() => {
|
|||||||
return items
|
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[]>(() => [
|
const accountMenuItems = computed<DropdownMenuItem[]>(() => [
|
||||||
{
|
{
|
||||||
label: t('nav.changePassword'),
|
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">
|
<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="mx-auto max-w-7xl px-4 py-2.5 sm:px-6">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<NuxtLink
|
<div class="flex min-w-0 items-center gap-2 sm:gap-3">
|
||||||
to="/"
|
<NuxtLink
|
||||||
:title="t('nav.backToUpload')"
|
to="/"
|
||||||
class="group flex min-w-0 shrink items-center gap-2.5 transition-opacity hover:opacity-90 sm:gap-3"
|
: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="logoLight"
|
|
||||||
alt="PicHost"
|
|
||||||
class="h-9 w-auto shrink-0 object-contain sm:h-11 dark:hidden"
|
|
||||||
decoding="async"
|
|
||||||
draggable="false"
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="logoDark"
|
:src="logoLight"
|
||||||
alt="PicHost"
|
alt="PicHost"
|
||||||
class="hidden h-9 w-auto shrink-0 object-contain sm:h-11 dark:block"
|
class="h-9 w-auto shrink-0 object-contain sm:h-11 dark:hidden"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
>
|
>
|
||||||
<div class="min-w-0 leading-tight">
|
<img
|
||||||
<p class="text-[15px] font-bold tracking-tight sm:text-base">
|
:src="logoDark"
|
||||||
<span class="text-highlighted">Pic</span><span class="text-primary">Host</span>
|
alt="PicHost"
|
||||||
</p>
|
class="hidden h-9 w-auto shrink-0 object-contain sm:h-11 dark:block"
|
||||||
<p class="mt-0.5 hidden text-[11px] text-muted min-[380px]:block sm:text-xs">
|
decoding="async"
|
||||||
{{ t('app.tagline') }}
|
draggable="false"
|
||||||
</p>
|
>
|
||||||
</div>
|
<div class="min-w-0 leading-tight">
|
||||||
</NuxtLink>
|
<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>
|
<ClientOnly>
|
||||||
<div class="flex shrink-0 items-center gap-0.5 sm:gap-1">
|
<div class="flex shrink-0 items-center gap-0.5 sm:gap-1">
|
||||||
@@ -124,7 +164,7 @@ async function handleLogout() {
|
|||||||
/>
|
/>
|
||||||
<MobileNavMenu
|
<MobileNavMenu
|
||||||
v-model:open="mobileNavOpen"
|
v-model:open="mobileNavOpen"
|
||||||
:items="navItems"
|
:items="mobileNavItems"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { MediaType } from '~/types/media'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
count: number
|
count: number
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
/** 删除对象的类型,决定确认文案 */
|
||||||
|
kind?: MediaType
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const kind = computed<MediaType>(() => props.kind ?? 'image')
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:open': [value: boolean]
|
'update:open': [value: boolean]
|
||||||
'confirm': []
|
'confirm': []
|
||||||
@@ -12,7 +18,16 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const { t } = useI18n()
|
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() {
|
function close() {
|
||||||
emit('update:open', false)
|
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">
|
<script setup lang="ts">
|
||||||
export interface GalleryStatsData {
|
import type { LibraryStatsData, MediaType } from '~/types/media'
|
||||||
uploadToday: number
|
|
||||||
uploadYesterday: number
|
|
||||||
uploadMonth: number
|
|
||||||
uploadLastMonth: number
|
|
||||||
uploadTotal: number
|
|
||||||
storedCount: number
|
|
||||||
uploadBytesTotal: number
|
|
||||||
bySource: {
|
|
||||||
web: number
|
|
||||||
api: number
|
|
||||||
}
|
|
||||||
storageUsage: {
|
|
||||||
usedBytes: number
|
|
||||||
totalBytes: number | null
|
|
||||||
percent: number | null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
stats: GalleryStatsData | null
|
stats: LibraryStatsData | null
|
||||||
|
/** 统计口径:图片 / 视频 / 文件各自独立 */
|
||||||
|
kind?: MediaType
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
}>()
|
}>(), {
|
||||||
|
kind: 'image'
|
||||||
|
})
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { formatFileSize } = useFileSize()
|
const { formatFileSize } = useFileSize()
|
||||||
|
|
||||||
const SOURCE_COLORS = ['var(--ui-primary)', '#10b981'] as const
|
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 sourceItems = computed(() => {
|
||||||
const bySource = props.stats?.bySource
|
const bySource = props.stats?.bySource
|
||||||
if (!bySource) return []
|
if (!bySource) return []
|
||||||
@@ -147,7 +145,7 @@ function displayValue(value: number | undefined) {
|
|||||||
{{ displayValue(stats?.uploadTotal) }}
|
{{ displayValue(stats?.uploadTotal) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 text-xs text-muted">
|
<p class="mt-2 text-xs text-muted">
|
||||||
{{ t('stats.totalFiles') }}
|
{{ totalHint }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 uploadCardBg from '~/assets/image/upload-card-bg.webp'
|
||||||
import uploadCardHero from '~/assets/image/upload-card-hero.webp'
|
import uploadCardHero from '~/assets/image/upload-card-hero.webp'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
disabled?: boolean
|
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<{
|
const emit = defineEmits<{
|
||||||
'upload': [files: File[]]
|
'upload': [files: File[]]
|
||||||
@@ -18,9 +33,22 @@ const { t } = useI18n()
|
|||||||
const { bindPaste } = useClipboardImage(ref(null))
|
const { bindPaste } = useClipboardImage(ref(null))
|
||||||
|
|
||||||
bindPaste((files) => {
|
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() {
|
function openFilePicker() {
|
||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
fileInput.value?.click()
|
fileInput.value?.click()
|
||||||
@@ -38,8 +66,7 @@ function onDrop(event: DragEvent) {
|
|||||||
dragging.value = false
|
dragging.value = false
|
||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
|
|
||||||
const files = Array.from(event.dataTransfer?.files ?? [])
|
const files = Array.from(event.dataTransfer?.files ?? []).filter(matchesMediaKind)
|
||||||
.filter(file => file.type.startsWith('image/'))
|
|
||||||
if (files.length) emit('upload', files)
|
if (files.length) emit('upload', files)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +110,7 @@ function onKeydown(event: KeyboardEvent) {
|
|||||||
<input
|
<input
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml,image/x-icon,.svg,.ico"
|
:accept="accept"
|
||||||
multiple
|
multiple
|
||||||
class="hidden"
|
class="hidden"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@@ -91,7 +118,7 @@ function onKeydown(event: KeyboardEvent) {
|
|||||||
>
|
>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
:src="uploadCardBg"
|
:src="backgroundSource"
|
||||||
alt=""
|
alt=""
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="pointer-events-none absolute inset-0 size-full object-cover opacity-100 dark:opacity-[0.12]"
|
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"
|
draggable="false"
|
||||||
>
|
>
|
||||||
|
|
||||||
<div class="absolute top-3 right-3 z-20">
|
<div
|
||||||
|
v-if="showSettings"
|
||||||
|
class="absolute top-3 right-3 z-20"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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="relative z-10 mx-auto flex max-w-lg flex-col items-center">
|
||||||
<div class="flex flex-col items-center">
|
<div class="flex flex-col items-center">
|
||||||
<img
|
<img
|
||||||
:src="uploadCardHero"
|
:src="heroSource"
|
||||||
alt=""
|
alt=""
|
||||||
aria-hidden="true"
|
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"
|
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">
|
<div class="space-y-2">
|
||||||
<p class="text-xl font-semibold tracking-tight text-highlighted sm:text-2xl">
|
<p class="text-xl font-semibold tracking-tight text-highlighted sm:text-2xl">
|
||||||
{{ t('upload.title') }}
|
{{ titleText }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-muted sm:text-base">
|
<p class="text-sm text-muted sm:text-base">
|
||||||
{{ t('upload.hint', { ctrl: 'Ctrl', v: 'V' }) }}
|
{{ hintText }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="mt-4 max-w-md text-xs leading-relaxed text-dimmed sm:text-sm">
|
<p class="mt-4 max-w-md text-xs leading-relaxed text-dimmed sm:text-sm">
|
||||||
{{ t('upload.formats') }}
|
{{ formatsText }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,7 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
import type { UploadMediaKind } from '~/composables/useUploadPreferences'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}>()
|
/** 上传页类型:决定默认可选文件类型、粘贴支持与偏好面板内容 */
|
||||||
|
kind?: UploadMediaKind
|
||||||
|
title?: string
|
||||||
|
hint?: string
|
||||||
|
formats?: string
|
||||||
|
accept?: string
|
||||||
|
heroImage?: string
|
||||||
|
backgroundImage?: string
|
||||||
|
pasteEnabled?: boolean
|
||||||
|
}>(), {
|
||||||
|
kind: 'image'
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
upload: [files: File[]]
|
upload: [files: File[]]
|
||||||
@@ -9,6 +22,15 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const flipped = ref(false)
|
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() {
|
function onOpenSettings() {
|
||||||
flipped.value = true
|
flipped.value = true
|
||||||
}
|
}
|
||||||
@@ -25,14 +47,25 @@ function onBack() {
|
|||||||
>
|
>
|
||||||
<div class="upload-card-flip-inner">
|
<div class="upload-card-flip-inner">
|
||||||
<div class="upload-card-face upload-card-face--front">
|
<div class="upload-card-face upload-card-face--front">
|
||||||
<ImageUploader
|
<UploadCard
|
||||||
:disabled="disabled"
|
: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)"
|
@upload="emit('upload', $event)"
|
||||||
@open-settings="onOpenSettings"
|
@open-settings="onOpenSettings"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="upload-card-face upload-card-face--back">
|
<div class="upload-card-face upload-card-face--back">
|
||||||
<UploadPreferencesPanel @back="onBack" />
|
<UploadPreferencesPanel
|
||||||
|
:kind="kind"
|
||||||
|
@back="onBack"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { UploadMediaKind } from '~/composables/useUploadPreferences'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
/** 每个上传页各自一份偏好 */
|
||||||
|
kind?: UploadMediaKind
|
||||||
|
}>(), {
|
||||||
|
kind: 'image'
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
back: []
|
back: []
|
||||||
}>()
|
}>()
|
||||||
@@ -8,8 +17,18 @@ const {
|
|||||||
autoCopyMarkdown,
|
autoCopyMarkdown,
|
||||||
copyFormat,
|
copyFormat,
|
||||||
clientWebpQuality,
|
clientWebpQuality,
|
||||||
|
transcodeToHls,
|
||||||
loadPreferences
|
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()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -20,6 +39,32 @@ const {
|
|||||||
load: loadUserSettings
|
load: loadUserSettings
|
||||||
} = useUserAutoDeleteSettings()
|
} = 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(() => [
|
const copyFormatItems = computed(() => [
|
||||||
{ label: t('copy.url'), value: 'url' as const },
|
{ label: t('copy.url'), value: 'url' as const },
|
||||||
{ label: t('copy.markdown'), value: 'markdown' 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
|
clientWebpQuality.value = Number.isFinite(value) ? value : 80
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectPreset(value: string) {
|
||||||
|
transcodePreset.value = value
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadPreferences()
|
loadPreferences()
|
||||||
void loadUserSettings()
|
void loadUserSettings()
|
||||||
|
void loadTranscodeSettings()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -78,8 +128,14 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col justify-center p-5 sm:p-6">
|
<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
|
||||||
<div class="space-y-4">
|
class="grid gap-5"
|
||||||
|
:class="gridClass"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isImageKind"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
<h3 class="text-sm font-semibold">
|
<h3 class="text-sm font-semibold">
|
||||||
{{ t('preferences.processing') }}
|
{{ t('preferences.processing') }}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -137,7 +193,103 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<h3 class="text-sm font-semibold">
|
||||||
{{ t('preferences.webpQuality') }}
|
{{ t('preferences.webpQuality') }}
|
||||||
</h3>
|
</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
|
checking: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
// 「检查更新」已停用(改动较大,与上游版本号已不对应),暂时保留事件定义
|
||||||
|
defineEmits<{
|
||||||
checkUpdate: []
|
checkUpdate: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ const { t } = useI18n()
|
|||||||
<span class="text-sm font-medium tabular-nums text-highlighted">
|
<span class="text-sm font-medium tabular-nums text-highlighted">
|
||||||
v{{ appVersion }}
|
v{{ appVersion }}
|
||||||
</span>
|
</span>
|
||||||
|
<!-- 检查更新功能已停用,需要时恢复下面这段
|
||||||
<UBadge
|
<UBadge
|
||||||
v-if="updateAvailable && latestVersion"
|
v-if="updateAvailable && latestVersion"
|
||||||
color="warning"
|
color="warning"
|
||||||
@@ -63,6 +65,7 @@ const { t } = useI18n()
|
|||||||
>
|
>
|
||||||
{{ t('settings.viewReleases') }}
|
{{ t('settings.viewReleases') }}
|
||||||
</a>
|
</a>
|
||||||
|
-->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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'
|
const STORAGE_KEY = 'pichost.upload-preferences'
|
||||||
|
|
||||||
export type CopyFormat = 'url' | 'markdown' | 'html' | 'bbcode'
|
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 {
|
export interface UploadPreferences {
|
||||||
compressEnabled: boolean
|
compressEnabled: boolean
|
||||||
@@ -8,6 +14,8 @@ export interface UploadPreferences {
|
|||||||
copyFormat: CopyFormat
|
copyFormat: CopyFormat
|
||||||
clientWebpQuality: number
|
clientWebpQuality: number
|
||||||
lastTagIds: number[]
|
lastTagIds: number[]
|
||||||
|
/** 视频床专属:上传后转码为 HLS(m3u8 + 分片) */
|
||||||
|
transcodeToHls: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS: UploadPreferences = {
|
const DEFAULTS: UploadPreferences = {
|
||||||
@@ -15,7 +23,8 @@ const DEFAULTS: UploadPreferences = {
|
|||||||
autoCopyMarkdown: false,
|
autoCopyMarkdown: false,
|
||||||
copyFormat: 'markdown',
|
copyFormat: 'markdown',
|
||||||
clientWebpQuality: 80,
|
clientWebpQuality: 80,
|
||||||
lastTagIds: []
|
lastTagIds: [],
|
||||||
|
transcodeToHls: false
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampQuality(value: unknown): number {
|
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'
|
return value === 'url' || value === 'markdown' || value === 'html' || value === 'bbcode'
|
||||||
}
|
}
|
||||||
|
|
||||||
function readStored(): UploadPreferences {
|
function readStored(kind: UploadMediaKind): UploadPreferences {
|
||||||
if (import.meta.server) return { ...DEFAULTS }
|
if (import.meta.server) return { ...DEFAULTS }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const raw = localStorage.getItem(storageKeyFor(kind))
|
||||||
if (!raw) return { ...DEFAULTS }
|
if (!raw) return { ...DEFAULTS }
|
||||||
const parsed = JSON.parse(raw) as Partial<UploadPreferences>
|
const parsed = JSON.parse(raw) as Partial<UploadPreferences>
|
||||||
return {
|
return {
|
||||||
@@ -42,33 +51,36 @@ function readStored(): UploadPreferences {
|
|||||||
clientWebpQuality: clampQuality(parsed.clientWebpQuality),
|
clientWebpQuality: clampQuality(parsed.clientWebpQuality),
|
||||||
lastTagIds: Array.isArray(parsed.lastTagIds)
|
lastTagIds: Array.isArray(parsed.lastTagIds)
|
||||||
? parsed.lastTagIds.filter(id => Number.isInteger(id) && id > 0)
|
? parsed.lastTagIds.filter(id => Number.isInteger(id) && id > 0)
|
||||||
: DEFAULTS.lastTagIds
|
: DEFAULTS.lastTagIds,
|
||||||
|
transcodeToHls: parsed.transcodeToHls === true
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return { ...DEFAULTS }
|
return { ...DEFAULTS }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeStored(prefs: UploadPreferences) {
|
function writeStored(kind: UploadMediaKind, prefs: UploadPreferences) {
|
||||||
if (import.meta.server) return
|
if (import.meta.server) return
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs))
|
localStorage.setItem(storageKeyFor(kind), JSON.stringify(prefs))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUploadPreferences() {
|
export function useUploadPreferences(kind: UploadMediaKind = 'image') {
|
||||||
const compressEnabled = useState('upload-pref-compress', () => DEFAULTS.compressEnabled)
|
const compressEnabled = useState(`upload-pref-compress-${kind}`, () => DEFAULTS.compressEnabled)
|
||||||
const autoCopyMarkdown = useState('upload-pref-auto-md', () => DEFAULTS.autoCopyMarkdown)
|
const autoCopyMarkdown = useState(`upload-pref-auto-md-${kind}`, () => DEFAULTS.autoCopyMarkdown)
|
||||||
const copyFormat = useState<CopyFormat>('upload-pref-copy-format', () => DEFAULTS.copyFormat)
|
const copyFormat = useState<CopyFormat>(`upload-pref-copy-format-${kind}`, () => DEFAULTS.copyFormat)
|
||||||
const clientWebpQuality = useState('upload-pref-webp-quality', () => DEFAULTS.clientWebpQuality)
|
const clientWebpQuality = useState(`upload-pref-webp-quality-${kind}`, () => DEFAULTS.clientWebpQuality)
|
||||||
const lastTagIds = useState<number[]>('upload-pref-last-tag-ids', () => DEFAULTS.lastTagIds)
|
const lastTagIds = useState<number[]>(`upload-pref-last-tag-ids-${kind}`, () => DEFAULTS.lastTagIds)
|
||||||
const loaded = useState('upload-pref-loaded', () => false)
|
const transcodeToHls = useState(`upload-pref-transcode-${kind}`, () => DEFAULTS.transcodeToHls)
|
||||||
|
const loaded = useState(`upload-pref-loaded-${kind}`, () => false)
|
||||||
|
|
||||||
function loadPreferences() {
|
function loadPreferences() {
|
||||||
const stored = readStored()
|
const stored = readStored(kind)
|
||||||
compressEnabled.value = stored.compressEnabled
|
compressEnabled.value = stored.compressEnabled
|
||||||
autoCopyMarkdown.value = stored.autoCopyMarkdown
|
autoCopyMarkdown.value = stored.autoCopyMarkdown
|
||||||
copyFormat.value = stored.copyFormat
|
copyFormat.value = stored.copyFormat
|
||||||
clientWebpQuality.value = stored.clientWebpQuality
|
clientWebpQuality.value = stored.clientWebpQuality
|
||||||
lastTagIds.value = stored.lastTagIds
|
lastTagIds.value = stored.lastTagIds
|
||||||
|
transcodeToHls.value = stored.transcodeToHls
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,12 +89,13 @@ export function useUploadPreferences() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function savePreferences() {
|
function savePreferences() {
|
||||||
writeStored({
|
writeStored(kind, {
|
||||||
compressEnabled: compressEnabled.value,
|
compressEnabled: compressEnabled.value,
|
||||||
autoCopyMarkdown: autoCopyMarkdown.value,
|
autoCopyMarkdown: autoCopyMarkdown.value,
|
||||||
copyFormat: copyFormat.value,
|
copyFormat: copyFormat.value,
|
||||||
clientWebpQuality: clampQuality(clientWebpQuality.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
|
autoCopyMarkdown.value = DEFAULTS.autoCopyMarkdown
|
||||||
copyFormat.value = DEFAULTS.copyFormat
|
copyFormat.value = DEFAULTS.copyFormat
|
||||||
clientWebpQuality.value = DEFAULTS.clientWebpQuality
|
clientWebpQuality.value = DEFAULTS.clientWebpQuality
|
||||||
|
transcodeToHls.value = DEFAULTS.transcodeToHls
|
||||||
savePreferences()
|
savePreferences()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch([compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality, lastTagIds], () => {
|
watch(
|
||||||
if (loaded.value) {
|
[compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality, lastTagIds, transcodeToHls],
|
||||||
savePreferences()
|
() => {
|
||||||
|
if (loaded.value) {
|
||||||
|
savePreferences()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
kind,
|
||||||
compressEnabled,
|
compressEnabled,
|
||||||
autoCopyMarkdown,
|
autoCopyMarkdown,
|
||||||
copyFormat,
|
copyFormat,
|
||||||
clientWebpQuality,
|
clientWebpQuality,
|
||||||
lastTagIds,
|
lastTagIds,
|
||||||
|
transcodeToHls,
|
||||||
loaded,
|
loaded,
|
||||||
loadPreferences,
|
loadPreferences,
|
||||||
savePreferences,
|
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,
|
activeTagMode,
|
||||||
activeUntaggedOnly,
|
activeUntaggedOnly,
|
||||||
setActiveTagFilter,
|
setActiveTagFilter,
|
||||||
toggleTagFilter,
|
toggleTagFilter
|
||||||
listQueryParams
|
|
||||||
} = useImageList()
|
} = useImageList()
|
||||||
|
|
||||||
const { items: tagItems, fetchTags, batchUpdateTags } = useTags()
|
const { items: tagItems, fetchTags, batchUpdateTags } = useTags()
|
||||||
@@ -73,7 +72,7 @@ const batchDeleting = ref(false)
|
|||||||
const showScrollTop = ref(false)
|
const showScrollTop = ref(false)
|
||||||
const previewOpen = ref(false)
|
const previewOpen = ref(false)
|
||||||
const previewImage = ref<ImageItem | null>(null)
|
const previewImage = ref<ImageItem | null>(null)
|
||||||
const viewMode = ref<'grid' | 'list'>('grid')
|
const { viewMode } = useLibraryView('image')
|
||||||
const batchTagOpen = ref(false)
|
const batchTagOpen = ref(false)
|
||||||
|
|
||||||
const currentUploadSource = ref('all')
|
const currentUploadSource = ref('all')
|
||||||
@@ -104,9 +103,9 @@ function scrollToTop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchStats() {
|
async function fetchStats() {
|
||||||
stats.value = await $fetch<StatsResponse>('/api/stats', {
|
stats.value = await $fetch<StatsResponse>('/api/library/stats', {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
query: listQueryParams()
|
query: { kind: 'image' }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,7 +465,8 @@ watch(isAuthenticated, async (authed, prev) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GalleryStatsOverview
|
<LibraryStatsPanel
|
||||||
|
kind="image"
|
||||||
:stats="stats"
|
:stats="stats"
|
||||||
:loading="statsLoading"
|
:loading="statsLoading"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ interface SettingsResponse {
|
|||||||
loginRateWindowMinutesSource: PolicySource
|
loginRateWindowMinutesSource: PolicySource
|
||||||
preserveOriginalUpload: boolean
|
preserveOriginalUpload: boolean
|
||||||
preserveOriginalUploadSource: PolicySource
|
preserveOriginalUploadSource: PolicySource
|
||||||
|
fileBedAccess: 'public' | 'token'
|
||||||
mimeTypeOptions: ReadonlyArray<{ mime: MimeType, label: string }>
|
mimeTypeOptions: ReadonlyArray<{ mime: MimeType, label: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +172,20 @@ const uploadRateWindowMinutesDraft = ref(15)
|
|||||||
const loginRateMaxDraft = ref(10)
|
const loginRateMaxDraft = ref(10)
|
||||||
const loginRateWindowMinutesDraft = ref(15)
|
const loginRateWindowMinutesDraft = ref(15)
|
||||||
const preserveOriginalUploadDraft = ref(false)
|
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 {
|
interface ReleaseCheckResponse {
|
||||||
currentVersion: string
|
currentVersion: string
|
||||||
@@ -207,6 +222,7 @@ const hasServerChanges = computed(() => {
|
|||||||
|| loginRateMaxDraft.value !== settings.value.loginRateMax
|
|| loginRateMaxDraft.value !== settings.value.loginRateMax
|
||||||
|| loginRateWindowMinutesDraft.value !== settings.value.loginRateWindowMinutes
|
|| loginRateWindowMinutesDraft.value !== settings.value.loginRateWindowMinutes
|
||||||
|| preserveOriginalUploadDraft.value !== settings.value.preserveOriginalUpload
|
|| preserveOriginalUploadDraft.value !== settings.value.preserveOriginalUpload
|
||||||
|
|| fileBedAccessDraft.value !== settings.value.fileBedAccess
|
||||||
})
|
})
|
||||||
|
|
||||||
const domainSeparationWouldActivate = computed(() =>
|
const domainSeparationWouldActivate = computed(() =>
|
||||||
@@ -270,6 +286,7 @@ function applySettings(data: SettingsResponse) {
|
|||||||
loginRateMaxDraft.value = data.loginRateMax
|
loginRateMaxDraft.value = data.loginRateMax
|
||||||
loginRateWindowMinutesDraft.value = data.loginRateWindowMinutes
|
loginRateWindowMinutesDraft.value = data.loginRateWindowMinutes
|
||||||
preserveOriginalUploadDraft.value = data.preserveOriginalUpload
|
preserveOriginalUploadDraft.value = data.preserveOriginalUpload
|
||||||
|
fileBedAccessDraft.value = data.fileBedAccess
|
||||||
}
|
}
|
||||||
|
|
||||||
async function patchSettings(body: Record<string, unknown>) {
|
async function patchSettings(body: Record<string, unknown>) {
|
||||||
@@ -370,7 +387,8 @@ async function performSaveServerSettings() {
|
|||||||
uploadRateWindowMinutes: uploadRateWindowMinutesDraft.value,
|
uploadRateWindowMinutes: uploadRateWindowMinutesDraft.value,
|
||||||
loginRateMax: loginRateMaxDraft.value,
|
loginRateMax: loginRateMaxDraft.value,
|
||||||
loginRateWindowMinutes: loginRateWindowMinutesDraft.value,
|
loginRateWindowMinutes: loginRateWindowMinutesDraft.value,
|
||||||
preserveOriginalUpload: preserveOriginalUploadDraft.value
|
preserveOriginalUpload: preserveOriginalUploadDraft.value,
|
||||||
|
fileBedAccess: fileBedAccessDraft.value
|
||||||
})
|
})
|
||||||
toast.add({ title: t('settings.saved'), color: 'success' })
|
toast.add({ title: t('settings.saved'), color: 'success' })
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -923,6 +941,18 @@ watch(isAdmin, () => {
|
|||||||
</div>
|
</div>
|
||||||
</SettingsPlainSection>
|
</SettingsPlainSection>
|
||||||
|
|
||||||
|
<SettingsSection
|
||||||
|
:title="t('settings.fileBed')"
|
||||||
|
:hint="t('settings.fileBedHint')"
|
||||||
|
>
|
||||||
|
<SettingsGroup>
|
||||||
|
<SettingsRadioGroup
|
||||||
|
v-model="fileBedAccessDraft"
|
||||||
|
:items="fileBedAccessOptions"
|
||||||
|
/>
|
||||||
|
</SettingsGroup>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
:title="t('settings.loginVerification')"
|
:title="t('settings.loginVerification')"
|
||||||
:hint="t('settings.loginVerificationHint')"
|
: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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"api": "API",
|
"api": "API",
|
||||||
"gallery": "Gallery",
|
"gallery": "Gallery",
|
||||||
|
"videoLibrary": "Video library",
|
||||||
|
"fileLibrary": "File library",
|
||||||
"stats": "Stats",
|
"stats": "Stats",
|
||||||
"logs": "Activity log",
|
"logs": "Activity log",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
@@ -298,6 +300,23 @@
|
|||||||
"qualityMedium": "Medium quality",
|
"qualityMedium": "Medium quality",
|
||||||
"qualityLow": "Lossy",
|
"qualityLow": "Lossy",
|
||||||
"convenience": "Convenience",
|
"convenience": "Convenience",
|
||||||
|
"videoProcessing": "Video processing",
|
||||||
|
"transcodeToHls": "Transcode to m3u8",
|
||||||
|
"transcodeToHlsHint": "Segment into HLS, uses extra CPU.",
|
||||||
|
"concurrentHint": "at a time",
|
||||||
|
"threadsHint": "per job",
|
||||||
|
"transcodeConcurrency": "Concurrent transcodes",
|
||||||
|
"transcodeThreads": "CPU threads",
|
||||||
|
"unitCount": "",
|
||||||
|
"transcodeQuality": "Quality",
|
||||||
|
"presetCopy": "Remux",
|
||||||
|
"presetSpeed": "Speed",
|
||||||
|
"presetBalanced": "Balanced",
|
||||||
|
"presetQuality": "Quality",
|
||||||
|
"presetCompress": "Compress",
|
||||||
|
"compressBitrate": "Target bitrate",
|
||||||
|
"transcodeLoadFailed": "Failed to load transcode settings",
|
||||||
|
"transcodeSaveFailed": "Failed to save transcode settings",
|
||||||
"autoCopy": "Copy link after upload",
|
"autoCopy": "Copy link after upload",
|
||||||
"autoCopyHint": "Copy to clipboard in the selected format"
|
"autoCopyHint": "Copy to clipboard in the selected format"
|
||||||
},
|
},
|
||||||
@@ -356,6 +375,12 @@
|
|||||||
"refererUnrestricted": "Unrestricted",
|
"refererUnrestricted": "Unrestricted",
|
||||||
"loginVerification": "Login verification",
|
"loginVerification": "Login verification",
|
||||||
"loginVerificationHint": "Human verification for login and registration (choose one)",
|
"loginVerificationHint": "Human verification for login and registration (choose one)",
|
||||||
|
"fileBed": "File bed",
|
||||||
|
"fileBedHint": "Access mode for file download links. Takes effect immediately.",
|
||||||
|
"fileBedPublic": "Public",
|
||||||
|
"fileBedPublicHint": "Anyone with the link can download. Best for sharing.",
|
||||||
|
"fileBedToken": "Token required",
|
||||||
|
"fileBedTokenHint": "Downloads must carry a token (append ?token= to the link). Best for private files.",
|
||||||
"loginVerificationSlider": "Local slider",
|
"loginVerificationSlider": "Local slider",
|
||||||
"loginVerificationSliderHint": "Built-in slider puzzle; no third-party service",
|
"loginVerificationSliderHint": "Built-in slider puzzle; no third-party service",
|
||||||
"loginVerificationTurnstile": "Cloudflare Turnstile",
|
"loginVerificationTurnstile": "Cloudflare Turnstile",
|
||||||
@@ -816,6 +841,8 @@
|
|||||||
"storageBackend": "Storage: {name}"
|
"storageBackend": "Storage: {name}"
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
|
"confirmDescVideo": "Delete the selected {count} video(s)? This cannot be undone.",
|
||||||
|
"confirmDescFile": "Delete the selected {count} file(s)? This cannot be undone.",
|
||||||
"confirmTitle": "Confirm delete",
|
"confirmTitle": "Confirm delete",
|
||||||
"confirmDesc": "Delete {count} selected image(s)? This cannot be undone.",
|
"confirmDesc": "Delete {count} selected image(s)? This cannot be undone.",
|
||||||
"confirm": "Delete"
|
"confirm": "Delete"
|
||||||
@@ -967,6 +994,76 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"files": {
|
||||||
|
"maxFiles": "Up to {n} files per upload",
|
||||||
|
"uploadSuccess": "Uploaded {n} file(s)",
|
||||||
|
"uploadFailed": "Upload failed, please try again",
|
||||||
|
"loadFailed": "Failed to load files",
|
||||||
|
"copyLink": "Copy link",
|
||||||
|
"download": "Download",
|
||||||
|
"deleted": "Deleted",
|
||||||
|
"deleteFailed": "Delete failed"
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"video": "Video",
|
||||||
|
"file": "File",
|
||||||
|
"imageFull": "Personal lightweight image host",
|
||||||
|
"videoFull": "Personal lightweight video host",
|
||||||
|
"fileFull": "Personal lightweight file host"
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"play": "Play",
|
||||||
|
"copyVideoLink": "Copy video link",
|
||||||
|
"copyThumbLink": "Copy thumbnail link",
|
||||||
|
"selectedCount": "{n} selected",
|
||||||
|
"selectFirst": "Select videos first",
|
||||||
|
"clearSelection": "Clear",
|
||||||
|
"reset": "Reset",
|
||||||
|
"batchDelete": "Delete",
|
||||||
|
"deleted": "Deleted {n} video(s)",
|
||||||
|
"fileDeleted": "Deleted {n} file(s)",
|
||||||
|
"deletePartialFail": "{n} video(s) failed to delete",
|
||||||
|
"statsToday": "Uploaded today",
|
||||||
|
"statsMonth": "This month",
|
||||||
|
"statsTotal": "Videos",
|
||||||
|
"statsBytes": "Total size",
|
||||||
|
"statsUsage": "Storage usage",
|
||||||
|
"statsSource": "Upload source",
|
||||||
|
"statsTotalHintVideo": "Total videos",
|
||||||
|
"statsTotalHintFile": "Total files",
|
||||||
|
"statsTotalHintImage": "Total images",
|
||||||
|
"statsVsYesterday": "vs yesterday {value}",
|
||||||
|
"statsVsLastMonth": "vs last month {value}",
|
||||||
|
"transcodePending": "Queued",
|
||||||
|
"transcoding": "Transcoding",
|
||||||
|
"transcodeFailed": "Transcode failed",
|
||||||
|
"searchPlaceholder": "Search file name",
|
||||||
|
"searchEmpty": "Nothing matched",
|
||||||
|
"clearSearch": "Clear search",
|
||||||
|
"file": {
|
||||||
|
"title": "File bed",
|
||||||
|
"subtitle": "Manage uploaded files, copy links or download",
|
||||||
|
"uploadTitle": "Drop files here to upload",
|
||||||
|
"uploadHint": "or click to choose files, up to 10 at a time",
|
||||||
|
"uploadFormats": "Any type allowed (HTML / HTM / SVG blocked), up to 100MB per file",
|
||||||
|
"empty": "No files yet — upload one to get started"
|
||||||
|
},
|
||||||
|
"video": {
|
||||||
|
"title": "Videos",
|
||||||
|
"subtitle": "Manage uploaded videos, copy links or download",
|
||||||
|
"previewTitle": "Video details",
|
||||||
|
"previewSubtitle": "View video info and links",
|
||||||
|
"previewTranscode": "Transcode",
|
||||||
|
"previewThumbLink": "Thumbnail link",
|
||||||
|
"previewKey": "Video path",
|
||||||
|
"transcodeNone": "Not transcoded",
|
||||||
|
"transcodeDone": "Done (m3u8)",
|
||||||
|
"uploadTitle": "Drop videos here to upload",
|
||||||
|
"uploadHint": "or click to choose video files, up to 10 at a time",
|
||||||
|
"uploadFormats": "MP4 / WebM / MOV / MKV and more, up to 100MB per file",
|
||||||
|
"empty": "No videos yet — upload one to get started"
|
||||||
|
}
|
||||||
|
},
|
||||||
"userSettings": {
|
"userSettings": {
|
||||||
"loadFailed": "Failed to load settings",
|
"loadFailed": "Failed to load settings",
|
||||||
"saveFailed": "Failed to save settings"
|
"saveFailed": "Failed to save settings"
|
||||||
|
|||||||
+98
-1
@@ -24,6 +24,8 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"api": "API",
|
"api": "API",
|
||||||
"gallery": "图库",
|
"gallery": "图库",
|
||||||
|
"videoLibrary": "视频库",
|
||||||
|
"fileLibrary": "文件库",
|
||||||
"stats": "统计",
|
"stats": "统计",
|
||||||
"logs": "操作日志",
|
"logs": "操作日志",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
@@ -298,8 +300,25 @@
|
|||||||
"qualityMedium": "中等质量",
|
"qualityMedium": "中等质量",
|
||||||
"qualityLow": "有损压缩",
|
"qualityLow": "有损压缩",
|
||||||
"convenience": "便捷功能",
|
"convenience": "便捷功能",
|
||||||
|
"videoProcessing": "视频处理",
|
||||||
|
"transcodeToHls": "转码成 m3u8",
|
||||||
|
"transcodeToHlsHint": "自动切片为 HLS,会占用 CPU。",
|
||||||
|
"concurrentHint": "同时转码数",
|
||||||
|
"threadsHint": "单任务 CPU 上限",
|
||||||
|
"transcodeConcurrency": "并发转码数量",
|
||||||
|
"transcodeThreads": "限制核心数",
|
||||||
|
"unitCount": "个",
|
||||||
|
"transcodeQuality": "转码质量",
|
||||||
|
"presetCopy": "直接封装",
|
||||||
|
"presetSpeed": "速度",
|
||||||
|
"presetBalanced": "平衡",
|
||||||
|
"presetQuality": "质量",
|
||||||
|
"presetCompress": "压缩",
|
||||||
|
"compressBitrate": "目标码率",
|
||||||
|
"transcodeLoadFailed": "读取转码设置失败",
|
||||||
|
"transcodeSaveFailed": "保存转码设置失败",
|
||||||
"autoCopy": "上传成功自动复制链接",
|
"autoCopy": "上传成功自动复制链接",
|
||||||
"autoCopyHint": "图片上传成功后,自动复制图片链接到剪贴板。"
|
"autoCopyHint": "上传成功后,自动复制链接到剪贴板。"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "系统设置",
|
"title": "系统设置",
|
||||||
@@ -356,6 +375,12 @@
|
|||||||
"refererUnrestricted": "未限制",
|
"refererUnrestricted": "未限制",
|
||||||
"loginVerification": "登录验证",
|
"loginVerification": "登录验证",
|
||||||
"loginVerificationHint": "登录与注册时使用的人机验证方式,三选一",
|
"loginVerificationHint": "登录与注册时使用的人机验证方式,三选一",
|
||||||
|
"fileBed": "文件床",
|
||||||
|
"fileBedHint": "文件直链的访问方式,切换后立即生效",
|
||||||
|
"fileBedPublic": "公开访问",
|
||||||
|
"fileBedPublicHint": "任何人拿到链接都能下载,适合分享。",
|
||||||
|
"fileBedToken": "Token 访问",
|
||||||
|
"fileBedTokenHint": "下载需要携带 Token(链接后追加 ?token=),适合私密文件。",
|
||||||
"loginVerificationSlider": "本地滑块",
|
"loginVerificationSlider": "本地滑块",
|
||||||
"loginVerificationSliderHint": "内置滑块验证,无需第三方服务。",
|
"loginVerificationSliderHint": "内置滑块验证,无需第三方服务。",
|
||||||
"loginVerificationTurnstile": "Cloudflare Turnstile",
|
"loginVerificationTurnstile": "Cloudflare Turnstile",
|
||||||
@@ -816,6 +841,8 @@
|
|||||||
"storageBackend": "存储:{name}"
|
"storageBackend": "存储:{name}"
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
|
"confirmDescVideo": "确定要删除选中的 {count} 个视频吗?此操作不可恢复。",
|
||||||
|
"confirmDescFile": "确定要删除选中的 {count} 个文件吗?此操作不可恢复。",
|
||||||
"confirmTitle": "确认删除",
|
"confirmTitle": "确认删除",
|
||||||
"confirmDesc": "确定要删除选中的 {count} 张图片吗?此操作不可恢复。",
|
"confirmDesc": "确定要删除选中的 {count} 张图片吗?此操作不可恢复。",
|
||||||
"confirm": "确认删除"
|
"confirm": "确认删除"
|
||||||
@@ -970,6 +997,76 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"files": {
|
||||||
|
"maxFiles": "每次最多上传 {n} 个文件",
|
||||||
|
"uploadSuccess": "已上传 {n} 个文件",
|
||||||
|
"uploadFailed": "上传失败,请稍后重试",
|
||||||
|
"loadFailed": "读取文件列表失败",
|
||||||
|
"copyLink": "复制直链",
|
||||||
|
"download": "下载",
|
||||||
|
"deleted": "已删除",
|
||||||
|
"deleteFailed": "删除失败"
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"video": "视频",
|
||||||
|
"file": "文件",
|
||||||
|
"imageFull": "个人轻量图床",
|
||||||
|
"videoFull": "个人轻量视频床",
|
||||||
|
"fileFull": "个人轻量文件床"
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"play": "播放",
|
||||||
|
"copyVideoLink": "复制视频链接",
|
||||||
|
"copyThumbLink": "复制缩略图链接",
|
||||||
|
"selectedCount": "已选择 {n} 项",
|
||||||
|
"selectFirst": "请先选择视频",
|
||||||
|
"clearSelection": "取消选择",
|
||||||
|
"reset": "重置",
|
||||||
|
"batchDelete": "批量删除",
|
||||||
|
"deleted": "已删除 {n} 个视频",
|
||||||
|
"fileDeleted": "已删除 {n} 个文件",
|
||||||
|
"deletePartialFail": "{n} 个视频删除失败",
|
||||||
|
"statsToday": "今日上传",
|
||||||
|
"statsMonth": "本月上传",
|
||||||
|
"statsTotal": "累计视频",
|
||||||
|
"statsBytes": "总占用",
|
||||||
|
"statsUsage": "存储使用率",
|
||||||
|
"statsSource": "上传来源分布",
|
||||||
|
"statsTotalHintVideo": "总视频数",
|
||||||
|
"statsTotalHintFile": "总文件数",
|
||||||
|
"statsTotalHintImage": "总图片数",
|
||||||
|
"statsVsYesterday": "较昨日 {value}",
|
||||||
|
"statsVsLastMonth": "较上月 {value}",
|
||||||
|
"transcodePending": "等待转码中",
|
||||||
|
"transcoding": "转码中",
|
||||||
|
"transcodeFailed": "转码失败",
|
||||||
|
"searchPlaceholder": "搜索文件名",
|
||||||
|
"searchEmpty": "没有匹配的内容",
|
||||||
|
"clearSearch": "清除搜索",
|
||||||
|
"file": {
|
||||||
|
"title": "文件床",
|
||||||
|
"subtitle": "管理已上传的文件,复制直链或下载",
|
||||||
|
"uploadTitle": "拖拽文件到此处上传",
|
||||||
|
"uploadHint": "或点击选择文件,单次最多 10 个",
|
||||||
|
"uploadFormats": "支持任意类型(禁止 HTML / HTM / SVG),单个文件最大 100MB",
|
||||||
|
"empty": "还没有文件,上传一个试试"
|
||||||
|
},
|
||||||
|
"video": {
|
||||||
|
"title": "视频",
|
||||||
|
"subtitle": "管理已上传的视频,复制直链或下载",
|
||||||
|
"previewTitle": "视频详情",
|
||||||
|
"previewSubtitle": "查看视频信息与链接",
|
||||||
|
"previewTranscode": "转码状态",
|
||||||
|
"previewThumbLink": "缩略图链接",
|
||||||
|
"previewKey": "视频路径",
|
||||||
|
"transcodeNone": "未转码",
|
||||||
|
"transcodeDone": "已完成(m3u8)",
|
||||||
|
"uploadTitle": "拖拽视频到此处上传",
|
||||||
|
"uploadHint": "或点击选择视频文件,单次最多 10 个",
|
||||||
|
"uploadFormats": "支持 MP4 / WebM / MOV / MKV 等,单个文件最大 100MB",
|
||||||
|
"empty": "还没有视频,上传一个试试"
|
||||||
|
}
|
||||||
|
},
|
||||||
"userSettings": {
|
"userSettings": {
|
||||||
"loadFailed": "加载设置失败",
|
"loadFailed": "加载设置失败",
|
||||||
"saveFailed": "保存设置失败"
|
"saveFailed": "保存设置失败"
|
||||||
|
|||||||
Generated
+7
@@ -16,6 +16,7 @@
|
|||||||
"@nuxt/ui": "^4.9.0",
|
"@nuxt/ui": "^4.9.0",
|
||||||
"@nuxtjs/i18n": "^10.6.0",
|
"@nuxtjs/i18n": "^10.6.0",
|
||||||
"cap-widget": "^0.1.58",
|
"cap-widget": "^0.1.58",
|
||||||
|
"hls.js": "^1.7.3",
|
||||||
"nuxt": "^4.4.8",
|
"nuxt": "^4.4.8",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
"tailwindcss": "^4.3.1",
|
"tailwindcss": "^4.3.1",
|
||||||
@@ -13843,6 +13844,12 @@
|
|||||||
"integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==",
|
"integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/hls.js": {
|
||||||
|
"version": "1.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz",
|
||||||
|
"integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/hookable": {
|
"node_modules/hookable": {
|
||||||
"version": "6.1.1",
|
"version": "6.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/hookable/-/hookable-6.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/hookable/-/hookable-6.1.1.tgz",
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
"@nuxt/ui": "^4.9.0",
|
"@nuxt/ui": "^4.9.0",
|
||||||
"@nuxtjs/i18n": "^10.6.0",
|
"@nuxtjs/i18n": "^10.6.0",
|
||||||
"cap-widget": "^0.1.58",
|
"cap-widget": "^0.1.58",
|
||||||
|
"hls.js": "^1.7.3",
|
||||||
"nuxt": "^4.4.8",
|
"nuxt": "^4.4.8",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
"tailwindcss": "^4.3.1",
|
"tailwindcss": "^4.3.1",
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { BatchDeleteResponse } from '~/types/image'
|
||||||
|
import { assertImageOwnership, requireApiOrAdminAuth } from '../../utils/access'
|
||||||
|
import { createApiError } from '../../utils/api-error'
|
||||||
|
import { logActivity } from '../../utils/activity-log'
|
||||||
|
import { deleteImageIndex, getImageIndexRow } from '../../utils/image-index'
|
||||||
|
import { validateFileKey, validateVideoKey } from '../../utils/image-key'
|
||||||
|
import { deleteImage, headImage } from '../../utils/storage'
|
||||||
|
import { cancelTranscode, removeHlsArtifacts } from '../../utils/transcode'
|
||||||
|
import { MAX_DELETE_BATCH } from '../../utils/constants'
|
||||||
|
|
||||||
|
interface BatchDeleteBody {
|
||||||
|
keys?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireApiOrAdminAuth(event)
|
||||||
|
|
||||||
|
const body = await readBody<BatchDeleteBody>(event).catch((): BatchDeleteBody => ({}))
|
||||||
|
const keys = Array.isArray(body.keys)
|
||||||
|
? body.keys.map(key => String(key)).filter(Boolean)
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (!keys.length) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '未提供要删除的文件', 400)
|
||||||
|
}
|
||||||
|
if (keys.length > MAX_DELETE_BATCH) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', `每次最多删除 ${MAX_DELETE_BATCH} 个文件`, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted: string[] = []
|
||||||
|
const failed: BatchDeleteResponse['failed'] = []
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!validateFileKey(key) && !validateVideoKey(key)) {
|
||||||
|
failed.push({ key, error: { code: 'INVALID_IMAGE_KEY', message: '无效的文件路径' } })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await headImage(key)
|
||||||
|
const indexed = getImageIndexRow(key)
|
||||||
|
|
||||||
|
if (!existing && !indexed) {
|
||||||
|
failed.push({ key, error: { code: 'IMAGE_NOT_FOUND', message: '文件不存在' } })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerId = existing?.userId ?? indexed?.user_id ?? null
|
||||||
|
await assertImageOwnership(event, ownerId)
|
||||||
|
|
||||||
|
if (validateVideoKey(key)) {
|
||||||
|
cancelTranscode(key)
|
||||||
|
await removeHlsArtifacts(key).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await deleteImage(key, key)
|
||||||
|
} else {
|
||||||
|
deleteImageIndex(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
logActivity(event, {
|
||||||
|
action: 'delete',
|
||||||
|
key,
|
||||||
|
originalName: existing?.originalName ?? indexed?.original_name ?? key,
|
||||||
|
size: existing?.size ?? indexed?.size ?? 0,
|
||||||
|
contentType: existing?.contentType ?? indexed?.content_type ?? 'application/octet-stream',
|
||||||
|
userId: ownerId,
|
||||||
|
backendId: existing?.backendId ?? indexed?.backend_id ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
deleted.push(key)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : '删除失败'
|
||||||
|
failed.push({ key, error: { code: 'DELETE_FAILED', message } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: BatchDeleteResponse = { success: failed.length === 0, deleted, failed }
|
||||||
|
return response
|
||||||
|
})
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { DeleteResponse } from '~/types/image'
|
||||||
|
import { assertImageOwnership, requireApiOrAdminAuth } from '../../utils/access'
|
||||||
|
import { createApiError } from '../../utils/api-error'
|
||||||
|
import { logActivity } from '../../utils/activity-log'
|
||||||
|
import { deleteImageIndex, getImageIndexRow } from '../../utils/image-index'
|
||||||
|
import { validateFileKey, validateVideoKey } from '../../utils/image-key'
|
||||||
|
import { deleteImage, headImage } from '../../utils/storage'
|
||||||
|
import { cancelTranscode, removeHlsArtifacts } from '../../utils/transcode'
|
||||||
|
import { logInfo } from '../../utils/logger'
|
||||||
|
|
||||||
|
interface DeleteBody {
|
||||||
|
key?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
await requireApiOrAdminAuth(event)
|
||||||
|
|
||||||
|
const query = getQuery(event)
|
||||||
|
const body = await readBody<DeleteBody>(event).catch(() => null)
|
||||||
|
const key = (typeof query.key === 'string' ? query.key : undefined) ?? body?.key
|
||||||
|
|
||||||
|
if (!key || (!validateFileKey(key) && !validateVideoKey(key))) {
|
||||||
|
createApiError(event, 'INVALID_IMAGE_KEY', '无效的文件路径', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await headImage(key)
|
||||||
|
const indexed = getImageIndexRow(key)
|
||||||
|
|
||||||
|
if (!existing && !indexed) {
|
||||||
|
createApiError(event, 'IMAGE_NOT_FOUND', '文件不存在', 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalName = existing?.originalName ?? indexed?.original_name ?? key
|
||||||
|
const size = existing?.size ?? indexed?.size ?? 0
|
||||||
|
const contentType = existing?.contentType ?? indexed?.content_type ?? 'application/octet-stream'
|
||||||
|
const ownerId = existing?.userId ?? indexed?.user_id ?? null
|
||||||
|
const backendId = existing?.backendId ?? indexed?.backend_id ?? null
|
||||||
|
|
||||||
|
await assertImageOwnership(event, ownerId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (validateVideoKey(key)) {
|
||||||
|
// 立即中断正在进行的转码,再清理产物与原文件
|
||||||
|
cancelTranscode(key)
|
||||||
|
await removeHlsArtifacts(key).catch(() => {})
|
||||||
|
}
|
||||||
|
if (existing) {
|
||||||
|
await deleteImage(key, key)
|
||||||
|
} else {
|
||||||
|
deleteImageIndex(key)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
createApiError(event, 'DELETE_FAILED', '删除失败', 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
logActivity(event, {
|
||||||
|
action: 'delete',
|
||||||
|
key,
|
||||||
|
originalName,
|
||||||
|
size,
|
||||||
|
contentType,
|
||||||
|
userId: ownerId,
|
||||||
|
backendId
|
||||||
|
})
|
||||||
|
|
||||||
|
logInfo('file delete success', { key, size })
|
||||||
|
|
||||||
|
const response: DeleteResponse = { success: true }
|
||||||
|
return response
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error && typeof error === 'object' && 'statusCode' in error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
createApiError(event, 'DELETE_FAILED', '删除失败', 500)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import type { FileListResponse } from '~/types/file'
|
||||||
|
import type { MediaType, TranscodeStatus } from '../../utils/storage/types'
|
||||||
|
import { requireApiOrAdminAuth, getImageUserFilter } from '../../utils/access'
|
||||||
|
import { getCurrentUser } from '../../utils/auth'
|
||||||
|
import { DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT } from '../../utils/constants'
|
||||||
|
import { getUploadSourcesForKeys, listUserIdUsernameMap } from '../../utils/db'
|
||||||
|
import { getFileBedAccessMode } from '../../utils/env'
|
||||||
|
import { buildFileItem } from '../../utils/file-bed'
|
||||||
|
import {
|
||||||
|
resolveImageOwner,
|
||||||
|
resolveImageStorage,
|
||||||
|
resolveImageUrl
|
||||||
|
} from '../../utils/image-response'
|
||||||
|
import { getTagsForImageKeys } from '../../utils/tags'
|
||||||
|
import { listStorageBackendNameMap } from '../../utils/storage-backends'
|
||||||
|
import { createApiError } from '../../utils/api-error'
|
||||||
|
import {
|
||||||
|
readBackendIdQuery,
|
||||||
|
readTagFilterQuery,
|
||||||
|
readUploadSourceQuery
|
||||||
|
} from '../../utils/image-query'
|
||||||
|
import { listImages, searchImages } from '../../utils/storage'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireApiOrAdminAuth(event)
|
||||||
|
|
||||||
|
const query = getQuery(event)
|
||||||
|
|
||||||
|
const limitRaw = Number(query.limit ?? DEFAULT_LIST_LIMIT)
|
||||||
|
const limit = Number.isFinite(limitRaw)
|
||||||
|
? Math.min(Math.max(1, Math.floor(limitRaw)), MAX_LIST_LIMIT)
|
||||||
|
: DEFAULT_LIST_LIMIT
|
||||||
|
|
||||||
|
const pageRaw = Number(query.page ?? 1)
|
||||||
|
const page = Number.isFinite(pageRaw) && pageRaw > 0 ? Math.floor(pageRaw) : 1
|
||||||
|
|
||||||
|
const search = typeof query.q === 'string' ? query.q.trim() : ''
|
||||||
|
const userFilter = await getImageUserFilter(event)
|
||||||
|
|
||||||
|
const backendId = readBackendIdQuery(query)
|
||||||
|
if (backendId === null) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
|
||||||
|
}
|
||||||
|
const uploadSource = readUploadSourceQuery(query)
|
||||||
|
if (uploadSource === null) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '无效的上传来源', 400)
|
||||||
|
}
|
||||||
|
const tagFilter = readTagFilterQuery(query)
|
||||||
|
if (tagFilter === null) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '无效的标签筛选参数', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件床按类型分工:视频库看 media_type=video,文件库看 media_type=file
|
||||||
|
const kindRaw = typeof query.kind === 'string' ? query.kind.trim() : ''
|
||||||
|
if (kindRaw && kindRaw !== 'file' && kindRaw !== 'video') {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '无效的文件类型筛选', 400)
|
||||||
|
}
|
||||||
|
const mediaType: MediaType = kindRaw === 'video' ? 'video' : 'file'
|
||||||
|
// 视频库只收录转码已结束的条目;processing=1 用于上传页查仍在转码中的视频
|
||||||
|
const processingRaw = query.processing
|
||||||
|
const processingOnly = processingRaw === '1' || processingRaw === 'true'
|
||||||
|
const transcodeStatuses: TranscodeStatus[] | undefined = mediaType === 'video'
|
||||||
|
? (processingOnly ? ['pending', 'running'] : ['none', 'done', 'failed'])
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const listing = search
|
||||||
|
? await searchImages({
|
||||||
|
query: search,
|
||||||
|
limit,
|
||||||
|
page,
|
||||||
|
userFilter,
|
||||||
|
mediaType,
|
||||||
|
transcodeStatuses,
|
||||||
|
backendId,
|
||||||
|
uploadSource,
|
||||||
|
tagFilter
|
||||||
|
})
|
||||||
|
: await listImages({
|
||||||
|
limit,
|
||||||
|
page,
|
||||||
|
userFilter,
|
||||||
|
mediaType,
|
||||||
|
transcodeStatuses,
|
||||||
|
backendId,
|
||||||
|
uploadSource,
|
||||||
|
tagFilter
|
||||||
|
})
|
||||||
|
|
||||||
|
const user = await getCurrentUser(event)
|
||||||
|
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
|
||||||
|
const backendMap = listStorageBackendNameMap()
|
||||||
|
const sourceMap = getUploadSourcesForKeys(listing.items.map(item => item.key))
|
||||||
|
|
||||||
|
const mapped = listing.items.map((stored) => {
|
||||||
|
const owner = resolveImageOwner(stored.userId, ownerMap)
|
||||||
|
const storage = resolveImageStorage(stored.backendId, backendMap)
|
||||||
|
const uploadSource = sourceMap.get(stored.key)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...buildFileItem({
|
||||||
|
event,
|
||||||
|
key: stored.key,
|
||||||
|
backendId: stored.backendId,
|
||||||
|
originalName: stored.originalName,
|
||||||
|
contentType: stored.contentType,
|
||||||
|
size: stored.size,
|
||||||
|
uploadedAt: stored.uploadedAt
|
||||||
|
}),
|
||||||
|
...(owner ? { owner } : {}),
|
||||||
|
...(storage ? { storage } : {}),
|
||||||
|
...(uploadSource === 'web' || uploadSource === 'api' ? { uploadSource } : {}),
|
||||||
|
transcodeStatus: stored.transcodeStatus ?? 'none',
|
||||||
|
transcodeProgress: stored.transcodeProgress ?? 0,
|
||||||
|
...(stored.transcodePreset ? { transcodePreset: stored.transcodePreset } : {}),
|
||||||
|
...(stored.sourceDeleted ? { sourceDeleted: true } : {}),
|
||||||
|
...(stored.thumbKey
|
||||||
|
? { thumbUrl: resolveImageUrl(event, stored.thumbKey, stored.backendId) }
|
||||||
|
: {}),
|
||||||
|
...(stored.hlsKey
|
||||||
|
? {
|
||||||
|
hlsUrl: resolveImageUrl(event, stored.hlsKey, stored.backendId),
|
||||||
|
hlsKey: stored.hlsKey
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const tagMap = getTagsForImageKeys(mapped.map(item => item.key))
|
||||||
|
const items = mapped.map(item => ({ ...item, tags: tagMap.get(item.key) ?? [] }))
|
||||||
|
|
||||||
|
const response: FileListResponse = {
|
||||||
|
items,
|
||||||
|
total: listing.total,
|
||||||
|
page: listing.page,
|
||||||
|
pageSize: listing.pageSize,
|
||||||
|
totalPages: listing.totalPages,
|
||||||
|
accessMode: getFileBedAccessMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
})
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { readMultipartFormData } from 'h3'
|
||||||
|
import type { FileItem, FileUploadResponse } from '~/types/file'
|
||||||
|
import type { UploadErrorItem } from '~/types/image'
|
||||||
|
import {
|
||||||
|
getUploadUserId,
|
||||||
|
requireUploadAuth,
|
||||||
|
verifyApiUploadToken
|
||||||
|
} from '../../utils/access'
|
||||||
|
import { MAX_FILES_PER_UPLOAD } from '../../utils/constants'
|
||||||
|
import { createApiError } from '../../utils/api-error'
|
||||||
|
import { checkUploadRateLimit } from '../../utils/rate-limit'
|
||||||
|
import { processSingleFileUpload } from '../../utils/file-bed'
|
||||||
|
import { getFileBedAccessMode } from '../../utils/env'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireUploadAuth(event)
|
||||||
|
|
||||||
|
const formData = await readMultipartFormData(event)
|
||||||
|
if (!formData?.length) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenField = formData.find(part => part.name === 'token')
|
||||||
|
const formToken = tokenField?.data?.length
|
||||||
|
? new TextDecoder().decode(tokenField.data).trim()
|
||||||
|
: ''
|
||||||
|
checkUploadRateLimit(event, formToken)
|
||||||
|
|
||||||
|
const source = verifyApiUploadToken(event) ? 'api' : 'web'
|
||||||
|
const uploadUserId = await getUploadUserId(event, formToken)
|
||||||
|
|
||||||
|
const fileParts = formData.filter(
|
||||||
|
part => (part.name === 'file' || part.name === 'files') && part.data?.length
|
||||||
|
)
|
||||||
|
if (!fileParts.length) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileParts.length > MAX_FILES_PER_UPLOAD) {
|
||||||
|
createApiError(
|
||||||
|
event,
|
||||||
|
'TOO_MANY_FILES',
|
||||||
|
`每次最多上传 ${MAX_FILES_PER_UPLOAD} 个文件`,
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: FileItem[] = []
|
||||||
|
const errors: UploadErrorItem[] = []
|
||||||
|
|
||||||
|
for (const part of fileParts) {
|
||||||
|
const result = await processSingleFileUpload(event, {
|
||||||
|
bytes: new Uint8Array(part.data),
|
||||||
|
filename: part.filename,
|
||||||
|
contentType: part.type,
|
||||||
|
source,
|
||||||
|
userId: uploadUserId
|
||||||
|
})
|
||||||
|
|
||||||
|
if ('error' in result) {
|
||||||
|
errors.push(result.error)
|
||||||
|
} else {
|
||||||
|
items.push(result.item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: FileUploadResponse = {
|
||||||
|
success: items.length > 0,
|
||||||
|
items,
|
||||||
|
errors,
|
||||||
|
accessMode: getFileBedAccessMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items.length && errors.length) {
|
||||||
|
setResponseStatus(event, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
})
|
||||||
@@ -4,7 +4,7 @@ import { getCurrentUser } from '../../utils/auth'
|
|||||||
import { createApiError } from '../../utils/api-error'
|
import { createApiError } from '../../utils/api-error'
|
||||||
import { MAX_DELETE_BATCH } from '../../utils/constants'
|
import { MAX_DELETE_BATCH } from '../../utils/constants'
|
||||||
import { getImageIndexRow } from '../../utils/image-index'
|
import { getImageIndexRow } from '../../utils/image-index'
|
||||||
import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key'
|
import { toCanonicalImageKey, validateStorageKey } from '../../utils/image-key'
|
||||||
import {
|
import {
|
||||||
addTagsToImage,
|
addTagsToImage,
|
||||||
removeTagFromImage
|
removeTagFromImage
|
||||||
@@ -20,7 +20,7 @@ interface BatchTagsBody {
|
|||||||
function validateKeys(keys: string[]): void {
|
function validateKeys(keys: string[]): void {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
if (
|
if (
|
||||||
!validateImageKey(key)
|
!validateStorageKey(key)
|
||||||
&& !toCanonicalImageKey(key)
|
&& !toCanonicalImageKey(key)
|
||||||
&& !getImageIndexRow(key)
|
&& !getImageIndexRow(key)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { getImageUserFilter, requireApiOrAdminAuth } from '../../utils/access'
|
||||||
|
import { createApiError } from '../../utils/api-error'
|
||||||
|
import { getMediaStats } from '../../utils/image-index'
|
||||||
|
import { buildBackendCapacity } from '../../utils/storage-capacity'
|
||||||
|
import { getActiveBackendRow } from '../../utils/storage/resolver'
|
||||||
|
import type { MediaType } from '../../utils/storage/types'
|
||||||
|
|
||||||
|
const ALLOWED_KINDS: MediaType[] = ['image', 'video', 'file']
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireApiOrAdminAuth(event)
|
||||||
|
|
||||||
|
const query = getQuery(event)
|
||||||
|
const kindRaw = typeof query.kind === 'string' ? query.kind.trim() : 'image'
|
||||||
|
const kind = ALLOWED_KINDS.find(item => item === kindRaw)
|
||||||
|
if (!kind) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '无效的统计类型', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const userFilter = await getImageUserFilter(event)
|
||||||
|
const stats = getMediaStats(kind, userFilter)
|
||||||
|
|
||||||
|
const backend = getActiveBackendRow()
|
||||||
|
const capacity = backend
|
||||||
|
? await buildBackendCapacity(
|
||||||
|
backend.id,
|
||||||
|
backend.type,
|
||||||
|
stats.uploadBytesTotal,
|
||||||
|
backend.quota_bytes
|
||||||
|
)
|
||||||
|
: { totalBytes: null, percent: null }
|
||||||
|
|
||||||
|
return {
|
||||||
|
...stats,
|
||||||
|
storageUsage: {
|
||||||
|
usedBytes: stats.uploadBytesTotal,
|
||||||
|
totalBytes: capacity.totalBytes,
|
||||||
|
percent: capacity.percent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -15,9 +15,11 @@ import {
|
|||||||
SETTINGS_TURNSTILE_SECRET_KEY,
|
SETTINGS_TURNSTILE_SECRET_KEY,
|
||||||
SETTINGS_CAP_API_ENDPOINT,
|
SETTINGS_CAP_API_ENDPOINT,
|
||||||
SETTINGS_CAP_SECRET,
|
SETTINGS_CAP_SECRET,
|
||||||
|
SETTINGS_FILE_BED_ACCESS,
|
||||||
setAllowRegistration
|
setAllowRegistration
|
||||||
} from '../../utils/db'
|
} from '../../utils/db'
|
||||||
import {
|
import {
|
||||||
|
parseFileBedAccessMode,
|
||||||
getSettingsPayload,
|
getSettingsPayload,
|
||||||
isValidImageBaseUrl,
|
isValidImageBaseUrl,
|
||||||
isValidRefererHost,
|
isValidRefererHost,
|
||||||
@@ -68,6 +70,7 @@ interface SettingsPatchBody {
|
|||||||
loginRateMax?: unknown
|
loginRateMax?: unknown
|
||||||
loginRateWindowMinutes?: unknown
|
loginRateWindowMinutes?: unknown
|
||||||
preserveOriginalUpload?: unknown
|
preserveOriginalUpload?: unknown
|
||||||
|
fileBedAccess?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@@ -333,6 +336,14 @@ export default defineEventHandler(async (event) => {
|
|||||||
setUploadPolicySettings({ preserveOriginalUpload: body.preserveOriginalUpload })
|
setUploadPolicySettings({ preserveOriginalUpload: body.preserveOriginalUpload })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (body.fileBedAccess !== undefined) {
|
||||||
|
const mode = parseFileBedAccessMode(String(body.fileBedAccess))
|
||||||
|
if (!mode) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '文件床访问方式无效', 400)
|
||||||
|
}
|
||||||
|
setSetting(SETTINGS_FILE_BED_ACCESS, mode)
|
||||||
|
}
|
||||||
|
|
||||||
logActivity(event, {
|
logActivity(event, {
|
||||||
action: 'settings',
|
action: 'settings',
|
||||||
key: 'settings/system',
|
key: 'settings/system',
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { requireUserAuth } from '../../utils/access'
|
||||||
|
import {
|
||||||
|
getTranscodeSettings,
|
||||||
|
MAX_TRANSCODE_CONCURRENCY,
|
||||||
|
MAX_TRANSCODE_THREADS,
|
||||||
|
MAX_TRANSCODE_BITRATE_PERCENT,
|
||||||
|
MIN_TRANSCODE_BITRATE_PERCENT,
|
||||||
|
TRANSCODE_PRESETS
|
||||||
|
} from '../../utils/transcode'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireUserAuth(event)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...getTranscodeSettings(),
|
||||||
|
maxConcurrency: MAX_TRANSCODE_CONCURRENCY,
|
||||||
|
maxThreads: MAX_TRANSCODE_THREADS,
|
||||||
|
minBitratePercent: MIN_TRANSCODE_BITRATE_PERCENT,
|
||||||
|
maxBitratePercent: MAX_TRANSCODE_BITRATE_PERCENT,
|
||||||
|
presets: TRANSCODE_PRESETS
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { requireAdminAuth } from '../../utils/access'
|
||||||
|
import { createApiError } from '../../utils/api-error'
|
||||||
|
import {
|
||||||
|
MAX_TRANSCODE_CONCURRENCY,
|
||||||
|
MAX_TRANSCODE_THREADS,
|
||||||
|
MAX_TRANSCODE_BITRATE_PERCENT,
|
||||||
|
MIN_TRANSCODE_BITRATE_PERCENT,
|
||||||
|
parseTranscodePreset,
|
||||||
|
setTranscodeSettings
|
||||||
|
} from '../../utils/transcode'
|
||||||
|
|
||||||
|
interface TranscodeSettingsPatch {
|
||||||
|
concurrency?: unknown
|
||||||
|
threads?: unknown
|
||||||
|
preset?: unknown
|
||||||
|
bitratePercent?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAdminAuth(event)
|
||||||
|
|
||||||
|
const body = await readBody<TranscodeSettingsPatch>(event).catch(
|
||||||
|
(): TranscodeSettingsPatch => ({})
|
||||||
|
)
|
||||||
|
|
||||||
|
const full: Parameters<typeof setTranscodeSettings>[0] = {}
|
||||||
|
|
||||||
|
if (body.concurrency !== undefined) {
|
||||||
|
const value = Number(body.concurrency)
|
||||||
|
if (!Number.isInteger(value) || value < 1 || value > MAX_TRANSCODE_CONCURRENCY) {
|
||||||
|
createApiError(
|
||||||
|
event,
|
||||||
|
'INVALID_REQUEST',
|
||||||
|
`并发转码数量需为 1–${MAX_TRANSCODE_CONCURRENCY} 的整数`,
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
full.concurrency = value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.threads !== undefined) {
|
||||||
|
const value = Number(body.threads)
|
||||||
|
if (!Number.isInteger(value) || value < 1 || value > MAX_TRANSCODE_THREADS) {
|
||||||
|
createApiError(
|
||||||
|
event,
|
||||||
|
'INVALID_REQUEST',
|
||||||
|
`限制核心数需为 1–${MAX_TRANSCODE_THREADS} 的整数`,
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
full.threads = value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.preset !== undefined) {
|
||||||
|
const preset = parseTranscodePreset(String(body.preset))
|
||||||
|
if (!preset) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '无效的转码质量档位', 400)
|
||||||
|
}
|
||||||
|
full.preset = preset
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.bitratePercent !== undefined) {
|
||||||
|
const value = Number(body.bitratePercent)
|
||||||
|
if (
|
||||||
|
!Number.isInteger(value)
|
||||||
|
|| value < MIN_TRANSCODE_BITRATE_PERCENT
|
||||||
|
|| value > MAX_TRANSCODE_BITRATE_PERCENT
|
||||||
|
) {
|
||||||
|
createApiError(
|
||||||
|
event,
|
||||||
|
'INVALID_REQUEST',
|
||||||
|
`码率需为 ${MIN_TRANSCODE_BITRATE_PERCENT}–${MAX_TRANSCODE_BITRATE_PERCENT} 的整数`,
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
full.bitratePercent = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return setTranscodeSettings(full)
|
||||||
|
})
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { readMultipartFormData } from 'h3'
|
||||||
|
import type { FileItem, FileUploadResponse } from '~/types/file'
|
||||||
|
import type { UploadErrorItem } from '~/types/image'
|
||||||
|
import {
|
||||||
|
getUploadUserId,
|
||||||
|
requireUploadAuth,
|
||||||
|
verifyApiUploadToken
|
||||||
|
} from '../../utils/access'
|
||||||
|
import { MAX_FILES_PER_UPLOAD } from '../../utils/constants'
|
||||||
|
import { createApiError } from '../../utils/api-error'
|
||||||
|
import { checkUploadRateLimit } from '../../utils/rate-limit'
|
||||||
|
import { processSingleVideoUpload } from '../../utils/file-bed'
|
||||||
|
import { enqueueTranscode } from '../../utils/transcode'
|
||||||
|
import { getFileBedAccessMode } from '../../utils/env'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireUploadAuth(event)
|
||||||
|
|
||||||
|
const formData = await readMultipartFormData(event)
|
||||||
|
if (!formData?.length) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenField = formData.find(part => part.name === 'token')
|
||||||
|
const formToken = tokenField?.data?.length
|
||||||
|
? new TextDecoder().decode(tokenField.data).trim()
|
||||||
|
: ''
|
||||||
|
checkUploadRateLimit(event, formToken)
|
||||||
|
|
||||||
|
const source = verifyApiUploadToken(event) ? 'api' : 'web'
|
||||||
|
const uploadUserId = await getUploadUserId(event, formToken)
|
||||||
|
|
||||||
|
const transcodeField = formData.find(part => part.name === 'transcode')
|
||||||
|
const wantTranscode = transcodeField?.data?.length
|
||||||
|
? ['1', 'true', 'yes'].includes(
|
||||||
|
new TextDecoder().decode(transcodeField.data).trim().toLowerCase()
|
||||||
|
)
|
||||||
|
: false
|
||||||
|
|
||||||
|
const fileParts = formData.filter(
|
||||||
|
part => (part.name === 'file' || part.name === 'files' || part.name === 'video')
|
||||||
|
&& part.data?.length
|
||||||
|
)
|
||||||
|
if (!fileParts.length) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileParts.length > MAX_FILES_PER_UPLOAD) {
|
||||||
|
createApiError(
|
||||||
|
event,
|
||||||
|
'TOO_MANY_FILES',
|
||||||
|
`每次最多上传 ${MAX_FILES_PER_UPLOAD} 个视频`,
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: FileItem[] = []
|
||||||
|
const errors: UploadErrorItem[] = []
|
||||||
|
|
||||||
|
for (const part of fileParts) {
|
||||||
|
const result = await processSingleVideoUpload(event, {
|
||||||
|
bytes: new Uint8Array(part.data),
|
||||||
|
filename: part.filename,
|
||||||
|
contentType: part.type,
|
||||||
|
source,
|
||||||
|
userId: uploadUserId
|
||||||
|
})
|
||||||
|
|
||||||
|
if ('error' in result) {
|
||||||
|
errors.push(result.error)
|
||||||
|
} else {
|
||||||
|
items.push(result.item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: FileUploadResponse = {
|
||||||
|
success: items.length > 0,
|
||||||
|
items,
|
||||||
|
errors,
|
||||||
|
accessMode: getFileBedAccessMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wantTranscode) {
|
||||||
|
for (const item of items) {
|
||||||
|
enqueueTranscode(item.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items.length && errors.length) {
|
||||||
|
setResponseStatus(event, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
})
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
import { getDataDir } from '../utils/data-dir'
|
import { getDataDir } from '../utils/data-dir'
|
||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
import { runAutoDeleteCleanup } from '../utils/auto-delete'
|
import { runAutoDeleteCleanup } from '../utils/auto-delete'
|
||||||
import { migrateLocalImagesToIndex, purgeOrphanImageIndexRows, reconcileLegacyImageKeys, repairLocalImageIndexMetadata } from '../utils/image-index'
|
import { migrateLocalImagesToIndex, purgeOrphanImageIndexRows, reconcileLegacyImageKeys, repairLocalImageIndexMetadata } from '../utils/image-index'
|
||||||
import { ensureDefaultBackends } from '../utils/storage-backends'
|
import { ensureDefaultBackends } from '../utils/storage-backends'
|
||||||
|
import {
|
||||||
|
cleanupTranscodedSources,
|
||||||
|
killOrphanFfmpeg,
|
||||||
|
purgeOrphanVideoAssets,
|
||||||
|
resumePendingTranscodes
|
||||||
|
} from '../utils/transcode'
|
||||||
import { logException, logInfo } from '../utils/logger'
|
import { logException, logInfo } from '../utils/logger'
|
||||||
|
|
||||||
export default defineNitroPlugin(() => {
|
export default defineNitroPlugin(() => {
|
||||||
@@ -17,10 +26,26 @@ export default defineNitroPlugin(() => {
|
|||||||
|
|
||||||
ensureDefaultBackends()
|
ensureDefaultBackends()
|
||||||
|
|
||||||
|
/** 清理上次异常退出留下的转码临时目录 */
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(tmpdir(), { withFileTypes: true })
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory() || !entry.name.startsWith('pichost-hls-')) continue
|
||||||
|
await fs.rm(join(tmpdir(), entry.name), { recursive: true, force: true })
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 清理失败不影响启动
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const dataDir = getDataDir()
|
const dataDir = getDataDir()
|
||||||
try {
|
try {
|
||||||
logInfo('开始同步图片索引:扫描本地磁盘', { path: `${dataDir}/images/` })
|
logInfo('开始同步存储索引:扫描本地磁盘', {
|
||||||
|
path: `${dataDir}/images/, ${dataDir}/files/, ${dataDir}/videos/`
|
||||||
|
})
|
||||||
|
|
||||||
const inserted = await migrateLocalImagesToIndex()
|
const inserted = await migrateLocalImagesToIndex()
|
||||||
if (inserted > 0) {
|
if (inserted > 0) {
|
||||||
@@ -54,6 +79,18 @@ export default defineNitroPlugin(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logInfo('图片索引同步结束')
|
logInfo('图片索引同步结束')
|
||||||
|
|
||||||
|
logInfo('开始恢复未完成的视频转码任务')
|
||||||
|
await killOrphanFfmpeg()
|
||||||
|
const purgedAssets = await purgeOrphanVideoAssets()
|
||||||
|
if (purgedAssets > 0) {
|
||||||
|
logInfo('清理无主的视频附属文件', { removed: purgedAssets })
|
||||||
|
}
|
||||||
|
const removedSources = await cleanupTranscodedSources()
|
||||||
|
if (removedSources > 0) {
|
||||||
|
logInfo('清理转码完成后的源视频', { removed: removedSources })
|
||||||
|
}
|
||||||
|
resumePendingTranscodes()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logException('图片索引同步失败', error)
|
logException('图片索引同步失败', error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,3 +286,21 @@ export async function getUploadUserId(
|
|||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 文件床直链在 Token 模式下的校验:Auth-Token 头、?token= 查询参数或已登录会话 */
|
||||||
|
export async function isFileAccessAuthorized(event: H3Event): Promise<boolean> {
|
||||||
|
if (devBypass(event)) return true
|
||||||
|
if (verifyApiUploadToken(event)) return true
|
||||||
|
|
||||||
|
const queryToken = getQuery(event).token
|
||||||
|
if (
|
||||||
|
typeof queryToken === 'string'
|
||||||
|
&& queryToken
|
||||||
|
&& verifyUploadTokenValue(event, queryToken)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getCurrentUser(event)
|
||||||
|
return Boolean(user)
|
||||||
|
}
|
||||||
@@ -17,3 +17,33 @@ export const MAX_LIST_LIMIT = 100
|
|||||||
|
|
||||||
export const IMAGE_CACHE_CONTROL
|
export const IMAGE_CACHE_CONTROL
|
||||||
= 'public, max-age=86400, s-maxage=604800, stale-while-revalidate=86400'
|
= 'public, max-age=86400, s-maxage=604800, stale-while-revalidate=86400'
|
||||||
|
|
||||||
|
/** 文件床单文件上限(当前上传全量读入内存,限制在可控范围) */
|
||||||
|
export const MAX_FILE_BED_FILE_SIZE = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
/** 视频床单文件上限 */
|
||||||
|
export const MAX_VIDEO_FILE_SIZE = 500 * 1024 * 1024
|
||||||
|
|
||||||
|
/** 文件床禁止的扩展名:避免上传的文件被浏览器当作脚本或页面执行 */
|
||||||
|
export const FORBIDDEN_FILE_EXTENSIONS = ['html', 'htm', 'svg'] as const
|
||||||
|
|
||||||
|
/** 视频床允许的扩展名 */
|
||||||
|
export const VIDEO_EXTENSIONS = [
|
||||||
|
'mp4',
|
||||||
|
'm4v',
|
||||||
|
'webm',
|
||||||
|
'mov',
|
||||||
|
'mkv',
|
||||||
|
'avi',
|
||||||
|
'flv',
|
||||||
|
'wmv',
|
||||||
|
'mpg',
|
||||||
|
'mpeg',
|
||||||
|
'ts',
|
||||||
|
'm3u8'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** 文件床直链缓存:公开访问可长期缓存,Token 访问不能进共享缓存 */
|
||||||
|
export const FILE_CACHE_CONTROL
|
||||||
|
= 'public, max-age=86400, s-maxage=604800, stale-while-revalidate=86400'
|
||||||
|
export const FILE_PRIVATE_CACHE_CONTROL = 'private, no-store'
|
||||||
@@ -1,3 +1,31 @@
|
|||||||
|
/** 常见文件床类型的扩展名推断(仅在索引缺失元数据时使用) */
|
||||||
|
const EXTRA_TYPES: Record<string, string> = {
|
||||||
|
'pdf': 'application/pdf',
|
||||||
|
'zip': 'application/zip',
|
||||||
|
'7z': 'application/x-7z-compressed',
|
||||||
|
'rar': 'application/vnd.rar',
|
||||||
|
'gz': 'application/gzip',
|
||||||
|
'tar': 'application/x-tar',
|
||||||
|
'txt': 'text/plain; charset=utf-8',
|
||||||
|
'md': 'text/markdown; charset=utf-8',
|
||||||
|
'json': 'application/json',
|
||||||
|
'csv': 'text/csv; charset=utf-8',
|
||||||
|
'mp3': 'audio/mpeg',
|
||||||
|
'mp4': 'video/mp4',
|
||||||
|
'm4v': 'video/x-m4v',
|
||||||
|
'webm': 'video/webm',
|
||||||
|
'mov': 'video/quicktime',
|
||||||
|
'mkv': 'video/x-matroska',
|
||||||
|
'avi': 'video/x-msvideo',
|
||||||
|
'flv': 'video/x-flv',
|
||||||
|
'wmv': 'video/x-ms-wmv',
|
||||||
|
'mpg': 'video/mpeg',
|
||||||
|
'mpeg': 'video/mpeg',
|
||||||
|
'ts': 'video/mp2t',
|
||||||
|
'm3u8': 'application/vnd.apple.mpegurl',
|
||||||
|
'apk': 'application/vnd.android.package-archive'
|
||||||
|
}
|
||||||
|
|
||||||
export function contentTypeFromKey(key: string): string {
|
export function contentTypeFromKey(key: string): string {
|
||||||
const ext = key.split('.').pop()?.toLowerCase()
|
const ext = key.split('.').pop()?.toLowerCase()
|
||||||
switch (ext) {
|
switch (ext) {
|
||||||
@@ -15,6 +43,6 @@ export function contentTypeFromKey(key: string): string {
|
|||||||
case 'ico':
|
case 'ico':
|
||||||
return 'image/x-icon'
|
return 'image/x-icon'
|
||||||
default:
|
default:
|
||||||
return 'application/octet-stream'
|
return (ext && EXTRA_TYPES[ext]) || 'application/octet-stream'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -500,6 +500,11 @@ export function deleteSessionsForUser(userId: number): void {
|
|||||||
export const SETTINGS_API_UPLOAD_TOKEN = 'api_upload_token'
|
export const SETTINGS_API_UPLOAD_TOKEN = 'api_upload_token'
|
||||||
export const SETTINGS_WEBP_QUALITY = 'webp_quality'
|
export const SETTINGS_WEBP_QUALITY = 'webp_quality'
|
||||||
export const SETTINGS_ALLOWED_REFERER_HOSTS = 'allowed_referer_hosts'
|
export const SETTINGS_ALLOWED_REFERER_HOSTS = 'allowed_referer_hosts'
|
||||||
|
export const SETTINGS_FILE_BED_ACCESS = 'file_bed_access'
|
||||||
|
export const SETTINGS_TRANSCODE_CONCURRENCY = 'transcode_concurrency'
|
||||||
|
export const SETTINGS_TRANSCODE_THREADS = 'transcode_threads'
|
||||||
|
export const SETTINGS_TRANSCODE_PRESET = 'transcode_preset'
|
||||||
|
export const SETTINGS_TRANSCODE_BITRATE = 'transcode_bitrate_percent'
|
||||||
export const SETTINGS_HIDE_FOLDER_IN_URL = 'hide_folder_in_url'
|
export const SETTINGS_HIDE_FOLDER_IN_URL = 'hide_folder_in_url'
|
||||||
export const SETTINGS_STORAGE_USE_DATE_PATH = 'storage_use_date_path'
|
export const SETTINGS_STORAGE_USE_DATE_PATH = 'storage_use_date_path'
|
||||||
export const SETTINGS_SITE_BASE_URL = 'site_base_url'
|
export const SETTINGS_SITE_BASE_URL = 'site_base_url'
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
SETTINGS_STORAGE_USE_DATE_PATH,
|
SETTINGS_STORAGE_USE_DATE_PATH,
|
||||||
SETTINGS_IMAGE_BASE_URL,
|
SETTINGS_IMAGE_BASE_URL,
|
||||||
SETTINGS_SITE_BASE_URL,
|
SETTINGS_SITE_BASE_URL,
|
||||||
|
SETTINGS_FILE_BED_ACCESS,
|
||||||
SETTINGS_WEBP_QUALITY,
|
SETTINGS_WEBP_QUALITY,
|
||||||
SETTINGS_LOGIN_VERIFICATION_METHOD,
|
SETTINGS_LOGIN_VERIFICATION_METHOD,
|
||||||
SETTINGS_TURNSTILE_SITE_KEY,
|
SETTINGS_TURNSTILE_SITE_KEY,
|
||||||
@@ -20,6 +21,21 @@ import {
|
|||||||
} from './db'
|
} from './db'
|
||||||
import { getUploadPolicySettingsPayload } from './upload-policy'
|
import { getUploadPolicySettingsPayload } from './upload-policy'
|
||||||
|
|
||||||
|
export type FileBedAccessMode = 'public' | 'token'
|
||||||
|
|
||||||
|
export function parseFileBedAccessMode(
|
||||||
|
raw: string | null | undefined
|
||||||
|
): FileBedAccessMode | null {
|
||||||
|
const value = raw?.trim().toLowerCase()
|
||||||
|
if (value === 'public' || value === 'token') return value
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件床直链访问方式:公开直链或需要 Token(默认公开) */
|
||||||
|
export function getFileBedAccessMode(): FileBedAccessMode {
|
||||||
|
return parseFileBedAccessMode(getSetting(SETTINGS_FILE_BED_ACCESS)) ?? 'public'
|
||||||
|
}
|
||||||
|
|
||||||
export function getRuntimeEnv(
|
export function getRuntimeEnv(
|
||||||
event: H3Event,
|
event: H3Event,
|
||||||
envKey: string,
|
envKey: string,
|
||||||
@@ -688,6 +704,7 @@ export function getSettingsPayload(event: H3Event) {
|
|||||||
autoDeleteDays,
|
autoDeleteDays,
|
||||||
autoDeleteDaysSource: getAutoDeleteDaysSource(),
|
autoDeleteDaysSource: getAutoDeleteDaysSource(),
|
||||||
autoDeleteEnvFallback: parseAutoDeleteDays(getAutoDeleteDaysFromEnv()) ?? 0,
|
autoDeleteEnvFallback: parseAutoDeleteDays(getAutoDeleteDaysFromEnv()) ?? 0,
|
||||||
|
fileBedAccess: getFileBedAccessMode(),
|
||||||
allowRegistration: isAllowRegistration(),
|
allowRegistration: isAllowRegistration(),
|
||||||
loginVerificationMethod: getLoginVerificationMethod(event),
|
loginVerificationMethod: getLoginVerificationMethod(event),
|
||||||
turnstileSiteKey: getTurnstileSiteKeyConfigured(event),
|
turnstileSiteKey: getTurnstileSiteKeyConfigured(event),
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
buildAttachmentDisposition,
|
||||||
|
isForbiddenFileExtension,
|
||||||
|
normalizeFileContentType,
|
||||||
|
normalizeVideoContentType,
|
||||||
|
sanitizeDownloadName
|
||||||
|
} from './file-bed'
|
||||||
|
import {
|
||||||
|
extensionFromFilename,
|
||||||
|
generateFileKey,
|
||||||
|
generateVideoKey,
|
||||||
|
isVideoExtension,
|
||||||
|
validateFileKey,
|
||||||
|
validateStorageKey,
|
||||||
|
validateVideoKey
|
||||||
|
} from './image-key'
|
||||||
|
import { parseFileBedAccessMode } from './env'
|
||||||
|
|
||||||
|
describe('isForbiddenFileExtension', () => {
|
||||||
|
it('blocks html, htm and svg regardless of case', () => {
|
||||||
|
expect(isForbiddenFileExtension('html')).toBe(true)
|
||||||
|
expect(isForbiddenFileExtension('HTM')).toBe(true)
|
||||||
|
expect(isForbiddenFileExtension('svg')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows other extensions', () => {
|
||||||
|
expect(isForbiddenFileExtension('pdf')).toBe(false)
|
||||||
|
expect(isForbiddenFileExtension('')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sanitizeDownloadName', () => {
|
||||||
|
it('keeps unicode names and strips path segments', () => {
|
||||||
|
expect(sanitizeDownloadName('C:\\path/设计稿.pdf')).toBe('设计稿.pdf')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces reserved characters', () => {
|
||||||
|
expect(sanitizeDownloadName('a"b<c>.txt')).toBe('a_b_c_.txt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back when the name is empty', () => {
|
||||||
|
expect(sanitizeDownloadName('')).toBe('file')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildAttachmentDisposition', () => {
|
||||||
|
it('includes an ascii fallback and a utf-8 filename', () => {
|
||||||
|
const value = buildAttachmentDisposition('报告.pdf')
|
||||||
|
expect(value.startsWith('attachment; ')).toBe(true)
|
||||||
|
expect(value).toContain('filename="__.pdf"')
|
||||||
|
expect(value).toContain(`filename*=UTF-8''${encodeURIComponent('报告.pdf')}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('normalizeFileContentType', () => {
|
||||||
|
it('keeps valid mime types', () => {
|
||||||
|
expect(normalizeFileContentType('application/pdf; charset=binary', 'pdf'))
|
||||||
|
.toBe('application/pdf')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('infers from the extension when the declared type is invalid', () => {
|
||||||
|
expect(normalizeFileContentType('not a mime', 'pdf')).toBe('application/pdf')
|
||||||
|
expect(normalizeFileContentType(undefined, 'mp4')).toBe('video/mp4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to octet-stream without an extension', () => {
|
||||||
|
expect(normalizeFileContentType('', '')).toBe('application/octet-stream')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('extensionFromFilename', () => {
|
||||||
|
it('lowercases and sanitizes the extension', () => {
|
||||||
|
expect(extensionFromFilename('a/b/报告.PDF')).toBe('pdf')
|
||||||
|
expect(extensionFromFilename('archive.tar.gz')).toBe('gz')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty for files without a usable extension', () => {
|
||||||
|
expect(extensionFromFilename('README')).toBe('')
|
||||||
|
expect(extensionFromFilename('.env')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateFileKey', () => {
|
||||||
|
it('accepts files/ keys with or without extension', () => {
|
||||||
|
expect(validateFileKey('files/2026/10/abc123.pdf')).toBe(true)
|
||||||
|
expect(validateFileKey('files/abc123')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects traversal, absolute and image keys', () => {
|
||||||
|
expect(validateFileKey('files/../../etc/passwd')).toBe(false)
|
||||||
|
expect(validateFileKey('/files/a.pdf')).toBe(false)
|
||||||
|
expect(validateFileKey('images/2026/10/a.webp')).toBe(false)
|
||||||
|
expect(validateFileKey('../files/a.pdf')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps image keys working through validateStorageKey', () => {
|
||||||
|
expect(validateStorageKey('images/2026/10/a.webp')).toBe(true)
|
||||||
|
expect(validateStorageKey('files/2026/10/a.pdf')).toBe(true)
|
||||||
|
expect(validateStorageKey('other/2026/10/a.pdf')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateFileKey', () => {
|
||||||
|
const date = new Date('2026-10-16T00:00:00Z')
|
||||||
|
|
||||||
|
it('builds a dated key with the sanitized extension', () => {
|
||||||
|
expect(generateFileKey('pdf', date)).toMatch(/^files\/2026\/10\/[A-Za-z0-9]{12}\.pdf$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops unknown extensions and supports flat layout', () => {
|
||||||
|
expect(generateFileKey('../../etc', date)).toMatch(/^files\/2026\/10\/[A-Za-z0-9]{12}$/)
|
||||||
|
expect(generateFileKey('', date, 'flat')).toMatch(/^files\/[A-Za-z0-9]{12}$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseFileBedAccessMode', () => {
|
||||||
|
it('parses known modes and rejects others', () => {
|
||||||
|
expect(parseFileBedAccessMode('public')).toBe('public')
|
||||||
|
expect(parseFileBedAccessMode(' TOKEN ')).toBe('token')
|
||||||
|
expect(parseFileBedAccessMode('other')).toBeNull()
|
||||||
|
expect(parseFileBedAccessMode(null)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isVideoExtension', () => {
|
||||||
|
it('accepts common video containers', () => {
|
||||||
|
expect(isVideoExtension('mp4')).toBe(true)
|
||||||
|
expect(isVideoExtension('MP4')).toBe(true)
|
||||||
|
expect(isVideoExtension('webm')).toBe(true)
|
||||||
|
expect(isVideoExtension('mkv')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-video extensions', () => {
|
||||||
|
expect(isVideoExtension('png')).toBe(false)
|
||||||
|
expect(isVideoExtension('pdf')).toBe(false)
|
||||||
|
expect(isVideoExtension('')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateVideoKey', () => {
|
||||||
|
it('accepts videos/ keys with a video extension', () => {
|
||||||
|
expect(validateVideoKey('videos/2026/10/abc123.mp4')).toBe(true)
|
||||||
|
expect(validateVideoKey('videos/2026/10/abc123.MKV')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects other folders, non-video extensions and traversal', () => {
|
||||||
|
expect(validateVideoKey('files/2026/10/abc123.mp4')).toBe(false)
|
||||||
|
expect(validateVideoKey('videos/2026/10/abc123.txt')).toBe(false)
|
||||||
|
expect(validateVideoKey('videos/../../etc/passwd')).toBe(false)
|
||||||
|
expect(validateVideoKey('/videos/a.mp4')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is covered by validateStorageKey', () => {
|
||||||
|
expect(validateStorageKey('videos/2026/10/a.mp4')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateVideoKey', () => {
|
||||||
|
const date = new Date('2026-10-16T00:00:00Z')
|
||||||
|
|
||||||
|
it('builds a dated key and keeps the extension', () => {
|
||||||
|
expect(generateVideoKey('mp4', date)).toMatch(/^videos\/2026\/10\/[A-Za-z0-9]{12}\.mp4$/)
|
||||||
|
expect(generateVideoKey('', date)).toMatch(/^videos\/2026\/10\/[A-Za-z0-9]{12}\.mp4$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('normalizeVideoContentType', () => {
|
||||||
|
it('keeps declared video mime types', () => {
|
||||||
|
expect(normalizeVideoContentType('video/mp4', 'mp4')).toBe('video/mp4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the extension when the declared type is not a video', () => {
|
||||||
|
expect(normalizeVideoContentType('application/octet-stream', 'mkv'))
|
||||||
|
.toBe('video/x-matroska')
|
||||||
|
expect(normalizeVideoContentType(undefined, 'm4v')).toBe('video/x-m4v')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import type { H3Event } from 'h3'
|
||||||
|
import type { UploadErrorItem } from '~/types/image'
|
||||||
|
import type { FileItem } from '~/types/file'
|
||||||
|
import {
|
||||||
|
FORBIDDEN_FILE_EXTENSIONS,
|
||||||
|
MAX_FILE_BED_FILE_SIZE,
|
||||||
|
MAX_VIDEO_FILE_SIZE
|
||||||
|
} from './constants'
|
||||||
|
import { contentTypeFromKey } from './content-type'
|
||||||
|
import {
|
||||||
|
extensionFromFilename,
|
||||||
|
generateFileKey,
|
||||||
|
generateVideoKey,
|
||||||
|
isVideoExtension
|
||||||
|
} from './image-key'
|
||||||
|
import { getStorageLayout } from './env'
|
||||||
|
import { resolveImageUrl } from './image-response'
|
||||||
|
import type { LogSource } from './db'
|
||||||
|
import type { MediaType } from './storage/types'
|
||||||
|
import { logActivity } from './activity-log'
|
||||||
|
import { logException, logInfo } from './logger'
|
||||||
|
import { putImage } from './storage'
|
||||||
|
import { generateVideoThumbnail } from './transcode'
|
||||||
|
import { updateImageThumb } from './image-index'
|
||||||
|
|
||||||
|
/** 文件床禁止上传的扩展名(html / htm / svg) */
|
||||||
|
export function isForbiddenFileExtension(extension: string): boolean {
|
||||||
|
if (!extension) return false
|
||||||
|
return (FORBIDDEN_FILE_EXTENSIONS as readonly string[]).includes(extension.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下载文件名清洗:保留中文等字符,去掉路径与控制字符 */
|
||||||
|
export function sanitizeDownloadName(name: string): string {
|
||||||
|
const base = name.split(/[/\\]/).pop() ?? 'file'
|
||||||
|
const withoutControl = Array.from(base)
|
||||||
|
.filter(char => char.charCodeAt(0) > 0x1f && char.charCodeAt(0) !== 0x7f)
|
||||||
|
.join('')
|
||||||
|
const cleaned = withoutControl
|
||||||
|
.replace(/["<>|:*?]/g, '_')
|
||||||
|
.trim()
|
||||||
|
return cleaned.slice(0, 200) || 'file'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 5987 兼容的附件下载响应头值 */
|
||||||
|
export function buildAttachmentDisposition(name: string): string {
|
||||||
|
const safe = sanitizeDownloadName(name)
|
||||||
|
const ascii = safe.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_')
|
||||||
|
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(safe)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateFileStorageKey(originalName: string, date = new Date()): string {
|
||||||
|
return generateFileKey(extensionFromFilename(originalName), date, getStorageLayout())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateVideoStorageKey(originalName: string, date = new Date()): string {
|
||||||
|
return generateVideoKey(extensionFromFilename(originalName), date, getStorageLayout())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 声明 MIME 清洗;不合法时按扩展名推断 */
|
||||||
|
export function normalizeFileContentType(
|
||||||
|
declared: string | undefined,
|
||||||
|
extension: string
|
||||||
|
): string {
|
||||||
|
const value = (declared ?? '').split(';')[0]!.trim().toLowerCase()
|
||||||
|
const valid = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(value)
|
||||||
|
if (valid) return value
|
||||||
|
return extension ? contentTypeFromKey(`file.${extension}`) : 'application/octet-stream'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 视频必须落到 video/* 上,否则按扩展名推断 */
|
||||||
|
export function normalizeVideoContentType(
|
||||||
|
declared: string | undefined,
|
||||||
|
extension: string
|
||||||
|
): string {
|
||||||
|
const normalized = normalizeFileContentType(declared, extension)
|
||||||
|
if (normalized.startsWith('video/')) return normalized
|
||||||
|
return contentTypeFromKey(`video.${extension}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFileItem(input: {
|
||||||
|
event: H3Event
|
||||||
|
key: string
|
||||||
|
backendId?: string
|
||||||
|
originalName: string
|
||||||
|
contentType: string
|
||||||
|
size: number
|
||||||
|
uploadedAt: string
|
||||||
|
}): FileItem {
|
||||||
|
const url = resolveImageUrl(input.event, input.key, input.backendId)
|
||||||
|
const name = sanitizeDownloadName(input.originalName)
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: input.key,
|
||||||
|
url,
|
||||||
|
originalName: name,
|
||||||
|
contentType: input.contentType,
|
||||||
|
size: input.size,
|
||||||
|
uploadedAt: input.uploadedAt,
|
||||||
|
markdown: `[${name}](${url})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processSingleFileUpload(
|
||||||
|
event: H3Event,
|
||||||
|
input: {
|
||||||
|
bytes: Uint8Array
|
||||||
|
filename?: string
|
||||||
|
contentType?: string
|
||||||
|
source?: LogSource
|
||||||
|
userId?: number | null
|
||||||
|
}
|
||||||
|
): Promise<{ item: FileItem } | { error: UploadErrorItem }> {
|
||||||
|
const originalName = sanitizeDownloadName(input.filename ?? 'file')
|
||||||
|
const bytes = input.bytes
|
||||||
|
|
||||||
|
if (!bytes.byteLength) {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
name: originalName,
|
||||||
|
error: { code: 'INVALID_REQUEST', message: '文件内容为空' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes.byteLength > MAX_FILE_BED_FILE_SIZE) {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
name: originalName,
|
||||||
|
error: {
|
||||||
|
code: 'FILE_TOO_LARGE',
|
||||||
|
message: `单个文件不能超过 ${Math.round(MAX_FILE_BED_FILE_SIZE / 1024 / 1024)}MB`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = extensionFromFilename(originalName)
|
||||||
|
if (isForbiddenFileExtension(extension)) {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
name: originalName,
|
||||||
|
error: {
|
||||||
|
code: 'UNSUPPORTED_FILE_TYPE',
|
||||||
|
message: '不允许上传 html / htm / svg 文件'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadedAt = new Date().toISOString()
|
||||||
|
const key = generateFileStorageKey(originalName, new Date(uploadedAt))
|
||||||
|
|
||||||
|
return persistMediaUpload(event, {
|
||||||
|
key,
|
||||||
|
bytes,
|
||||||
|
originalName,
|
||||||
|
uploadedAt,
|
||||||
|
contentType: normalizeFileContentType(input.contentType, extension),
|
||||||
|
source: input.source,
|
||||||
|
userId: input.userId,
|
||||||
|
mediaType: 'file'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写存储 + 建索引 + 记日志 + 组装返回项(文件床与视频床共用) */
|
||||||
|
async function persistMediaUpload(
|
||||||
|
event: H3Event,
|
||||||
|
input: {
|
||||||
|
key: string
|
||||||
|
bytes: Uint8Array
|
||||||
|
originalName: string
|
||||||
|
uploadedAt: string
|
||||||
|
contentType: string
|
||||||
|
source?: LogSource
|
||||||
|
userId?: number | null
|
||||||
|
mediaType: MediaType
|
||||||
|
}
|
||||||
|
): Promise<{ item: FileItem } | { error: UploadErrorItem }> {
|
||||||
|
let backendId: string
|
||||||
|
try {
|
||||||
|
backendId = await putImage(input.key, input.bytes, {
|
||||||
|
originalName: input.originalName,
|
||||||
|
uploadedAt: input.uploadedAt,
|
||||||
|
contentType: input.contentType,
|
||||||
|
size: input.bytes.byteLength,
|
||||||
|
userId: input.userId ?? null,
|
||||||
|
mediaType: input.mediaType
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logException('upload failed: storage error', error, {
|
||||||
|
name: input.originalName,
|
||||||
|
source: input.source ?? 'web'
|
||||||
|
})
|
||||||
|
const detail = error instanceof Error ? error.message.trim() : ''
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
name: input.originalName,
|
||||||
|
error: {
|
||||||
|
code: 'UPLOAD_FAILED',
|
||||||
|
message: detail.startsWith('Telegram') || detail.includes('群组')
|
||||||
|
? detail
|
||||||
|
: '上传失败,请稍后重试'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logActivity(event, {
|
||||||
|
action: 'upload',
|
||||||
|
key: input.key,
|
||||||
|
originalName: input.originalName,
|
||||||
|
size: input.bytes.byteLength,
|
||||||
|
contentType: input.contentType,
|
||||||
|
source: input.source,
|
||||||
|
userId: input.userId ?? null,
|
||||||
|
backendId
|
||||||
|
})
|
||||||
|
|
||||||
|
logInfo(`${input.mediaType} upload success`, {
|
||||||
|
key: input.key,
|
||||||
|
size: input.bytes.byteLength,
|
||||||
|
contentType: input.contentType,
|
||||||
|
source: input.source ?? 'web'
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
item: buildFileItem({
|
||||||
|
event,
|
||||||
|
key: input.key,
|
||||||
|
backendId,
|
||||||
|
originalName: input.originalName,
|
||||||
|
contentType: input.contentType,
|
||||||
|
size: input.bytes.byteLength,
|
||||||
|
uploadedAt: input.uploadedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processSingleVideoUpload(
|
||||||
|
event: H3Event,
|
||||||
|
input: {
|
||||||
|
bytes: Uint8Array
|
||||||
|
filename?: string
|
||||||
|
contentType?: string
|
||||||
|
source?: LogSource
|
||||||
|
userId?: number | null
|
||||||
|
}
|
||||||
|
): Promise<{ item: FileItem } | { error: UploadErrorItem }> {
|
||||||
|
const originalName = sanitizeDownloadName(input.filename ?? 'video')
|
||||||
|
const bytes = input.bytes
|
||||||
|
|
||||||
|
if (!bytes.byteLength) {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
name: originalName,
|
||||||
|
error: { code: 'INVALID_REQUEST', message: '文件内容为空' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes.byteLength > MAX_VIDEO_FILE_SIZE) {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
name: originalName,
|
||||||
|
error: {
|
||||||
|
code: 'FILE_TOO_LARGE',
|
||||||
|
message: `单个视频不能超过 ${Math.round(MAX_VIDEO_FILE_SIZE / 1024 / 1024)}MB`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = extensionFromFilename(originalName)
|
||||||
|
if (!isVideoExtension(extension)) {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
name: originalName,
|
||||||
|
error: {
|
||||||
|
code: 'UNSUPPORTED_FILE_TYPE',
|
||||||
|
message: '只支持常见视频格式(MP4 / WebM / MOV / MKV 等)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadedAt = new Date().toISOString()
|
||||||
|
const key = generateVideoStorageKey(originalName, new Date(uploadedAt))
|
||||||
|
|
||||||
|
const result = await persistMediaUpload(event, {
|
||||||
|
key,
|
||||||
|
bytes,
|
||||||
|
originalName,
|
||||||
|
uploadedAt,
|
||||||
|
contentType: normalizeVideoContentType(input.contentType, extension),
|
||||||
|
source: input.source,
|
||||||
|
userId: input.userId,
|
||||||
|
mediaType: 'video'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 生成封面缩略图;失败不影响上传结果
|
||||||
|
if ('item' in result) {
|
||||||
|
const thumbKey = await generateVideoThumbnail(key).catch(() => null)
|
||||||
|
if (thumbKey) {
|
||||||
|
updateImageThumb(key, thumbKey)
|
||||||
|
result.item.thumbUrl = resolveImageUrl(event, thumbKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
+228
-23
@@ -3,7 +3,16 @@ import { dirname, join } from 'node:path'
|
|||||||
import { getDb, getUploadSourcesForKeys } from './db'
|
import { getDb, getUploadSourcesForKeys } from './db'
|
||||||
import { getDataDir } from './data-dir'
|
import { getDataDir } from './data-dir'
|
||||||
import { contentTypeFromKey } from './content-type'
|
import { contentTypeFromKey } from './content-type'
|
||||||
import { DEFAULT_FOLDER, toCanonicalImageKey, validateImageKey } from './image-key'
|
import {
|
||||||
|
DEFAULT_FOLDER,
|
||||||
|
FILE_FOLDER,
|
||||||
|
VIDEO_FOLDER,
|
||||||
|
isVideoSidecarKey,
|
||||||
|
mediaTypeForKey,
|
||||||
|
toCanonicalImageKey,
|
||||||
|
validateImageKey,
|
||||||
|
validateStorageKey
|
||||||
|
} from './image-key'
|
||||||
import { buildTagFilterSql, type TagFilterInput } from './tags'
|
import { buildTagFilterSql, type TagFilterInput } from './tags'
|
||||||
import { isBareImageFilename, isHiddenImageDatePath } from './image-public-path'
|
import { isBareImageFilename, isHiddenImageDatePath } from './image-public-path'
|
||||||
import {
|
import {
|
||||||
@@ -11,7 +20,13 @@ import {
|
|||||||
ensureStorageSchema,
|
ensureStorageSchema,
|
||||||
LOCAL_BACKEND_ID
|
LOCAL_BACKEND_ID
|
||||||
} from './storage-backends'
|
} from './storage-backends'
|
||||||
import type { PaginatedResult, StoredImage, StoredImageMeta } from './storage/types'
|
import type {
|
||||||
|
MediaType,
|
||||||
|
PaginatedResult,
|
||||||
|
StoredImage,
|
||||||
|
StoredImageMeta,
|
||||||
|
TranscodeStatus
|
||||||
|
} from './storage/types'
|
||||||
|
|
||||||
const META_SUFFIX = '.meta.json'
|
const META_SUFFIX = '.meta.json'
|
||||||
|
|
||||||
@@ -24,6 +39,12 @@ export interface ImageIndexRow {
|
|||||||
content_type: string
|
content_type: string
|
||||||
size: number
|
size: number
|
||||||
uploaded_at: string
|
uploaded_at: string
|
||||||
|
hls_key: string | null
|
||||||
|
transcode_status: string
|
||||||
|
transcode_progress: number
|
||||||
|
thumb_key: string | null
|
||||||
|
source_deleted: number
|
||||||
|
transcode_preset: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function keyToFilePath(key: string): string {
|
function keyToFilePath(key: string): string {
|
||||||
@@ -49,6 +70,12 @@ function rowToStoredImage(row: ImageIndexRow): StoredImage {
|
|||||||
contentType: row.content_type,
|
contentType: row.content_type,
|
||||||
size: row.size,
|
size: row.size,
|
||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
|
hlsKey: row.hls_key ?? null,
|
||||||
|
transcodeStatus: (row.transcode_status ?? 'none') as TranscodeStatus,
|
||||||
|
transcodeProgress: Number(row.transcode_progress ?? 0),
|
||||||
|
thumbKey: row.thumb_key ?? null,
|
||||||
|
sourceDeleted: Number(row.source_deleted ?? 0) === 1,
|
||||||
|
transcodePreset: row.transcode_preset ?? null,
|
||||||
mtimeMs: Number.isFinite(uploadedMs) ? uploadedMs : Date.now()
|
mtimeMs: Number.isFinite(uploadedMs) ? uploadedMs : Date.now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,13 +97,14 @@ export function insertImageIndex(input: {
|
|||||||
contentType: string
|
contentType: string
|
||||||
size: number
|
size: number
|
||||||
uploadedAt: string
|
uploadedAt: string
|
||||||
|
mediaType?: MediaType
|
||||||
}): void {
|
}): void {
|
||||||
ensureStorageSchema()
|
ensureStorageSchema()
|
||||||
const folder = input.key.split('/')[0] ?? DEFAULT_FOLDER
|
const folder = input.key.split('/')[0] ?? DEFAULT_FOLDER
|
||||||
getDb().prepare(`
|
getDb().prepare(`
|
||||||
INSERT INTO images
|
INSERT INTO images
|
||||||
(key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at)
|
(key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, media_type)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(key) DO UPDATE SET
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
backend_id = excluded.backend_id,
|
backend_id = excluded.backend_id,
|
||||||
user_id = excluded.user_id,
|
user_id = excluded.user_id,
|
||||||
@@ -84,7 +112,8 @@ export function insertImageIndex(input: {
|
|||||||
original_name = excluded.original_name,
|
original_name = excluded.original_name,
|
||||||
content_type = excluded.content_type,
|
content_type = excluded.content_type,
|
||||||
size = excluded.size,
|
size = excluded.size,
|
||||||
uploaded_at = excluded.uploaded_at
|
uploaded_at = excluded.uploaded_at,
|
||||||
|
media_type = excluded.media_type
|
||||||
`).run(
|
`).run(
|
||||||
input.key,
|
input.key,
|
||||||
input.backendId,
|
input.backendId,
|
||||||
@@ -93,7 +122,8 @@ export function insertImageIndex(input: {
|
|||||||
input.originalName,
|
input.originalName,
|
||||||
input.contentType,
|
input.contentType,
|
||||||
input.size,
|
input.size,
|
||||||
input.uploadedAt
|
input.uploadedAt,
|
||||||
|
input.mediaType ?? 'image'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,11 +210,13 @@ export async function reconcileLegacyImageKeys(): Promise<{
|
|||||||
export async function purgeOrphanImageIndexRows(): Promise<number> {
|
export async function purgeOrphanImageIndexRows(): Promise<number> {
|
||||||
ensureStorageSchema()
|
ensureStorageSchema()
|
||||||
const rows = getDb().prepare(`
|
const rows = getDb().prepare(`
|
||||||
SELECT key, backend_id FROM images
|
SELECT key, backend_id, source_deleted FROM images
|
||||||
`).all() as Array<{ key: string, backend_id: string }>
|
`).all() as Array<{ key: string, backend_id: string, source_deleted: number }>
|
||||||
|
|
||||||
let removed = 0
|
let removed = 0
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
// 转码成功后源文件是主动删除的,索引要保留(播放走 HLS)
|
||||||
|
if (Number(row.source_deleted ?? 0) === 1) continue
|
||||||
if (row.backend_id !== LOCAL_BACKEND_ID) continue
|
if (row.backend_id !== LOCAL_BACKEND_ID) continue
|
||||||
if (await fileExistsAtKey(row.key)) continue
|
if (await fileExistsAtKey(row.key)) continue
|
||||||
const canonical = toCanonicalImageKey(row.key)
|
const canonical = toCanonicalImageKey(row.key)
|
||||||
@@ -198,7 +230,7 @@ export async function purgeOrphanImageIndexRows(): Promise<number> {
|
|||||||
export function getImageIndexRow(key: string): ImageIndexRow | null {
|
export function getImageIndexRow(key: string): ImageIndexRow | null {
|
||||||
ensureStorageSchema()
|
ensureStorageSchema()
|
||||||
const row = getDb().prepare(`
|
const row = getDb().prepare(`
|
||||||
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
|
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset
|
||||||
FROM images
|
FROM images
|
||||||
WHERE key = ?
|
WHERE key = ?
|
||||||
`).get(key) as ImageIndexRow | undefined
|
`).get(key) as ImageIndexRow | undefined
|
||||||
@@ -217,7 +249,7 @@ export function findImageKeyByDatePath(datePath: string): string | null {
|
|||||||
ensureStorageSchema()
|
ensureStorageSchema()
|
||||||
const suffix = `/${datePath}`
|
const suffix = `/${datePath}`
|
||||||
const rows = getDb().prepare(`
|
const rows = getDb().prepare(`
|
||||||
SELECT key FROM images WHERE key LIKE ?
|
SELECT key FROM images WHERE media_type = 'image' AND key LIKE ?
|
||||||
`).all(`%${suffix}`) as Array<{ key: string }>
|
`).all(`%${suffix}`) as Array<{ key: string }>
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
@@ -240,7 +272,7 @@ export function findImageKeyByFilename(filename: string): string | null {
|
|||||||
ensureStorageSchema()
|
ensureStorageSchema()
|
||||||
const suffix = `/${filename}`
|
const suffix = `/${filename}`
|
||||||
const rows = getDb().prepare(`
|
const rows = getDb().prepare(`
|
||||||
SELECT key FROM images WHERE key LIKE ?
|
SELECT key FROM images WHERE media_type = 'image' AND key LIKE ?
|
||||||
`).all(`%${suffix}`) as Array<{ key: string }>
|
`).all(`%${suffix}`) as Array<{ key: string }>
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
@@ -274,7 +306,7 @@ async function collectImageKeysRecursive(
|
|||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
if (name.endsWith(META_SUFFIX)) continue
|
if (name.endsWith(META_SUFFIX)) continue
|
||||||
const key = `${keyPrefix}/${name}`
|
const key = `${keyPrefix}/${name}`
|
||||||
if (validateImageKey(key)) keys.push(key)
|
if (validateStorageKey(key)) keys.push(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -284,17 +316,25 @@ async function collectImageKeysRecursive(
|
|||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listLocalImageKeys(): Promise<string[]> {
|
/** 扫描本地磁盘的 images/、files/ 与 videos/ 目录 */
|
||||||
const imagesRoot = join(getDataDir(), DEFAULT_FOLDER)
|
async function listLocalStorageKeys(): Promise<string[]> {
|
||||||
return collectImageKeysRecursive(imagesRoot, DEFAULT_FOLDER)
|
const keys: string[] = []
|
||||||
|
for (const root of [DEFAULT_FOLDER, FILE_FOLDER, VIDEO_FOLDER]) {
|
||||||
|
const found = await collectImageKeysRecursive(join(getDataDir(), root), root)
|
||||||
|
keys.push(...found)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function migrateLocalImagesToIndex(): Promise<number> {
|
export async function migrateLocalImagesToIndex(): Promise<number> {
|
||||||
ensureDefaultBackends()
|
ensureDefaultBackends()
|
||||||
const keys = await listLocalImageKeys()
|
const keys = await listLocalStorageKeys()
|
||||||
let inserted = 0
|
let inserted = 0
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
|
// HLS 播放列表与分片属于视频的附属产物,不进索引
|
||||||
|
if (isVideoSidecarKey(key)) continue
|
||||||
|
|
||||||
const existing = getImageIndexRow(key)
|
const existing = getImageIndexRow(key)
|
||||||
if (existing) continue
|
if (existing) continue
|
||||||
|
|
||||||
@@ -320,7 +360,8 @@ export async function migrateLocalImagesToIndex(): Promise<number> {
|
|||||||
originalName: meta.originalName ?? key.split('/').pop() ?? 'image',
|
originalName: meta.originalName ?? key.split('/').pop() ?? 'image',
|
||||||
contentType,
|
contentType,
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
uploadedAt
|
uploadedAt,
|
||||||
|
mediaType: mediaTypeForKey(key) as MediaType
|
||||||
})
|
})
|
||||||
inserted++
|
inserted++
|
||||||
}
|
}
|
||||||
@@ -382,7 +423,7 @@ export function getImageTotals(): { count: number, bytes: number } {
|
|||||||
export function listAllImageIndexRows(): ImageIndexRow[] {
|
export function listAllImageIndexRows(): ImageIndexRow[] {
|
||||||
ensureStorageSchema()
|
ensureStorageSchema()
|
||||||
return getDb().prepare(`
|
return getDb().prepare(`
|
||||||
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
|
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset
|
||||||
FROM images
|
FROM images
|
||||||
ORDER BY key ASC
|
ORDER BY key ASC
|
||||||
`).all() as unknown as ImageIndexRow[]
|
`).all() as unknown as ImageIndexRow[]
|
||||||
@@ -391,7 +432,7 @@ export function listAllImageIndexRows(): ImageIndexRow[] {
|
|||||||
export function listImageIndexRowsByBackend(backendId: string): ImageIndexRow[] {
|
export function listImageIndexRowsByBackend(backendId: string): ImageIndexRow[] {
|
||||||
ensureStorageSchema()
|
ensureStorageSchema()
|
||||||
return getDb().prepare(`
|
return getDb().prepare(`
|
||||||
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
|
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset
|
||||||
FROM images
|
FROM images
|
||||||
WHERE backend_id = ?
|
WHERE backend_id = ?
|
||||||
ORDER BY key ASC
|
ORDER BY key ASC
|
||||||
@@ -405,15 +446,157 @@ export function updateImageBackendId(key: string, backendId: string): void {
|
|||||||
`).run(backendId, key)
|
`).run(backendId, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 更新 HLS 转码状态;hlsKey / progress 传 null 时保留原值 */
|
||||||
|
export function updateImageTranscode(
|
||||||
|
key: string,
|
||||||
|
input: {
|
||||||
|
status: TranscodeStatus
|
||||||
|
hlsKey?: string | null
|
||||||
|
progress?: number
|
||||||
|
sourceDeleted?: boolean
|
||||||
|
size?: number
|
||||||
|
preset?: string | null
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
ensureStorageSchema()
|
||||||
|
getDb().prepare(`
|
||||||
|
UPDATE images
|
||||||
|
SET transcode_status = ?,
|
||||||
|
hls_key = COALESCE(?, hls_key),
|
||||||
|
transcode_progress = COALESCE(?, transcode_progress),
|
||||||
|
source_deleted = COALESCE(?, source_deleted),
|
||||||
|
size = COALESCE(?, size),
|
||||||
|
transcode_preset = COALESCE(?, transcode_preset)
|
||||||
|
WHERE key = ?
|
||||||
|
`).run(
|
||||||
|
input.status,
|
||||||
|
input.hlsKey ?? null,
|
||||||
|
input.progress ?? null,
|
||||||
|
input.sourceDeleted === undefined ? null : (input.sourceDeleted ? 1 : 0),
|
||||||
|
input.size ?? null,
|
||||||
|
input.preset ?? null,
|
||||||
|
key
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动时恢复被中断的转码任务 */
|
||||||
|
export function listPendingTranscodeKeys(): string[] {
|
||||||
|
ensureStorageSchema()
|
||||||
|
const rows = getDb().prepare(`
|
||||||
|
SELECT key FROM images
|
||||||
|
WHERE media_type = 'video' AND transcode_status IN ('pending', 'running')
|
||||||
|
ORDER BY uploaded_at ASC
|
||||||
|
`).all() as Array<{ key: string }>
|
||||||
|
return rows.map(row => row.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 转码已完成但仍保留源文件的视频(用于补删历史遗留) */
|
||||||
|
export function listTranscodedSourceRows(): Array<{ key: string, hlsKey: string }> {
|
||||||
|
ensureStorageSchema()
|
||||||
|
const rows = getDb().prepare(`
|
||||||
|
SELECT key, hls_key FROM images
|
||||||
|
WHERE media_type = 'video'
|
||||||
|
AND transcode_status = 'done'
|
||||||
|
AND source_deleted = 0
|
||||||
|
AND hls_key IS NOT NULL
|
||||||
|
`).all() as Array<{ key: string, hls_key: string }>
|
||||||
|
return rows.map(row => ({ key: row.key, hlsKey: row.hls_key }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 记录视频封面缩略图 key */
|
||||||
|
export function updateImageThumb(key: string, thumbKey: string): void {
|
||||||
|
ensureStorageSchema()
|
||||||
|
getDb().prepare('UPDATE images SET thumb_key = ? WHERE key = ?').run(thumbKey, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaStats {
|
||||||
|
uploadToday: number
|
||||||
|
uploadYesterday: number
|
||||||
|
uploadMonth: number
|
||||||
|
uploadLastMonth: number
|
||||||
|
uploadTotal: number
|
||||||
|
storedCount: number
|
||||||
|
uploadBytesTotal: number
|
||||||
|
bySource: { web: number, api: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按媒体类型统计(图片 / 视频 / 文件各自独立) */
|
||||||
|
export function getMediaStats(
|
||||||
|
mediaType: MediaType,
|
||||||
|
userFilter?: number | 'admin'
|
||||||
|
): MediaStats {
|
||||||
|
ensureStorageSchema()
|
||||||
|
const params: Array<string | number> = [mediaType]
|
||||||
|
const userSql = buildUserFilterSql(userFilter, params)
|
||||||
|
// 转码中的视频还没入库,与列表口径保持一致:统计不计入
|
||||||
|
const transcodeSql = mediaType === 'video'
|
||||||
|
? ` AND transcode_status IN ('none', 'done', 'failed')`
|
||||||
|
: ''
|
||||||
|
const rows = getDb().prepare(`
|
||||||
|
SELECT size, uploaded_at FROM images
|
||||||
|
WHERE media_type = ?${transcodeSql}${userSql}
|
||||||
|
`).all(...params) as Array<{ size: number, uploaded_at: string }>
|
||||||
|
|
||||||
|
const startOfDay = startOfDayIsoInShanghai()
|
||||||
|
const startOfYesterday = startOfYesterdayIsoInShanghai()
|
||||||
|
const startOfMonth = startOfMonthIsoInShanghai()
|
||||||
|
const startOfLastMonth = startOfLastMonthIsoInShanghai()
|
||||||
|
|
||||||
|
let bytes = 0
|
||||||
|
let today = 0
|
||||||
|
let yesterday = 0
|
||||||
|
let month = 0
|
||||||
|
let lastMonth = 0
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
bytes += row.size
|
||||||
|
if (row.uploaded_at >= startOfDay) today++
|
||||||
|
if (row.uploaded_at >= startOfYesterday && row.uploaded_at < startOfDay) yesterday++
|
||||||
|
if (row.uploaded_at >= startOfMonth) month++
|
||||||
|
if (row.uploaded_at >= startOfLastMonth && row.uploaded_at < startOfMonth) lastMonth++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 来源分布来自上传日志,按目录前缀区分类型
|
||||||
|
const prefix = mediaType === 'video' ? 'videos/' : mediaType === 'file' ? 'files/' : 'images/'
|
||||||
|
const sourceRows = getDb().prepare(`
|
||||||
|
SELECT source, COUNT(*) AS count FROM activity_logs
|
||||||
|
WHERE action = 'upload' AND key LIKE ?
|
||||||
|
GROUP BY source
|
||||||
|
`).all(`${prefix}%`) as Array<{ source: string, count: number }>
|
||||||
|
|
||||||
|
const bySource = { web: 0, api: 0 }
|
||||||
|
for (const row of sourceRows) {
|
||||||
|
if (row.source === 'web') bySource.web = row.count
|
||||||
|
else if (row.source === 'api') bySource.api = row.count
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadToday: today,
|
||||||
|
uploadYesterday: yesterday,
|
||||||
|
uploadMonth: month,
|
||||||
|
uploadLastMonth: lastMonth,
|
||||||
|
uploadTotal: rows.length,
|
||||||
|
storedCount: rows.length,
|
||||||
|
uploadBytesTotal: bytes,
|
||||||
|
bySource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function countImages(
|
export async function countImages(
|
||||||
userFilter?: number | 'admin',
|
userFilter?: number | 'admin',
|
||||||
backendId?: string,
|
backendId?: string,
|
||||||
tagFilter?: TagFilterInput
|
tagFilter?: TagFilterInput,
|
||||||
|
mediaType: MediaType | 'all' = 'image'
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
ensureStorageSchema()
|
ensureStorageSchema()
|
||||||
const params: Array<string | number> = []
|
const params: Array<string | number> = []
|
||||||
const clauses: string[] = ['1=1']
|
const clauses: string[] = ['1=1']
|
||||||
|
|
||||||
|
if (mediaType !== 'all') {
|
||||||
|
clauses.push('media_type = ?')
|
||||||
|
params.push(mediaType)
|
||||||
|
}
|
||||||
|
|
||||||
const userSql = buildUserFilterSql(userFilter, params)
|
const userSql = buildUserFilterSql(userFilter, params)
|
||||||
const backendSql = buildBackendFilterSql(backendId, params)
|
const backendSql = buildBackendFilterSql(backendId, params)
|
||||||
const tagSql = buildTagFilterSql(tagFilter, params)
|
const tagSql = buildTagFilterSql(tagFilter, params)
|
||||||
@@ -466,16 +649,27 @@ export async function listImages(options: {
|
|||||||
contentType?: string
|
contentType?: string
|
||||||
uploadSource?: 'web' | 'api'
|
uploadSource?: 'web' | 'api'
|
||||||
tagFilter?: TagFilterInput
|
tagFilter?: TagFilterInput
|
||||||
|
mediaType?: MediaType
|
||||||
|
/** 只返回这些转码状态的条目(视频库用来隐藏转码中的视频) */
|
||||||
|
transcodeStatuses?: TranscodeStatus[]
|
||||||
}): Promise<PaginatedResult<StoredImage>> {
|
}): Promise<PaginatedResult<StoredImage>> {
|
||||||
ensureStorageSchema()
|
ensureStorageSchema()
|
||||||
const params: Array<string | number> = []
|
const params: Array<string | number> = []
|
||||||
const clauses: string[] = ['1=1']
|
const clauses: string[] = ['1=1']
|
||||||
|
|
||||||
|
clauses.push('media_type = ?')
|
||||||
|
params.push(options.mediaType ?? 'image')
|
||||||
|
|
||||||
|
if (options.transcodeStatuses?.length) {
|
||||||
|
clauses.push(`transcode_status IN (${options.transcodeStatuses.map(() => '?').join(', ')})`)
|
||||||
|
params.push(...options.transcodeStatuses)
|
||||||
|
}
|
||||||
|
|
||||||
const userSql = buildUserFilterSql(options.userFilter, params)
|
const userSql = buildUserFilterSql(options.userFilter, params)
|
||||||
const backendSql = buildBackendFilterSql(options.backendId, params)
|
const backendSql = buildBackendFilterSql(options.backendId, params)
|
||||||
const tagSql = buildTagFilterSql(options.tagFilter, params)
|
const tagSql = buildTagFilterSql(options.tagFilter, params)
|
||||||
const rows = getDb().prepare(`
|
const rows = getDb().prepare(`
|
||||||
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
|
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset
|
||||||
FROM images
|
FROM images
|
||||||
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql}
|
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql}
|
||||||
ORDER BY uploaded_at DESC, key DESC
|
ORDER BY uploaded_at DESC, key DESC
|
||||||
@@ -501,17 +695,27 @@ export async function searchImages(options: {
|
|||||||
contentType?: string
|
contentType?: string
|
||||||
uploadSource?: 'web' | 'api'
|
uploadSource?: 'web' | 'api'
|
||||||
tagFilter?: TagFilterInput
|
tagFilter?: TagFilterInput
|
||||||
|
mediaType?: MediaType
|
||||||
|
transcodeStatuses?: TranscodeStatus[]
|
||||||
}): Promise<PaginatedResult<StoredImage>> {
|
}): Promise<PaginatedResult<StoredImage>> {
|
||||||
ensureStorageSchema()
|
ensureStorageSchema()
|
||||||
const needle = options.query.trim().toLowerCase()
|
const needle = options.query.trim().toLowerCase()
|
||||||
const params: Array<string | number> = []
|
const params: Array<string | number> = []
|
||||||
const clauses: string[] = ['1=1']
|
const clauses: string[] = ['1=1']
|
||||||
|
|
||||||
|
clauses.push('media_type = ?')
|
||||||
|
params.push(options.mediaType ?? 'image')
|
||||||
|
|
||||||
|
if (options.transcodeStatuses?.length) {
|
||||||
|
clauses.push(`transcode_status IN (${options.transcodeStatuses.map(() => '?').join(', ')})`)
|
||||||
|
params.push(...options.transcodeStatuses)
|
||||||
|
}
|
||||||
|
|
||||||
const userSql = buildUserFilterSql(options.userFilter, params)
|
const userSql = buildUserFilterSql(options.userFilter, params)
|
||||||
const backendSql = buildBackendFilterSql(options.backendId, params)
|
const backendSql = buildBackendFilterSql(options.backendId, params)
|
||||||
const tagSql = buildTagFilterSql(options.tagFilter, params)
|
const tagSql = buildTagFilterSql(options.tagFilter, params)
|
||||||
const rows = getDb().prepare(`
|
const rows = getDb().prepare(`
|
||||||
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
|
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset
|
||||||
FROM images
|
FROM images
|
||||||
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql}
|
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql}
|
||||||
ORDER BY uploaded_at DESC, key DESC
|
ORDER BY uploaded_at DESC, key DESC
|
||||||
@@ -645,8 +849,9 @@ export async function getUserScopedStorageStats(
|
|||||||
export async function listImageKeysForAutoDelete(): Promise<StoredImage[]> {
|
export async function listImageKeysForAutoDelete(): Promise<StoredImage[]> {
|
||||||
ensureStorageSchema()
|
ensureStorageSchema()
|
||||||
const rows = getDb().prepare(`
|
const rows = getDb().prepare(`
|
||||||
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
|
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at, hls_key, transcode_status, transcode_progress, thumb_key, source_deleted, transcode_preset
|
||||||
FROM images
|
FROM images
|
||||||
|
WHERE media_type = 'image'
|
||||||
ORDER BY uploaded_at ASC
|
ORDER BY uploaded_at ASC
|
||||||
`).all() as unknown as ImageIndexRow[]
|
`).all() as unknown as ImageIndexRow[]
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { VIDEO_EXTENSIONS } from './constants'
|
||||||
import type { AllowedMimeType } from './constants'
|
import type { AllowedMimeType } from './constants'
|
||||||
|
|
||||||
const EXT_MAP: Record<AllowedMimeType, string> = {
|
const EXT_MAP: Record<AllowedMimeType, string> = {
|
||||||
@@ -10,11 +11,16 @@ const EXT_MAP: Record<AllowedMimeType, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_FOLDER = 'images'
|
export const DEFAULT_FOLDER = 'images'
|
||||||
|
export const FILE_FOLDER = 'files'
|
||||||
|
export const VIDEO_FOLDER = 'videos'
|
||||||
|
|
||||||
export type StorageLayout = 'date' | 'flat'
|
export type StorageLayout = 'date' | 'flat'
|
||||||
|
|
||||||
const IMAGE_EXT_PATTERN = /\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
|
const IMAGE_EXT_PATTERN = /\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
|
||||||
|
|
||||||
|
/** 文件床文件名:字母数字开头,允许 - _ .,最长 64(含扩展名) */
|
||||||
|
const FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
||||||
|
|
||||||
function generateRandomId(length = 12): string {
|
function generateRandomId(length = 12): string {
|
||||||
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||||
const bytes = new Uint8Array(length)
|
const bytes = new Uint8Array(length)
|
||||||
@@ -69,6 +75,128 @@ export function validateImageKey(key: string): boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 扩展名清洗:只保留 1–10 位字母数字 */
|
||||||
|
export function sanitizeFileExtension(raw: string): string {
|
||||||
|
const ext = raw.trim().toLowerCase().replace(/^\.+/, '')
|
||||||
|
return /^[a-z0-9]{1,10}$/.test(ext) ? ext : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从原始文件名取安全扩展名;无扩展名时返回空串 */
|
||||||
|
export function extensionFromFilename(filename: string): string {
|
||||||
|
const base = filename.split(/[/\\]/).pop() ?? ''
|
||||||
|
const dot = base.lastIndexOf('.')
|
||||||
|
if (dot <= 0 || dot === base.length - 1) return ''
|
||||||
|
return sanitizeFileExtension(base.slice(dot + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateFileKey(
|
||||||
|
extension: string,
|
||||||
|
date = new Date(),
|
||||||
|
layout: StorageLayout = 'date'
|
||||||
|
): string {
|
||||||
|
const id = generateRandomId(12)
|
||||||
|
const ext = sanitizeFileExtension(extension)
|
||||||
|
const suffix = ext ? `.${ext}` : ''
|
||||||
|
if (layout === 'flat') {
|
||||||
|
return `${FILE_FOLDER}/${id}${suffix}`
|
||||||
|
}
|
||||||
|
const year = date.getUTCFullYear()
|
||||||
|
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
|
||||||
|
return `${FILE_FOLDER}/${year}/${month}/${id}${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件床 key(files/…);图片 key 走 validateImageKey */
|
||||||
|
export function validateFileKey(key: string): boolean {
|
||||||
|
if (!key || typeof key !== 'string') return false
|
||||||
|
if (key.startsWith('/')) return false
|
||||||
|
if (key.includes('..')) return false
|
||||||
|
if (key.includes('//')) return false
|
||||||
|
|
||||||
|
const parts = key.split('/')
|
||||||
|
if (parts.length < 2 || parts.some(part => !part)) return false
|
||||||
|
|
||||||
|
if (parts[0] !== FILE_FOLDER) return false
|
||||||
|
|
||||||
|
const filename = parts[parts.length - 1]!
|
||||||
|
if (!FILE_NAME_PATTERN.test(filename)) return false
|
||||||
|
|
||||||
|
for (let i = 1; i < parts.length - 1; i++) {
|
||||||
|
if (!isValidPathSegment(parts[i]!)) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 图片或文件床的存储 key */
|
||||||
|
export function validateStorageKey(key: string): boolean {
|
||||||
|
return validateImageKey(key) || validateFileKey(key) || validateVideoKey(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaTypeForKey(key: string): 'image' | 'video' | 'file' {
|
||||||
|
if (key.startsWith(`${FILE_FOLDER}/`)) return 'file'
|
||||||
|
if (key.startsWith(`${VIDEO_FOLDER}/`)) return 'video'
|
||||||
|
return 'image'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** videos/ 下除视频本体外的附属资源:播放列表、分片、封面缩略图 */
|
||||||
|
const VIDEO_SIDECAR_EXTENSIONS = ['m3u8', 'ts', 'webp', 'jpg', 'jpeg', 'png']
|
||||||
|
|
||||||
|
function isVideoKeyExtension(extension: string): boolean {
|
||||||
|
if (!extension) return false
|
||||||
|
const value = extension.toLowerCase()
|
||||||
|
return (VIDEO_EXTENSIONS as readonly string[]).includes(value)
|
||||||
|
|| VIDEO_SIDECAR_EXTENSIONS.includes(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 附属资源不进索引,也不出现在列表里 */
|
||||||
|
export function isVideoSidecarKey(key: string): boolean {
|
||||||
|
return validateVideoKey(key)
|
||||||
|
&& VIDEO_SIDECAR_EXTENSIONS.includes(extensionFromFilename(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVideoExtension(extension: string): boolean {
|
||||||
|
if (!extension) return false
|
||||||
|
return (VIDEO_EXTENSIONS as readonly string[]).includes(extension.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateVideoKey(
|
||||||
|
extension: string,
|
||||||
|
date = new Date(),
|
||||||
|
layout: StorageLayout = 'date'
|
||||||
|
): string {
|
||||||
|
const id = generateRandomId(12)
|
||||||
|
const ext = sanitizeFileExtension(extension) || 'mp4'
|
||||||
|
if (layout === 'flat') {
|
||||||
|
return `${VIDEO_FOLDER}/${id}.${ext}`
|
||||||
|
}
|
||||||
|
const year = date.getUTCFullYear()
|
||||||
|
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
|
||||||
|
return `${VIDEO_FOLDER}/${year}/${month}/${id}.${ext}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 视频床 key(videos/…),扩展名必须在视频白名单内 */
|
||||||
|
export function validateVideoKey(key: string): boolean {
|
||||||
|
if (!key || typeof key !== 'string') return false
|
||||||
|
if (key.startsWith('/')) return false
|
||||||
|
if (key.includes('..')) return false
|
||||||
|
if (key.includes('//')) return false
|
||||||
|
|
||||||
|
const parts = key.split('/')
|
||||||
|
if (parts.length < 2 || parts.some(part => !part)) return false
|
||||||
|
|
||||||
|
if (parts[0] !== VIDEO_FOLDER) return false
|
||||||
|
|
||||||
|
const filename = parts[parts.length - 1]!
|
||||||
|
if (!FILE_NAME_PATTERN.test(filename)) return false
|
||||||
|
if (!isVideoKeyExtension(extensionFromFilename(filename))) return false
|
||||||
|
|
||||||
|
for (let i = 1; i < parts.length - 1; i++) {
|
||||||
|
if (!isValidPathSegment(parts[i]!)) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/** 将遗留顶层目录 key(如 blog/foo.webp)映射为 images/blog/foo.webp */
|
/** 将遗留顶层目录 key(如 blog/foo.webp)映射为 images/blog/foo.webp */
|
||||||
export function toCanonicalImageKey(key: string): string | null {
|
export function toCanonicalImageKey(key: string): string | null {
|
||||||
if (validateImageKey(key)) return key
|
if (validateImageKey(key)) return key
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { basename } from 'node:path'
|
import { basename } from 'node:path'
|
||||||
import { DEFAULT_FOLDER, validateImageKey } from './image-key'
|
import { DEFAULT_FOLDER, validateFileKey, validateImageKey } from './image-key'
|
||||||
|
|
||||||
/** 存储 key → 对外 URL 路径(不含域名) */
|
/** 存储 key → 对外 URL 路径(不含域名) */
|
||||||
export function storageKeyToPublicPath(key: string): string {
|
export function storageKeyToPublicPath(key: string): string {
|
||||||
@@ -23,6 +23,8 @@ export function toStorageKeyFromPublicPath(publicPath: string): string | null {
|
|||||||
const raw = publicPath.replace(/^\/+/, '')
|
const raw = publicPath.replace(/^\/+/, '')
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
if (validateImageKey(raw)) return raw
|
if (validateImageKey(raw)) return raw
|
||||||
|
// 文件床直链保留 files/ 前缀,避免与图片路径混用
|
||||||
|
if (validateFileKey(raw)) return raw
|
||||||
const candidate = `${DEFAULT_FOLDER}/${raw}`
|
const candidate = `${DEFAULT_FOLDER}/${raw}`
|
||||||
return validateImageKey(candidate) ? candidate : null
|
return validateImageKey(candidate) ? candidate : null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
import type { H3Event } from 'h3'
|
import type { H3Event } from 'h3'
|
||||||
import { IMAGE_CACHE_CONTROL } from './constants'
|
import {
|
||||||
|
FILE_CACHE_CONTROL,
|
||||||
|
FILE_PRIVATE_CACHE_CONTROL,
|
||||||
|
IMAGE_CACHE_CONTROL
|
||||||
|
} from './constants'
|
||||||
import {
|
import {
|
||||||
getAllowedRefererHostsRaw,
|
getAllowedRefererHostsRaw,
|
||||||
|
getFileBedAccessMode,
|
||||||
getImageBaseUrlConfigured,
|
getImageBaseUrlConfigured,
|
||||||
getSiteBaseUrlConfigured,
|
getSiteBaseUrlConfigured,
|
||||||
hostnameFromBaseUrl,
|
hostnameFromBaseUrl,
|
||||||
isRefererConfigured
|
isRefererConfigured
|
||||||
} from './env'
|
} from './env'
|
||||||
|
import { isFileAccessAuthorized } from './access'
|
||||||
|
import { buildAttachmentDisposition } from './file-bed'
|
||||||
import { findImageKeyByDatePath, findImageKeyByFilename } from './image-index'
|
import { findImageKeyByDatePath, findImageKeyByFilename } from './image-index'
|
||||||
import { validateImageKey } from './image-key'
|
import {
|
||||||
|
validateFileKey,
|
||||||
|
validateImageKey,
|
||||||
|
validateStorageKey,
|
||||||
|
validateVideoKey
|
||||||
|
} from './image-key'
|
||||||
import { isBareImageFilename, toStorageKeyFromPublicPath } from './image-public-path'
|
import { isBareImageFilename, toStorageKeyFromPublicPath } from './image-public-path'
|
||||||
import { buildPublicImageUrl } from './image-response'
|
import { buildPublicImageUrl } from './image-response'
|
||||||
import { createImageStream, headImage } from './storage'
|
import { createImageStream, headImage } from './storage'
|
||||||
@@ -90,7 +102,7 @@ export function requestPathToImageKey(
|
|||||||
const raw = decodeURIComponent(pathname).replace(/^\/+/, '')
|
const raw = decodeURIComponent(pathname).replace(/^\/+/, '')
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
|
|
||||||
if (validateImageKey(raw)) return raw
|
if (validateStorageKey(raw)) return raw
|
||||||
|
|
||||||
// 简短链接:纯文件名须先走索引反查,不能猜成 images/文件名(与按年/月存储冲突)
|
// 简短链接:纯文件名须先走索引反查,不能猜成 images/文件名(与按年/月存储冲突)
|
||||||
if (options?.hideFolder && isBareImageFilename(raw)) {
|
if (options?.hideFolder && isBareImageFilename(raw)) {
|
||||||
@@ -114,6 +126,12 @@ export function requestPathToImageKey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function serveImageByKey(event: H3Event, key: string) {
|
export async function serveImageByKey(event: H3Event, key: string) {
|
||||||
|
const isFile = validateFileKey(key)
|
||||||
|
const isVideo = validateVideoKey(key)
|
||||||
|
// 文件与视频共用「文件床访问方式」开关
|
||||||
|
const isMedia = isFile || isVideo
|
||||||
|
const fileAccessMode = isMedia ? getFileBedAccessMode() : 'public'
|
||||||
|
|
||||||
const referer = getHeader(event, 'referer') ?? null
|
const referer = getHeader(event, 'referer') ?? null
|
||||||
if (isHotlinkBlocked(event, referer)) {
|
if (isHotlinkBlocked(event, referer)) {
|
||||||
setHeader(event, 'Cache-Control', ERROR_CACHE_CONTROL)
|
setHeader(event, 'Cache-Control', ERROR_CACHE_CONTROL)
|
||||||
@@ -123,6 +141,12 @@ export async function serveImageByKey(event: H3Event, key: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Token 模式:文件直链需要 Auth-Token / ?token= / 已登录会话
|
||||||
|
if (isMedia && fileAccessMode === 'token' && !(await isFileAccessAuthorized(event))) {
|
||||||
|
setHeader(event, 'Cache-Control', ERROR_CACHE_CONTROL)
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
|
||||||
const stored = await headImage(key)
|
const stored = await headImage(key)
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
setHeader(event, 'Cache-Control', ERROR_CACHE_CONTROL)
|
setHeader(event, 'Cache-Control', ERROR_CACHE_CONTROL)
|
||||||
@@ -130,7 +154,12 @@ export async function serveImageByKey(event: H3Event, key: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const backendRow = await getBackendRowForKey(key)
|
const backendRow = await getBackendRowForKey(key)
|
||||||
if (backendRow.serving_mode === 'public' && backendRow.public_url) {
|
// Token 模式不做对象存储直链跳转,避免绕过鉴权
|
||||||
|
if (
|
||||||
|
fileAccessMode === 'public'
|
||||||
|
&& backendRow.serving_mode === 'public'
|
||||||
|
&& backendRow.public_url
|
||||||
|
) {
|
||||||
const publicUrl = buildPublicImageUrl(backendRow, key)
|
const publicUrl = buildPublicImageUrl(backendRow, key)
|
||||||
return sendRedirect(event, publicUrl, 302)
|
return sendRedirect(event, publicUrl, 302)
|
||||||
}
|
}
|
||||||
@@ -138,11 +167,27 @@ export async function serveImageByKey(event: H3Event, key: string) {
|
|||||||
const etag = `"${stored.size}-${Math.round(stored.mtimeMs)}"`
|
const etag = `"${stored.size}-${Math.round(stored.mtimeMs)}"`
|
||||||
|
|
||||||
setHeader(event, 'ETag', etag)
|
setHeader(event, 'ETag', etag)
|
||||||
setHeader(event, 'Cache-Control', IMAGE_CACHE_CONTROL)
|
setHeader(
|
||||||
|
event,
|
||||||
|
'Cache-Control',
|
||||||
|
isMedia
|
||||||
|
? (fileAccessMode === 'token' ? FILE_PRIVATE_CACHE_CONTROL : FILE_CACHE_CONTROL)
|
||||||
|
: IMAGE_CACHE_CONTROL
|
||||||
|
)
|
||||||
setHeader(event, 'Accept-Ranges', 'bytes')
|
setHeader(event, 'Accept-Ranges', 'bytes')
|
||||||
setHeader(event, 'X-Content-Type-Options', 'nosniff')
|
setHeader(event, 'X-Content-Type-Options', 'nosniff')
|
||||||
setHeader(event, 'Content-Type', stored.contentType)
|
setHeader(event, 'Content-Type', stored.contentType)
|
||||||
|
|
||||||
|
// 文件床统一以附件形式下发,避免上传的文档在站点域名下被渲染执行
|
||||||
|
if (isFile) {
|
||||||
|
setHeader(event, 'Content-Disposition', buildAttachmentDisposition(stored.originalName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频走 inline 播放,跨域(图片域名)访问需要放行 CORS
|
||||||
|
if (isVideo) {
|
||||||
|
setHeader(event, 'Access-Control-Allow-Origin', '*')
|
||||||
|
}
|
||||||
|
|
||||||
if (stored.contentType === 'image/svg+xml') {
|
if (stored.contentType === 'image/svg+xml') {
|
||||||
setHeader(
|
setHeader(
|
||||||
event,
|
event,
|
||||||
|
|||||||
@@ -88,6 +88,150 @@ function migrateStorageBackendTelegramType(database: DatabaseSync): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STORAGE_SCHEMA_MEDIA_TYPE_KEY = 'storage_schema_media_type'
|
||||||
|
const STORAGE_SCHEMA_TRANSCODE_KEY = 'storage_schema_transcode'
|
||||||
|
const STORAGE_SCHEMA_TRANSCODE_PROGRESS_KEY = 'storage_schema_transcode_progress'
|
||||||
|
const STORAGE_SCHEMA_THUMB_KEY = 'storage_schema_thumb'
|
||||||
|
const STORAGE_SCHEMA_SOURCE_DELETED_KEY = 'storage_schema_source_deleted'
|
||||||
|
const STORAGE_SCHEMA_TRANSCODE_PRESET_KEY = 'storage_schema_transcode_preset'
|
||||||
|
|
||||||
|
/** images 表新增 media_type 列(区分图床图片与文件床文件) */
|
||||||
|
function migrateImagesMediaType(database: DatabaseSync): void {
|
||||||
|
const migrated = database.prepare(`
|
||||||
|
SELECT value FROM settings WHERE key = ?
|
||||||
|
`).get(STORAGE_SCHEMA_MEDIA_TYPE_KEY) as { value?: string } | undefined
|
||||||
|
|
||||||
|
if (migrated?.value === '1') return
|
||||||
|
|
||||||
|
const columns = database.prepare('PRAGMA table_info(images)').all() as Array<{
|
||||||
|
name: string
|
||||||
|
}>
|
||||||
|
if (!columns.some(column => column.name === 'media_type')) {
|
||||||
|
database.exec(`
|
||||||
|
ALTER TABLE images ADD COLUMN media_type TEXT NOT NULL DEFAULT 'image'
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
database.prepare(`
|
||||||
|
INSERT OR REPLACE INTO settings (key, value, updated_at)
|
||||||
|
VALUES (?, '1', ?)
|
||||||
|
`).run(STORAGE_SCHEMA_MEDIA_TYPE_KEY, new Date().toISOString())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** images 表新增 HLS 转码相关列(hls_key / transcode_status) */
|
||||||
|
function migrateImagesTranscode(database: DatabaseSync): void {
|
||||||
|
const migrated = database.prepare(`
|
||||||
|
SELECT value FROM settings WHERE key = ?
|
||||||
|
`).get(STORAGE_SCHEMA_TRANSCODE_KEY) as { value?: string } | undefined
|
||||||
|
|
||||||
|
if (migrated?.value === '1') return
|
||||||
|
|
||||||
|
const columns = database.prepare('PRAGMA table_info(images)').all() as Array<{
|
||||||
|
name: string
|
||||||
|
}>
|
||||||
|
if (!columns.some(column => column.name === 'hls_key')) {
|
||||||
|
database.exec('ALTER TABLE images ADD COLUMN hls_key TEXT')
|
||||||
|
}
|
||||||
|
if (!columns.some(column => column.name === 'transcode_status')) {
|
||||||
|
database.exec(`
|
||||||
|
ALTER TABLE images ADD COLUMN transcode_status TEXT NOT NULL DEFAULT 'none'
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
database.prepare(`
|
||||||
|
INSERT OR REPLACE INTO settings (key, value, updated_at)
|
||||||
|
VALUES (?, '1', ?)
|
||||||
|
`).run(STORAGE_SCHEMA_TRANSCODE_KEY, new Date().toISOString())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** images 表新增转码进度列(0–100) */
|
||||||
|
function migrateImagesTranscodeProgress(database: DatabaseSync): void {
|
||||||
|
const migrated = database.prepare(`
|
||||||
|
SELECT value FROM settings WHERE key = ?
|
||||||
|
`).get(STORAGE_SCHEMA_TRANSCODE_PROGRESS_KEY) as { value?: string } | undefined
|
||||||
|
|
||||||
|
if (migrated?.value === '1') return
|
||||||
|
|
||||||
|
const columns = database.prepare('PRAGMA table_info(images)').all() as Array<{
|
||||||
|
name: string
|
||||||
|
}>
|
||||||
|
if (!columns.some(column => column.name === 'transcode_progress')) {
|
||||||
|
database.exec(`
|
||||||
|
ALTER TABLE images ADD COLUMN transcode_progress INTEGER NOT NULL DEFAULT 0
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
database.prepare(`
|
||||||
|
INSERT OR REPLACE INTO settings (key, value, updated_at)
|
||||||
|
VALUES (?, '1', ?)
|
||||||
|
`).run(STORAGE_SCHEMA_TRANSCODE_PROGRESS_KEY, new Date().toISOString())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** images 表新增视频缩略图列 */
|
||||||
|
function migrateImagesThumb(database: DatabaseSync): void {
|
||||||
|
const migrated = database.prepare(`
|
||||||
|
SELECT value FROM settings WHERE key = ?
|
||||||
|
`).get(STORAGE_SCHEMA_THUMB_KEY) as { value?: string } | undefined
|
||||||
|
|
||||||
|
if (migrated?.value === '1') return
|
||||||
|
|
||||||
|
const columns = database.prepare('PRAGMA table_info(images)').all() as Array<{
|
||||||
|
name: string
|
||||||
|
}>
|
||||||
|
if (!columns.some(column => column.name === 'thumb_key')) {
|
||||||
|
database.exec('ALTER TABLE images ADD COLUMN thumb_key TEXT')
|
||||||
|
}
|
||||||
|
|
||||||
|
database.prepare(`
|
||||||
|
INSERT OR REPLACE INTO settings (key, value, updated_at)
|
||||||
|
VALUES (?, '1', ?)
|
||||||
|
`).run(STORAGE_SCHEMA_THUMB_KEY, new Date().toISOString())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** images 表新增「源视频已删除」标记 */
|
||||||
|
function migrateImagesSourceDeleted(database: DatabaseSync): void {
|
||||||
|
const migrated = database.prepare(`
|
||||||
|
SELECT value FROM settings WHERE key = ?
|
||||||
|
`).get(STORAGE_SCHEMA_SOURCE_DELETED_KEY) as { value?: string } | undefined
|
||||||
|
|
||||||
|
if (migrated?.value === '1') return
|
||||||
|
|
||||||
|
const columns = database.prepare('PRAGMA table_info(images)').all() as Array<{
|
||||||
|
name: string
|
||||||
|
}>
|
||||||
|
if (!columns.some(column => column.name === 'source_deleted')) {
|
||||||
|
database.exec(`
|
||||||
|
ALTER TABLE images ADD COLUMN source_deleted INTEGER NOT NULL DEFAULT 0
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
database.prepare(`
|
||||||
|
INSERT OR REPLACE INTO settings (key, value, updated_at)
|
||||||
|
VALUES (?, '1', ?)
|
||||||
|
`).run(STORAGE_SCHEMA_SOURCE_DELETED_KEY, new Date().toISOString())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** images 表新增「本视频使用的转码档位」列 */
|
||||||
|
function migrateImagesTranscodePreset(database: DatabaseSync): void {
|
||||||
|
const migrated = database.prepare(`
|
||||||
|
SELECT value FROM settings WHERE key = ?
|
||||||
|
`).get(STORAGE_SCHEMA_TRANSCODE_PRESET_KEY) as { value?: string } | undefined
|
||||||
|
|
||||||
|
if (migrated?.value === '1') return
|
||||||
|
|
||||||
|
const columns = database.prepare('PRAGMA table_info(images)').all() as Array<{
|
||||||
|
name: string
|
||||||
|
}>
|
||||||
|
if (!columns.some(column => column.name === 'transcode_preset')) {
|
||||||
|
database.exec('ALTER TABLE images ADD COLUMN transcode_preset TEXT')
|
||||||
|
}
|
||||||
|
|
||||||
|
database.prepare(`
|
||||||
|
INSERT OR REPLACE INTO settings (key, value, updated_at)
|
||||||
|
VALUES (?, '1', ?)
|
||||||
|
`).run(STORAGE_SCHEMA_TRANSCODE_PRESET_KEY, new Date().toISOString())
|
||||||
|
}
|
||||||
|
|
||||||
function migrateStorageTables(database: DatabaseSync): void {
|
function migrateStorageTables(database: DatabaseSync): void {
|
||||||
database.exec(`
|
database.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS storage_backends (
|
CREATE TABLE IF NOT EXISTS storage_backends (
|
||||||
@@ -112,6 +256,13 @@ function migrateStorageTables(database: DatabaseSync): void {
|
|||||||
content_type TEXT NOT NULL,
|
content_type TEXT NOT NULL,
|
||||||
size INTEGER NOT NULL,
|
size INTEGER NOT NULL,
|
||||||
uploaded_at TEXT NOT NULL,
|
uploaded_at TEXT NOT NULL,
|
||||||
|
media_type TEXT NOT NULL DEFAULT 'image',
|
||||||
|
hls_key TEXT,
|
||||||
|
transcode_status TEXT NOT NULL DEFAULT 'none',
|
||||||
|
transcode_progress INTEGER NOT NULL DEFAULT 0,
|
||||||
|
thumb_key TEXT,
|
||||||
|
source_deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
transcode_preset TEXT,
|
||||||
FOREIGN KEY (backend_id) REFERENCES storage_backends(id)
|
FOREIGN KEY (backend_id) REFERENCES storage_backends(id)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_images_uploaded_at ON images(uploaded_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_images_uploaded_at ON images(uploaded_at DESC);
|
||||||
@@ -127,6 +278,15 @@ function migrateStorageTables(database: DatabaseSync): void {
|
|||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
migrateStorageBackendTelegramType(database)
|
migrateStorageBackendTelegramType(database)
|
||||||
|
migrateImagesMediaType(database)
|
||||||
|
migrateImagesTranscode(database)
|
||||||
|
migrateImagesTranscodeProgress(database)
|
||||||
|
migrateImagesThumb(database)
|
||||||
|
migrateImagesSourceDeleted(database)
|
||||||
|
migrateImagesTranscodePreset(database)
|
||||||
|
database.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_images_media_type ON images(media_type);
|
||||||
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureStorageSchema(): void {
|
export function ensureStorageSchema(): void {
|
||||||
|
|||||||
+30
-13
@@ -18,7 +18,13 @@ import {
|
|||||||
listImages as listImagesFromIndex,
|
listImages as listImagesFromIndex,
|
||||||
searchImages as searchImagesFromIndex
|
searchImages as searchImagesFromIndex
|
||||||
} from './image-index'
|
} from './image-index'
|
||||||
import { DEFAULT_FOLDER, toCanonicalImageKey, validateImageKey } from './image-key'
|
import {
|
||||||
|
DEFAULT_FOLDER,
|
||||||
|
isVideoSidecarKey,
|
||||||
|
mediaTypeForKey,
|
||||||
|
toCanonicalImageKey,
|
||||||
|
validateStorageKey
|
||||||
|
} from './image-key'
|
||||||
import type { TagFilterInput } from './tags'
|
import type { TagFilterInput } from './tags'
|
||||||
import {
|
import {
|
||||||
getActiveBackend,
|
getActiveBackend,
|
||||||
@@ -26,9 +32,11 @@ import {
|
|||||||
} from './storage/resolver'
|
} from './storage/resolver'
|
||||||
import type {
|
import type {
|
||||||
FolderStorageStat,
|
FolderStorageStat,
|
||||||
|
MediaType,
|
||||||
PaginatedResult,
|
PaginatedResult,
|
||||||
StoredImage,
|
StoredImage,
|
||||||
StoredImageMeta
|
StoredImageMeta,
|
||||||
|
TranscodeStatus
|
||||||
} from './storage/types'
|
} from './storage/types'
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -57,7 +65,7 @@ async function fileExistsAtKey(key: string): Promise<boolean> {
|
|||||||
export async function resolveStorageImageKey(key: string): Promise<string | null> {
|
export async function resolveStorageImageKey(key: string): Promise<string | null> {
|
||||||
if (!key || typeof key !== 'string') return null
|
if (!key || typeof key !== 'string') return null
|
||||||
|
|
||||||
if (validateImageKey(key)) {
|
if (validateStorageKey(key)) {
|
||||||
if (await fileExistsAtKey(key) || getImageIndexRow(key)) return key
|
if (await fileExistsAtKey(key) || getImageIndexRow(key)) return key
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -106,7 +114,8 @@ export async function putImage(
|
|||||||
originalName: meta.originalName,
|
originalName: meta.originalName,
|
||||||
contentType: meta.contentType,
|
contentType: meta.contentType,
|
||||||
size: meta.size,
|
size: meta.size,
|
||||||
uploadedAt: meta.uploadedAt
|
uploadedAt: meta.uploadedAt,
|
||||||
|
mediaType: meta.mediaType
|
||||||
})
|
})
|
||||||
return backend.id
|
return backend.id
|
||||||
}
|
}
|
||||||
@@ -134,15 +143,19 @@ export async function headImage(key: string): Promise<StoredImage | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
insertImageIndex({
|
// HLS 播放列表与分片不建索引,避免污染视频库
|
||||||
key: stored.key,
|
if (!isVideoSidecarKey(stored.key)) {
|
||||||
backendId: backend.id,
|
insertImageIndex({
|
||||||
userId: stored.userId,
|
key: stored.key,
|
||||||
originalName: stored.originalName,
|
backendId: backend.id,
|
||||||
contentType: stored.contentType,
|
userId: stored.userId,
|
||||||
size: stored.size,
|
originalName: stored.originalName,
|
||||||
uploadedAt: stored.uploadedAt
|
contentType: stored.contentType,
|
||||||
})
|
size: stored.size,
|
||||||
|
uploadedAt: stored.uploadedAt,
|
||||||
|
mediaType: mediaTypeForKey(stored.key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return { ...stored, backendId: backend.id }
|
return { ...stored, backendId: backend.id }
|
||||||
}
|
}
|
||||||
@@ -214,6 +227,8 @@ export async function listImages(options: {
|
|||||||
contentType?: string
|
contentType?: string
|
||||||
uploadSource?: 'web' | 'api'
|
uploadSource?: 'web' | 'api'
|
||||||
tagFilter?: TagFilterInput
|
tagFilter?: TagFilterInput
|
||||||
|
mediaType?: MediaType
|
||||||
|
transcodeStatuses?: TranscodeStatus[]
|
||||||
}): Promise<PaginatedResult<StoredImage>> {
|
}): Promise<PaginatedResult<StoredImage>> {
|
||||||
return listImagesFromIndex(options)
|
return listImagesFromIndex(options)
|
||||||
}
|
}
|
||||||
@@ -227,6 +242,8 @@ export async function searchImages(options: {
|
|||||||
contentType?: string
|
contentType?: string
|
||||||
uploadSource?: 'web' | 'api'
|
uploadSource?: 'web' | 'api'
|
||||||
tagFilter?: TagFilterInput
|
tagFilter?: TagFilterInput
|
||||||
|
mediaType?: MediaType
|
||||||
|
transcodeStatuses?: TranscodeStatus[]
|
||||||
}): Promise<PaginatedResult<StoredImage>> {
|
}): Promise<PaginatedResult<StoredImage>> {
|
||||||
return searchImagesFromIndex(options)
|
return searchImagesFromIndex(options)
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@ import type { ReadStream } from 'node:fs'
|
|||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import { contentTypeFromKey } from '../content-type'
|
import { contentTypeFromKey } from '../content-type'
|
||||||
import { getDataDir } from '../data-dir'
|
import { getDataDir } from '../data-dir'
|
||||||
import { validateImageKey } from '../image-key'
|
import { validateStorageKey } from '../image-key'
|
||||||
import type { StoredImage, StoredImageMeta, StorageBackend } from './types'
|
import type { StoredImage, StoredImageMeta, StorageBackend } from './types'
|
||||||
|
|
||||||
const META_SUFFIX = '.meta.json'
|
const META_SUFFIX = '.meta.json'
|
||||||
@@ -44,7 +44,7 @@ export class LocalStorageBackend implements StorageBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async head(key: string): Promise<StoredImage | null> {
|
async head(key: string): Promise<StoredImage | null> {
|
||||||
if (!validateImageKey(key)) return null
|
if (!validateStorageKey(key)) return null
|
||||||
|
|
||||||
let stat
|
let stat
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
S3Client
|
S3Client
|
||||||
} from '@aws-sdk/client-s3'
|
} from '@aws-sdk/client-s3'
|
||||||
import type { Readable } from 'node:stream'
|
import type { Readable } from 'node:stream'
|
||||||
import { validateImageKey } from '../image-key'
|
import { validateStorageKey } from '../image-key'
|
||||||
import type {
|
import type {
|
||||||
S3BackendConfig,
|
S3BackendConfig,
|
||||||
S3BackendSecrets,
|
S3BackendSecrets,
|
||||||
@@ -133,7 +133,7 @@ export class S3StorageBackend implements StorageBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async head(key: string): Promise<StoredImage | null> {
|
async head(key: string): Promise<StoredImage | null> {
|
||||||
if (!validateImageKey(key)) return null
|
if (!validateStorageKey(key)) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.client.send(new HeadObjectCommand({
|
const response = await this.client.send(new HeadObjectCommand({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Readable } from 'node:stream'
|
import { Readable } from 'node:stream'
|
||||||
import { getImageIndexAsStored } from '../image-index'
|
import { getImageIndexAsStored } from '../image-index'
|
||||||
import { validateImageKey } from '../image-key'
|
import { validateStorageKey } from '../image-key'
|
||||||
import { logException } from '../logger'
|
import { logException } from '../logger'
|
||||||
import {
|
import {
|
||||||
deleteStorageObjectRef,
|
deleteStorageObjectRef,
|
||||||
@@ -338,7 +338,7 @@ export class TelegramStorageBackend implements StorageBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async put(key: string, bytes: Uint8Array, _meta: StoredImageMeta): Promise<void> {
|
async put(key: string, bytes: Uint8Array, _meta: StoredImageMeta): Promise<void> {
|
||||||
if (!validateImageKey(key)) {
|
if (!validateStorageKey(key)) {
|
||||||
throw new Error('Invalid image key')
|
throw new Error('Invalid image key')
|
||||||
}
|
}
|
||||||
if (bytes.byteLength > TELEGRAM_MAX_FILE_BYTES) {
|
if (bytes.byteLength > TELEGRAM_MAX_FILE_BYTES) {
|
||||||
@@ -393,7 +393,7 @@ export class TelegramStorageBackend implements StorageBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async head(key: string): Promise<StoredImage | null> {
|
async head(key: string): Promise<StoredImage | null> {
|
||||||
if (!validateImageKey(key)) return null
|
if (!validateStorageKey(key)) return null
|
||||||
|
|
||||||
const indexed = getImageIndexAsStored(key)
|
const indexed = getImageIndexAsStored(key)
|
||||||
if (!indexed || indexed.backendId !== this.id) {
|
if (!indexed || indexed.backendId !== this.id) {
|
||||||
|
|||||||
@@ -6,12 +6,29 @@ export interface StoredImageMeta {
|
|||||||
contentType: string
|
contentType: string
|
||||||
size: number
|
size: number
|
||||||
userId?: number | null
|
userId?: number | null
|
||||||
|
/** 索引表中的资源类型:图片或文件床文件 */
|
||||||
|
mediaType?: MediaType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MediaType = 'image' | 'video' | 'file'
|
||||||
|
|
||||||
|
export type TranscodeStatus = 'none' | 'pending' | 'running' | 'done' | 'failed'
|
||||||
|
|
||||||
export interface StoredImage extends StoredImageMeta {
|
export interface StoredImage extends StoredImageMeta {
|
||||||
key: string
|
key: string
|
||||||
mtimeMs: number
|
mtimeMs: number
|
||||||
backendId?: string
|
backendId?: string
|
||||||
|
/** HLS 播放列表 key(转码完成后有值) */
|
||||||
|
hlsKey?: string | null
|
||||||
|
transcodeStatus?: TranscodeStatus
|
||||||
|
/** 转码进度 0–100 */
|
||||||
|
transcodeProgress?: number
|
||||||
|
/** 本视频使用的转码档位 */
|
||||||
|
transcodePreset?: string | null
|
||||||
|
/** 视频封面缩略图 key */
|
||||||
|
thumbKey?: string | null
|
||||||
|
/** 转码成功后源视频是否已删除 */
|
||||||
|
sourceDeleted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedResult<T> {
|
export interface PaginatedResult<T> {
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
DEFAULT_TRANSCODE_CONCURRENCY,
|
||||||
|
DEFAULT_TRANSCODE_PRESET,
|
||||||
|
DEFAULT_TRANSCODE_THREADS,
|
||||||
|
getTranscodeConcurrency,
|
||||||
|
getTranscodeSettings,
|
||||||
|
hlsPlaylistKey,
|
||||||
|
parseTranscodePreset,
|
||||||
|
setTranscodeSettings,
|
||||||
|
thumbKeyFor
|
||||||
|
} from './transcode'
|
||||||
|
import { isVideoSidecarKey, mediaTypeForKey } from './image-key'
|
||||||
|
|
||||||
|
describe('hlsPlaylistKey', () => {
|
||||||
|
it('uses the video key without extension as the hls directory', () => {
|
||||||
|
expect(hlsPlaylistKey('videos/2026/10/abc123.mp4'))
|
||||||
|
.toBe('videos/2026/10/abc123/index.m3u8')
|
||||||
|
expect(hlsPlaylistKey('videos/abc123.mkv'))
|
||||||
|
.toBe('videos/abc123/index.m3u8')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isVideoSidecarKey', () => {
|
||||||
|
it('detects playlists, segments and thumbnails', () => {
|
||||||
|
expect(isVideoSidecarKey('videos/2026/10/abc/index.m3u8')).toBe(true)
|
||||||
|
expect(isVideoSidecarKey('videos/2026/10/abc/seg000.ts')).toBe(true)
|
||||||
|
expect(isVideoSidecarKey('videos/2026/10/abc.webp')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the original video and other media out', () => {
|
||||||
|
expect(isVideoSidecarKey('videos/2026/10/abc.mp4')).toBe(false)
|
||||||
|
expect(isVideoSidecarKey('files/2026/10/abc.pdf')).toBe(false)
|
||||||
|
expect(isVideoSidecarKey('images/2026/10/abc.webp')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds thumbnail keys next to the video', () => {
|
||||||
|
expect(thumbKeyFor('videos/2026/10/abc.mp4')).toBe('videos/2026/10/abc.webp')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mediaTypeForKey', () => {
|
||||||
|
it('maps folders to media types', () => {
|
||||||
|
expect(mediaTypeForKey('images/2026/10/a.webp')).toBe('image')
|
||||||
|
expect(mediaTypeForKey('videos/2026/10/a.mp4')).toBe('video')
|
||||||
|
expect(mediaTypeForKey('files/2026/10/a.pdf')).toBe('file')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('transcode settings', () => {
|
||||||
|
it('defaults to one concurrent job with one thread', () => {
|
||||||
|
expect(DEFAULT_TRANSCODE_CONCURRENCY).toBe(1)
|
||||||
|
expect(DEFAULT_TRANSCODE_THREADS).toBe(1)
|
||||||
|
expect(getTranscodeSettings()).toEqual({
|
||||||
|
concurrency: 1,
|
||||||
|
threads: 1,
|
||||||
|
preset: 'veryfast',
|
||||||
|
bitratePercent: 80
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips stored values', () => {
|
||||||
|
setTranscodeSettings({ concurrency: 3, threads: 2 })
|
||||||
|
expect(getTranscodeSettings()).toEqual({
|
||||||
|
concurrency: 3,
|
||||||
|
threads: 2,
|
||||||
|
preset: 'veryfast',
|
||||||
|
bitratePercent: 80
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the default when the stored value is out of range', () => {
|
||||||
|
setTranscodeSettings({ concurrency: 99 })
|
||||||
|
expect(getTranscodeConcurrency()).toBe(DEFAULT_TRANSCODE_CONCURRENCY)
|
||||||
|
|
||||||
|
setTranscodeSettings({ concurrency: 1, threads: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores the quality preset and rejects unknown ones', () => {
|
||||||
|
setTranscodeSettings({ preset: 'slow' })
|
||||||
|
expect(getTranscodeSettings().preset).toBe('slow')
|
||||||
|
|
||||||
|
expect(parseTranscodePreset('nope')).toBeNull()
|
||||||
|
setTranscodeSettings({ preset: DEFAULT_TRANSCODE_PRESET })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,712 @@
|
|||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import type { ChildProcess } from 'node:child_process'
|
||||||
|
import { createWriteStream, promises as fs } from 'node:fs'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { basename, join } from 'node:path'
|
||||||
|
import { pipeline } from 'node:stream/promises'
|
||||||
|
import { getDataDir } from './data-dir'
|
||||||
|
import {
|
||||||
|
listAllImageIndexRows,
|
||||||
|
listPendingTranscodeKeys,
|
||||||
|
listTranscodedSourceRows,
|
||||||
|
updateImageTranscode
|
||||||
|
} from './image-index'
|
||||||
|
import {
|
||||||
|
VIDEO_FOLDER,
|
||||||
|
extensionFromFilename,
|
||||||
|
isVideoSidecarKey,
|
||||||
|
validateStorageKey,
|
||||||
|
validateVideoKey
|
||||||
|
} from './image-key'
|
||||||
|
import {
|
||||||
|
createImageStream,
|
||||||
|
deleteImage,
|
||||||
|
headImage
|
||||||
|
} from './storage'
|
||||||
|
import { getBackendForKey } from './storage/resolver'
|
||||||
|
import { logException, logInfo } from './logger'
|
||||||
|
import {
|
||||||
|
getSetting,
|
||||||
|
SETTINGS_TRANSCODE_CONCURRENCY,
|
||||||
|
SETTINGS_TRANSCODE_BITRATE,
|
||||||
|
SETTINGS_TRANSCODE_PRESET,
|
||||||
|
SETTINGS_TRANSCODE_THREADS,
|
||||||
|
setSetting
|
||||||
|
} from './db'
|
||||||
|
|
||||||
|
/** HLS 分片时长(秒) */
|
||||||
|
const HLS_SEGMENT_TIME = 6
|
||||||
|
|
||||||
|
export const DEFAULT_TRANSCODE_CONCURRENCY = 1
|
||||||
|
export const DEFAULT_TRANSCODE_THREADS = 1
|
||||||
|
export const MAX_TRANSCODE_CONCURRENCY = 8
|
||||||
|
export const MAX_TRANSCODE_THREADS = 64
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转码档位:直接封装(不重编码)/ 速度 / 平衡 / 质量。
|
||||||
|
* 注意 CRF 模式下 preset 越慢画质越好、体积也越大。
|
||||||
|
*/
|
||||||
|
export const TRANSCODE_PRESETS = ['copy', 'veryfast', 'medium', 'slow', 'compress'] as const
|
||||||
|
export type TranscodePreset = (typeof TRANSCODE_PRESETS)[number]
|
||||||
|
export const DEFAULT_TRANSCODE_PRESET: TranscodePreset = 'veryfast'
|
||||||
|
|
||||||
|
/** 「压缩」档的目标码率占源码率的百分比 */
|
||||||
|
export const DEFAULT_TRANSCODE_BITRATE_PERCENT = 80
|
||||||
|
export const MIN_TRANSCODE_BITRATE_PERCENT = 10
|
||||||
|
export const MAX_TRANSCODE_BITRATE_PERCENT = 100
|
||||||
|
|
||||||
|
export function getTranscodeBitratePercent(): number {
|
||||||
|
return parseBoundedInt(
|
||||||
|
getSetting(SETTINGS_TRANSCODE_BITRATE),
|
||||||
|
DEFAULT_TRANSCODE_BITRATE_PERCENT,
|
||||||
|
MIN_TRANSCODE_BITRATE_PERCENT,
|
||||||
|
MAX_TRANSCODE_BITRATE_PERCENT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTranscodePreset(raw: string | null | undefined): TranscodePreset | null {
|
||||||
|
const value = raw?.trim()
|
||||||
|
if (!value) return null
|
||||||
|
return (TRANSCODE_PRESETS as readonly string[]).includes(value)
|
||||||
|
? value as TranscodePreset
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBoundedInt(
|
||||||
|
raw: string | null,
|
||||||
|
fallback: number,
|
||||||
|
min: number,
|
||||||
|
max: number
|
||||||
|
): number {
|
||||||
|
const value = Number(raw)
|
||||||
|
if (!Number.isInteger(value) || value < min || value > max) return fallback
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscodeSettings {
|
||||||
|
concurrency: number
|
||||||
|
threads: number
|
||||||
|
preset: TranscodePreset
|
||||||
|
bitratePercent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同时进行的转码任务数,默认 1(串行) */
|
||||||
|
export function getTranscodeConcurrency(): number {
|
||||||
|
return parseBoundedInt(
|
||||||
|
getSetting(SETTINGS_TRANSCODE_CONCURRENCY),
|
||||||
|
DEFAULT_TRANSCODE_CONCURRENCY,
|
||||||
|
1,
|
||||||
|
MAX_TRANSCODE_CONCURRENCY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每个转码任务使用的 CPU 核心数,默认 1 */
|
||||||
|
export function getTranscodeThreads(): number {
|
||||||
|
return parseBoundedInt(
|
||||||
|
getSetting(SETTINGS_TRANSCODE_THREADS),
|
||||||
|
DEFAULT_TRANSCODE_THREADS,
|
||||||
|
1,
|
||||||
|
MAX_TRANSCODE_THREADS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTranscodeSettings(): TranscodeSettings {
|
||||||
|
return {
|
||||||
|
concurrency: getTranscodeConcurrency(),
|
||||||
|
threads: getTranscodeThreads(),
|
||||||
|
preset: parseTranscodePreset(getSetting(SETTINGS_TRANSCODE_PRESET)) ?? DEFAULT_TRANSCODE_PRESET,
|
||||||
|
bitratePercent: getTranscodeBitratePercent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTranscodeSettings(patch: {
|
||||||
|
concurrency?: number
|
||||||
|
threads?: number
|
||||||
|
preset?: TranscodePreset
|
||||||
|
bitratePercent?: number
|
||||||
|
}): TranscodeSettings {
|
||||||
|
if (patch.concurrency !== undefined) {
|
||||||
|
setSetting(SETTINGS_TRANSCODE_CONCURRENCY, String(patch.concurrency))
|
||||||
|
}
|
||||||
|
if (patch.threads !== undefined) {
|
||||||
|
setSetting(SETTINGS_TRANSCODE_THREADS, String(patch.threads))
|
||||||
|
}
|
||||||
|
if (patch.preset !== undefined) {
|
||||||
|
setSetting(SETTINGS_TRANSCODE_PRESET, patch.preset)
|
||||||
|
}
|
||||||
|
if (patch.bitratePercent !== undefined) {
|
||||||
|
setSetting(SETTINGS_TRANSCODE_BITRATE, String(patch.bitratePercent))
|
||||||
|
}
|
||||||
|
return getTranscodeSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripExtension(key: string): string {
|
||||||
|
const slash = key.lastIndexOf('/')
|
||||||
|
const dot = key.lastIndexOf('.')
|
||||||
|
return dot > slash ? key.slice(0, dot) : key
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionOf(key: string): string {
|
||||||
|
const dot = key.lastIndexOf('.')
|
||||||
|
return dot > 0 ? key.slice(dot + 1) : 'mp4'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 视频 key 对应的 HLS 播放列表 key(去扩展名后作为目录) */
|
||||||
|
export function hlsPlaylistKey(videoKey: string): string {
|
||||||
|
return `${stripExtension(videoKey)}/index.m3u8`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 视频 key 对应的封面缩略图 key(同目录同名,扩展名换成 webp) */
|
||||||
|
export function thumbKeyFor(videoKey: string): string {
|
||||||
|
return `${stripExtension(videoKey)}.webp`
|
||||||
|
}
|
||||||
|
|
||||||
|
function runThumbFfmpeg(inputPath: string, outputPath: string, seek: number): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn('ffmpeg', [
|
||||||
|
'-hide_banner',
|
||||||
|
'-loglevel', 'error',
|
||||||
|
'-y',
|
||||||
|
'-ss', String(seek),
|
||||||
|
'-i', inputPath,
|
||||||
|
'-frames:v', '1',
|
||||||
|
'-vf', 'scale=640:-2',
|
||||||
|
'-c:v', 'libwebp',
|
||||||
|
'-quality', '80',
|
||||||
|
outputPath
|
||||||
|
], { windowsHide: true })
|
||||||
|
|
||||||
|
let stderr = ''
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
stderr += String(chunk)
|
||||||
|
})
|
||||||
|
child.on('error', reject)
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const detail = stderr.trim().split('\n').filter(Boolean).pop()
|
||||||
|
reject(new Error(detail || `ffmpeg 退出码 ${code}`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 抽取视频封面并写入存储,返回缩略图 key。
|
||||||
|
* 优先取第 1 秒的画面,短视频或抽取失败时退回第一帧。
|
||||||
|
*/
|
||||||
|
export async function generateVideoThumbnail(videoKey: string): Promise<string | null> {
|
||||||
|
const stored = await headImage(videoKey)
|
||||||
|
if (!stored) return null
|
||||||
|
|
||||||
|
const workDir = join(tmpdir(), `pichost-thumb-${randomUUID()}`)
|
||||||
|
const inputPath = join(workDir, `input.${extensionOf(videoKey)}`)
|
||||||
|
const outputPath = join(workDir, 'thumb.webp')
|
||||||
|
|
||||||
|
await fs.mkdir(workDir, { recursive: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await streamToFile(await createImageStream(videoKey), inputPath)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runThumbFfmpeg(inputPath, outputPath, 1)
|
||||||
|
} catch {
|
||||||
|
await runThumbFfmpeg(inputPath, outputPath, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await fs.readFile(outputPath)
|
||||||
|
const thumbKey = thumbKeyFor(videoKey)
|
||||||
|
const backend = await getBackendForKey(videoKey)
|
||||||
|
|
||||||
|
await backend.put(thumbKey, new Uint8Array(bytes), {
|
||||||
|
originalName: `${basename(thumbKey)}`,
|
||||||
|
uploadedAt: new Date().toISOString(),
|
||||||
|
contentType: 'image/webp',
|
||||||
|
size: bytes.byteLength,
|
||||||
|
userId: stored.userId ?? null,
|
||||||
|
mediaType: 'video'
|
||||||
|
})
|
||||||
|
|
||||||
|
return thumbKey
|
||||||
|
} catch (error) {
|
||||||
|
logException('video thumbnail failed', error, { key: videoKey })
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
await fs.rm(workDir, { recursive: true, force: true }).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 正在转码的视频 → ffmpeg 进程,用于删除时中断 */
|
||||||
|
const runningProcesses = new Map<string, ChildProcess>()
|
||||||
|
|
||||||
|
/** 读取视频总时长(秒);失败返回 0,表示无法计算进度 */
|
||||||
|
function probeDuration(inputPath: string): Promise<number> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn('ffprobe', [
|
||||||
|
'-v', 'error',
|
||||||
|
'-show_entries', 'format=duration',
|
||||||
|
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||||
|
inputPath
|
||||||
|
], { windowsHide: true })
|
||||||
|
|
||||||
|
let output = ''
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
output += String(chunk)
|
||||||
|
})
|
||||||
|
child.on('error', () => resolve(0))
|
||||||
|
child.on('close', () => {
|
||||||
|
const value = Number.parseFloat(output.trim())
|
||||||
|
resolve(Number.isFinite(value) && value > 0 ? value : 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取视频总码率(bps);失败返回 0 */
|
||||||
|
function probeBitrate(inputPath: string): Promise<number> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn('ffprobe', [
|
||||||
|
'-v', 'error',
|
||||||
|
'-show_entries', 'format=bit_rate',
|
||||||
|
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||||
|
inputPath
|
||||||
|
], { windowsHide: true })
|
||||||
|
|
||||||
|
let output = ''
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
output += String(chunk)
|
||||||
|
})
|
||||||
|
child.on('error', () => resolve(0))
|
||||||
|
child.on('close', () => {
|
||||||
|
const value = Number.parseInt(output.trim(), 10)
|
||||||
|
resolve(Number.isFinite(value) && value > 0 ? value : 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取音轨码率(bps);没有音轨或读不到时返回 0。
|
||||||
|
* 音频目标码率会取「128k 与源音频码率」中的较小值,避免低码率音频被放大。
|
||||||
|
*/
|
||||||
|
function probeAudioBitrate(inputPath: string): Promise<number> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn('ffprobe', [
|
||||||
|
'-v', 'error',
|
||||||
|
'-select_streams', 'a:0',
|
||||||
|
'-show_entries', 'stream=bit_rate',
|
||||||
|
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||||
|
inputPath
|
||||||
|
], { windowsHide: true })
|
||||||
|
|
||||||
|
let output = ''
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
output += String(chunk)
|
||||||
|
})
|
||||||
|
child.on('error', () => resolve(0))
|
||||||
|
child.on('close', () => {
|
||||||
|
const value = Number.parseInt(output.trim(), 10)
|
||||||
|
resolve(Number.isFinite(value) && value > 0 ? value : 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单码率 H.264 + AAC 切片,产出 index.m3u8 与 segNNN.ts */
|
||||||
|
function runFfmpeg(
|
||||||
|
videoKey: string,
|
||||||
|
inputPath: string,
|
||||||
|
outputDir: string,
|
||||||
|
sourceBitrate: number,
|
||||||
|
sourceAudioBitrate: number,
|
||||||
|
onProgressUs: (outTimeUs: number) => void
|
||||||
|
): Promise<void> {
|
||||||
|
const { preset, bitratePercent } = getTranscodeSettings()
|
||||||
|
|
||||||
|
// 音频不放大也不压缩:跟随源音轨码率(读不到时才用 128k)
|
||||||
|
const audioBitrate = sourceAudioBitrate > 0
|
||||||
|
? sourceAudioBitrate
|
||||||
|
: 128_000
|
||||||
|
const audioKbps = `${Math.round(audioBitrate / 1000)}k`
|
||||||
|
|
||||||
|
let codecArgs: string[]
|
||||||
|
if (preset === 'copy') {
|
||||||
|
// 不重新编码:只把音视频流重新封装成 HLS 切片
|
||||||
|
codecArgs = ['-c', 'copy', '-bsf:v', 'h264_mp4toannexb']
|
||||||
|
} else if (preset === 'compress') {
|
||||||
|
// 目标「总码率」= 源码率 × 比例,再扣掉音频预算得到视频码率,避免总量反超
|
||||||
|
const targetTotal = Math.round(sourceBitrate * bitratePercent / 100)
|
||||||
|
const targetVideo = Math.max(80_000, targetTotal - audioBitrate)
|
||||||
|
codecArgs = [
|
||||||
|
'-c:v', 'libx264',
|
||||||
|
'-preset', 'medium',
|
||||||
|
'-b:v', String(targetVideo),
|
||||||
|
'-maxrate', String(Math.round(targetVideo * 1.2)),
|
||||||
|
'-bufsize', String(targetVideo * 2),
|
||||||
|
'-c:a', 'aac',
|
||||||
|
'-b:a', audioKbps
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
// 恒定质量优先,但用「源码率 - 音频预算」卡住视频峰值,避免体积反超源文件
|
||||||
|
const ceiling = sourceBitrate > audioBitrate ? sourceBitrate - audioBitrate : 0
|
||||||
|
codecArgs = [
|
||||||
|
'-c:v', 'libx264',
|
||||||
|
'-preset', preset,
|
||||||
|
'-crf', '23',
|
||||||
|
...(ceiling
|
||||||
|
? ['-maxrate', String(ceiling), '-bufsize', String(ceiling * 2)]
|
||||||
|
: []),
|
||||||
|
'-c:a', 'aac',
|
||||||
|
'-b:a', audioKbps
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn('ffmpeg', [
|
||||||
|
'-hide_banner',
|
||||||
|
'-loglevel', 'error',
|
||||||
|
'-nostats',
|
||||||
|
'-progress', 'pipe:1',
|
||||||
|
'-y',
|
||||||
|
'-i', inputPath,
|
||||||
|
'-threads', String(getTranscodeThreads()),
|
||||||
|
...codecArgs,
|
||||||
|
'-f', 'hls',
|
||||||
|
'-hls_time', String(HLS_SEGMENT_TIME),
|
||||||
|
'-hls_playlist_type', 'vod',
|
||||||
|
// 分片写相对名,避免播放列表里出现本机绝对路径
|
||||||
|
'-hls_segment_filename', 'seg%03d.ts',
|
||||||
|
'index.m3u8'
|
||||||
|
], { cwd: outputDir, windowsHide: true })
|
||||||
|
|
||||||
|
runningProcesses.set(videoKey, child)
|
||||||
|
const release = () => {
|
||||||
|
runningProcesses.delete(videoKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
let stderr = ''
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
stderr += String(chunk)
|
||||||
|
})
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
const match = /out_time_us=(\d+)/.exec(String(chunk))
|
||||||
|
if (match?.[1]) onProgressUs(Number(match[1]))
|
||||||
|
})
|
||||||
|
child.on('error', (error) => {
|
||||||
|
release()
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
child.on('close', (code) => {
|
||||||
|
release()
|
||||||
|
if (code === 0) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const detail = stderr.trim().split('\n').filter(Boolean).pop()
|
||||||
|
reject(new Error(detail || `ffmpeg 退出码 ${code}`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function streamToFile(stream: NodeJS.ReadableStream, filePath: string): Promise<void> {
|
||||||
|
await pipeline(stream, createWriteStream(filePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function streamToString(stream: NodeJS.ReadableStream): Promise<string> {
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(Buffer.from(chunk as Uint8Array))
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks).toString('utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把视频转成 HLS 并写入存储,返回播放列表 key 与产物总大小 */
|
||||||
|
export async function transcodeVideoToHls(
|
||||||
|
videoKey: string
|
||||||
|
): Promise<{ playlistKey: string, totalBytes: number }> {
|
||||||
|
const stored = await headImage(videoKey)
|
||||||
|
if (!stored) throw new Error('视频不存在')
|
||||||
|
|
||||||
|
const workDir = join(tmpdir(), `pichost-hls-${randomUUID()}`)
|
||||||
|
const inputPath = join(workDir, `input.${extensionOf(videoKey)}`)
|
||||||
|
const outputDir = join(workDir, 'out')
|
||||||
|
const dirKey = stripExtension(videoKey)
|
||||||
|
const playlistKey = hlsPlaylistKey(videoKey)
|
||||||
|
|
||||||
|
await fs.mkdir(outputDir, { recursive: true })
|
||||||
|
|
||||||
|
// 记录已写入存储的产物,中途失败时要逐个回收,避免留下残缺切片
|
||||||
|
const writtenKeys: string[] = []
|
||||||
|
const backend = await getBackendForKey(videoKey)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await streamToFile(await createImageStream(videoKey), inputPath)
|
||||||
|
const durationSec = await probeDuration(inputPath)
|
||||||
|
const sourceBitrate = await probeBitrate(inputPath)
|
||||||
|
const sourceAudioBitrate = await probeAudioBitrate(inputPath)
|
||||||
|
let lastProgressAt = 0
|
||||||
|
|
||||||
|
await runFfmpeg(videoKey, inputPath, outputDir, sourceBitrate, sourceAudioBitrate, (outTimeUs) => {
|
||||||
|
if (!durationSec) return
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastProgressAt < 1000) return
|
||||||
|
lastProgressAt = now
|
||||||
|
const percent = Math.round((outTimeUs / (durationSec * 1_000_000)) * 100)
|
||||||
|
updateImageTranscode(videoKey, {
|
||||||
|
status: 'running',
|
||||||
|
progress: Math.min(99, Math.max(1, percent))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const uploadedAt = new Date().toISOString()
|
||||||
|
|
||||||
|
let totalBytes = 0
|
||||||
|
for (const name of await fs.readdir(outputDir)) {
|
||||||
|
const bytes = await fs.readFile(join(outputDir, name))
|
||||||
|
const objectKey = `${dirKey}/${name}`
|
||||||
|
await backend.put(objectKey, new Uint8Array(bytes), {
|
||||||
|
originalName: name,
|
||||||
|
uploadedAt,
|
||||||
|
contentType: name.endsWith('.m3u8')
|
||||||
|
? 'application/vnd.apple.mpegurl'
|
||||||
|
: 'video/mp2t',
|
||||||
|
size: bytes.byteLength,
|
||||||
|
userId: stored.userId ?? null,
|
||||||
|
mediaType: 'video'
|
||||||
|
})
|
||||||
|
writtenKeys.push(objectKey)
|
||||||
|
totalBytes += bytes.byteLength
|
||||||
|
}
|
||||||
|
|
||||||
|
return { playlistKey, totalBytes }
|
||||||
|
} catch (error) {
|
||||||
|
for (const key of writtenKeys) {
|
||||||
|
await backend.delete(key).catch(() => {})
|
||||||
|
}
|
||||||
|
if (backend.type === 'local') {
|
||||||
|
await fs.rmdir(join(getDataDir(), ...dirKey.split('/'))).catch(() => {})
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
await fs.rm(workDir, { recursive: true, force: true }).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除视频对应的 HLS 播放列表与分片 */
|
||||||
|
export async function removeHlsArtifacts(videoKey: string): Promise<void> {
|
||||||
|
const playlistKey = hlsPlaylistKey(videoKey)
|
||||||
|
|
||||||
|
// 封面缩略图与播放列表无关,必须先于提前返回处理
|
||||||
|
await deleteImage(thumbKeyFor(videoKey)).catch(() => {})
|
||||||
|
|
||||||
|
if (!await headImage(playlistKey)) return
|
||||||
|
|
||||||
|
const dirKey = stripExtension(videoKey)
|
||||||
|
const text = await streamToString(await createImageStream(playlistKey))
|
||||||
|
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
const name = line.trim()
|
||||||
|
if (!name || name.startsWith('#')) continue
|
||||||
|
const segmentKey = `${dirKey}/${basename(name)}`
|
||||||
|
if (!validateStorageKey(segmentKey)) continue
|
||||||
|
await deleteImage(segmentKey).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteImage(playlistKey).catch(() => {})
|
||||||
|
|
||||||
|
// 本地磁盘上把清空后的分片目录一并移除
|
||||||
|
const backend = await getBackendForKey(videoKey)
|
||||||
|
if (backend.type === 'local') {
|
||||||
|
await fs.rmdir(join(getDataDir(), ...dirKey.split('/'))).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue: string[] = []
|
||||||
|
const queued = new Set<string>()
|
||||||
|
let activeCount = 0
|
||||||
|
|
||||||
|
async function processOne(videoKey: string): Promise<void> {
|
||||||
|
updateImageTranscode(videoKey, { status: 'running' })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { playlistKey, totalBytes } = await transcodeVideoToHls(videoKey)
|
||||||
|
|
||||||
|
// 转码成功后源文件不再需要,直接删除以节省空间
|
||||||
|
const backend = await getBackendForKey(videoKey)
|
||||||
|
await backend.delete(videoKey).catch(() => {})
|
||||||
|
|
||||||
|
updateImageTranscode(videoKey, {
|
||||||
|
status: 'done',
|
||||||
|
hlsKey: playlistKey,
|
||||||
|
progress: 100,
|
||||||
|
sourceDeleted: true,
|
||||||
|
size: totalBytes
|
||||||
|
})
|
||||||
|
logInfo('transcode done', { key: videoKey, hlsKey: playlistKey })
|
||||||
|
} catch (error) {
|
||||||
|
updateImageTranscode(videoKey, { status: 'failed' })
|
||||||
|
logException('transcode failed', error, { key: videoKey })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按设置的并发数消费队列(默认 1,即串行) */
|
||||||
|
function drain(): void {
|
||||||
|
while (queue.length && activeCount < getTranscodeConcurrency()) {
|
||||||
|
const key = queue.shift()!
|
||||||
|
activeCount += 1
|
||||||
|
|
||||||
|
void processOne(key).finally(() => {
|
||||||
|
activeCount -= 1
|
||||||
|
queued.delete(key)
|
||||||
|
drain()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enqueueTranscode(videoKey: string): void {
|
||||||
|
if (!validateVideoKey(videoKey) || queued.has(videoKey)) return
|
||||||
|
queued.add(videoKey)
|
||||||
|
queue.push(videoKey)
|
||||||
|
// 记录本视频使用的档位,便于上传页展示
|
||||||
|
const preset = getTranscodeSettings().preset
|
||||||
|
updateImageTranscode(videoKey, {
|
||||||
|
status: 'pending',
|
||||||
|
// 压缩档带上百分比,例如 compress:80
|
||||||
|
preset: preset === 'compress'
|
||||||
|
? `compress:${getTranscodeBitratePercent()}`
|
||||||
|
: preset
|
||||||
|
})
|
||||||
|
drain()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消排队中或正在转码的任务(删除视频时调用) */
|
||||||
|
export function cancelTranscode(videoKey: string): void {
|
||||||
|
const index = queue.indexOf(videoKey)
|
||||||
|
if (index >= 0) queue.splice(index, 1)
|
||||||
|
queued.delete(videoKey)
|
||||||
|
|
||||||
|
const child = runningProcesses.get(videoKey)
|
||||||
|
if (child) {
|
||||||
|
runningProcesses.delete(videoKey)
|
||||||
|
child.kill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 补删转码完成后仍保留的源文件。
|
||||||
|
* 仅当 HLS 播放列表确实存在时才删除,避免产物缺失时把视频删没。
|
||||||
|
*/
|
||||||
|
export async function cleanupTranscodedSources(): Promise<number> {
|
||||||
|
let removed = 0
|
||||||
|
|
||||||
|
for (const row of listTranscodedSourceRows()) {
|
||||||
|
try {
|
||||||
|
if (!await headImage(row.hlsKey)) continue
|
||||||
|
|
||||||
|
const backend = await getBackendForKey(row.key)
|
||||||
|
await backend.delete(row.key).catch(() => {})
|
||||||
|
updateImageTranscode(row.key, { status: 'done', sourceDeleted: true })
|
||||||
|
removed += 1
|
||||||
|
} catch (error) {
|
||||||
|
logException('cleanup transcoded source failed', error, { key: row.key })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动时把上次中断的任务重新排队 */
|
||||||
|
export function resumePendingTranscodes(): void {
|
||||||
|
for (const key of listPendingTranscodeKeys()) {
|
||||||
|
enqueueTranscode(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理遗留的转码进程。
|
||||||
|
* 开发时热重载会重建模块,队列里的旧 ffmpeg 仍在跑,必须按命令行特征回收。
|
||||||
|
*/
|
||||||
|
export function killOrphanFfmpeg(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const psScript = 'Get-CimInstance Win32_Process -Filter "Name=\'ffmpeg.exe\'" '
|
||||||
|
+ '| Where-Object { $_.CommandLine -like \'*pichost-hls-*\' } '
|
||||||
|
+ '| ForEach-Object { Stop-Process -Id $_.ProcessId -Force }'
|
||||||
|
|
||||||
|
const child = process.platform === 'win32'
|
||||||
|
? spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', psScript], {
|
||||||
|
windowsHide: true,
|
||||||
|
stdio: 'ignore'
|
||||||
|
})
|
||||||
|
: spawn('pkill', ['-f', 'pichost-hls-'], { stdio: 'ignore' })
|
||||||
|
|
||||||
|
child.on('error', () => resolve())
|
||||||
|
child.on('close', () => resolve())
|
||||||
|
} catch {
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理没有对应主视频记录的附属文件(缩略图、HLS 播放列表与分片)。
|
||||||
|
* 正常删除流程已经清理,这里兜底历史残留。
|
||||||
|
*/
|
||||||
|
export async function purgeOrphanVideoAssets(): Promise<number> {
|
||||||
|
const root = join(getDataDir(), VIDEO_FOLDER)
|
||||||
|
const owners = new Set(
|
||||||
|
listAllImageIndexRows()
|
||||||
|
.filter(row => row.key.startsWith(`${VIDEO_FOLDER}/`))
|
||||||
|
.map(row => stripExtension(row.key))
|
||||||
|
)
|
||||||
|
|
||||||
|
let removed = 0
|
||||||
|
|
||||||
|
async function walk(dir: string, keyPrefix: string): Promise<void> {
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(dir, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const name = String(entry.name)
|
||||||
|
const key = `${keyPrefix}/${name}`
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await walk(join(dir, name), key)
|
||||||
|
// 附属文件清空后的空目录一并移除
|
||||||
|
await fs.rmdir(join(dir, name)).catch(() => {})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isVideoSidecarKey(key)) continue
|
||||||
|
|
||||||
|
const extension = extensionFromFilename(name)
|
||||||
|
// 缩略图与视频同名前缀;播放列表和分片归属所在目录
|
||||||
|
const owner = ['webp', 'jpg', 'jpeg', 'png'].includes(extension)
|
||||||
|
? stripExtension(key)
|
||||||
|
: keyPrefix
|
||||||
|
|
||||||
|
if (owners.has(owner)) continue
|
||||||
|
|
||||||
|
await fs.rm(join(dir, name), { force: true }).catch(() => {})
|
||||||
|
removed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await walk(root, VIDEO_FOLDER)
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 进程退出时尽量带走正在运行的 ffmpeg,避免留下孤儿进程 */
|
||||||
|
function killRunningProcesses(): void {
|
||||||
|
for (const child of runningProcesses.values()) {
|
||||||
|
child.kill()
|
||||||
|
}
|
||||||
|
runningProcesses.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('exit', killRunningProcesses)
|
||||||
|
process.on('SIGINT', killRunningProcesses)
|
||||||
|
process.on('SIGTERM', killRunningProcesses)
|
||||||
Reference in new issue
Block a user