feat: 视频缩略图生成、最近文件面板与前端视图重构
- 新增 VideoThumbnailService,基于 ffmpeg 截取视频缩略图,ffprobe 提取时长
- 新增 ManagedThumbnailMap 模型及多数据库迁移,存储缩略图元数据
- 新增 /api/thumbnails/{id} 缩略图流端点
- 新增最近添加/最近播放 API 与前端面板,支持列表/网格双视图切换
- FileRecordDto 扩展 thumbnailUrl、videoDuration、lastPlayedAt 字段
- 前端新增文件库 Tab 导航、卡片网格视图、视频海报与时长信息栏
- 添加文件库目录不再同步全量扫描,改为后台异步自动扫描
This commit is contained in:
@@ -43,6 +43,9 @@ export interface FileRecordDto {
|
||||
streamUrl: string
|
||||
textUrl: string | null
|
||||
browserPlayable: boolean
|
||||
thumbnailUrl: string | null
|
||||
videoDuration: number | null
|
||||
lastPlayedAt: string | null
|
||||
}
|
||||
|
||||
export interface BrowseDirectoryResponse {
|
||||
@@ -98,5 +101,10 @@ export const api = {
|
||||
getTextPreview: (id: number) =>
|
||||
request<TextPreviewDto>(`files/text${qs({ id })}`),
|
||||
mediaUrl: (path: string) => apiUrl(path),
|
||||
thumbnailUrl: (path: string) => apiUrl(path),
|
||||
getRecentFiles: (type: string, count = 12) =>
|
||||
request<FileRecordDto[]>(`files/recent${qs({ type, count })}`),
|
||||
markFilePlayed: (id: number) =>
|
||||
request('files/played', { method: 'POST', body: { id } }),
|
||||
qrCode: () => request<{ url: string; qrCodeBase64: string }>('qrcode'),
|
||||
}
|
||||
|
||||
@@ -498,19 +498,6 @@ a {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.player-panel video {
|
||||
width: 100%;
|
||||
max-height: 54vh;
|
||||
margin-top: 12px;
|
||||
border-radius: 10px;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.player-panel audio {
|
||||
width: 100%;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.open-media-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -524,19 +511,6 @@ a {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.player-panel pre {
|
||||
overflow: auto;
|
||||
max-height: 54vh;
|
||||
margin: 12px 0 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
color: #111827;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mobile-list {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
@@ -724,6 +698,257 @@ a {
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
/* Root tabs */
|
||||
.root-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.root-tabs button {
|
||||
flex: 1;
|
||||
min-height: 36px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.root-tabs button.active {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* View toggle */
|
||||
.view-toggle-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-toggle button {
|
||||
min-height: 30px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 4px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.view-toggle button.active {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.view-toggle button + button {
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* Section header with view toggle inline */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* File grid / card view */
|
||||
.file-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||
gap: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 10px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.file-card {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.file-card.active {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
|
||||
.file-card:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.card-thumb {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
object-fit: contain;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.card-thumb-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 16 / 10;
|
||||
border-radius: 999px;
|
||||
margin: 12px auto 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
color: #fff;
|
||||
background: var(--accent-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 3px;
|
||||
padding: 10px 10px 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-body strong {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.card-body small {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* File thumbnail in list view */
|
||||
.file-thumb {
|
||||
flex: 0 0 72px;
|
||||
width: 72px;
|
||||
height: 48px;
|
||||
border-radius: 6px;
|
||||
object-fit: contain;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.file-list .mobile-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.file-list .mobile-file > span:last-child {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Video info bar */
|
||||
.video-info-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.video-info-bar span {
|
||||
border-radius: 999px;
|
||||
padding: 3px 9px;
|
||||
background: #f2f4f7;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Player media wrapper */
|
||||
.player-media-wrapper {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.player-media-wrapper video {
|
||||
width: 100%;
|
||||
max-height: 54vh;
|
||||
border-radius: 10px;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.player-media-wrapper audio {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.player-media-wrapper pre {
|
||||
overflow: auto;
|
||||
max-height: 54vh;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
color: #111827;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.unsupported-player {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.unsupported-thumb {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
margin: 0 auto 12px;
|
||||
border-radius: 10px;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: contain;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
/* Recent files section */
|
||||
.recent-files {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.admin-layout,
|
||||
.admin-browser {
|
||||
|
||||
@@ -15,6 +15,12 @@ const rootId = ref<number | undefined>()
|
||||
const browsePath = ref<string[]>([])
|
||||
const isBrowsingRoots = ref(true)
|
||||
|
||||
// Recent files & tabs
|
||||
const activeTab = ref<'recent-added' | 'recent-played' | 'libraries'>('libraries')
|
||||
const recentFiles = ref<FileRecordDto[]>([])
|
||||
const recentLoading = ref(false)
|
||||
const viewMode = ref<'list' | 'grid'>('list')
|
||||
|
||||
const qrModal = ref<InstanceType<typeof QrCodeModal> | null>(null)
|
||||
|
||||
const activeRoots = computed(() => roots.value.filter((root) => root.isEnabled && root.isAvailable))
|
||||
@@ -34,8 +40,13 @@ const breadcrumbs = computed(() => {
|
||||
})
|
||||
|
||||
const selectedMediaUrl = computed(() => selectedFile.value ? api.mediaUrl(selectedFile.value.streamUrl) : '')
|
||||
const selectedThumbnailUrl = computed(() =>
|
||||
selectedFile.value?.thumbnailUrl ? api.thumbnailUrl(selectedFile.value.thumbnailUrl) : ''
|
||||
)
|
||||
|
||||
const clientTitle = computed(() => {
|
||||
if (activeTab.value === 'recent-added') return '最近添加'
|
||||
if (activeTab.value === 'recent-played') return '最近播放'
|
||||
if (isBrowsingRoots.value) return '文件库'
|
||||
const root = roots.value.find((r) => r.id === rootId.value)
|
||||
const dir = browsePath.value.length > 0 ? browsePath.value[browsePath.value.length - 1] : ''
|
||||
@@ -54,6 +65,13 @@ function formatSize(bytes: number) {
|
||||
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[index]}`
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number | null) {
|
||||
if (!seconds) return ''
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return ''
|
||||
return new Date(value).toLocaleString()
|
||||
@@ -80,12 +98,35 @@ async function browseDirectory() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecentFiles(type: string) {
|
||||
try {
|
||||
errorMessage.value = ''
|
||||
recentLoading.value = true
|
||||
recentFiles.value = await api.getRecentFiles(type, 24)
|
||||
} catch (error) {
|
||||
setError(error)
|
||||
} finally {
|
||||
recentLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(tab: 'recent-added' | 'recent-played' | 'libraries') {
|
||||
activeTab.value = tab
|
||||
isBrowsingRoots.value = true
|
||||
browseData.value = null
|
||||
selectedFile.value = null
|
||||
textPreview.value = null
|
||||
if (tab === 'recent-added') loadRecentFiles('added')
|
||||
else if (tab === 'recent-played') loadRecentFiles('played')
|
||||
}
|
||||
|
||||
async function enterRoot(id: number) {
|
||||
rootId.value = id
|
||||
isBrowsingRoots.value = false
|
||||
browsePath.value = []
|
||||
selectedFile.value = null
|
||||
textPreview.value = null
|
||||
activeTab.value = 'libraries'
|
||||
await browseDirectory()
|
||||
}
|
||||
|
||||
@@ -96,6 +137,7 @@ function backToRoots() {
|
||||
browsePath.value = []
|
||||
selectedFile.value = null
|
||||
textPreview.value = null
|
||||
activeTab.value = 'libraries'
|
||||
}
|
||||
|
||||
async function navigateTo(path: string) {
|
||||
@@ -119,6 +161,11 @@ async function enterSubdirectory(name: string) {
|
||||
async function selectFile(file: FileRecordDto) {
|
||||
selectedFile.value = file
|
||||
textPreview.value = null
|
||||
|
||||
if (file.mediaType === 'video' || file.mediaType === 'audio') {
|
||||
api.markFilePlayed(file.id).catch(() => {})
|
||||
}
|
||||
|
||||
if (file.mediaType !== 'text') return
|
||||
|
||||
try {
|
||||
@@ -146,7 +193,9 @@ onMounted(async () => {
|
||||
<div>
|
||||
<h1>{{ clientTitle }}</h1>
|
||||
<p>
|
||||
<template v-if="isBrowsingRoots">{{ activeRoots.length }} 个目录</template>
|
||||
<template v-if="activeTab === 'recent-added'">{{ recentFiles.length }} 个文件</template>
|
||||
<template v-else-if="activeTab === 'recent-played'">{{ recentFiles.length }} 个文件</template>
|
||||
<template v-else-if="isBrowsingRoots">{{ activeRoots.length }} 个目录</template>
|
||||
<template v-else-if="browseData">
|
||||
{{ browseData.subdirectories.length }} 个文件夹 · {{ browseData.files.length }} 个文件
|
||||
</template>
|
||||
@@ -161,8 +210,99 @@ onMounted(async () => {
|
||||
|
||||
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
|
||||
|
||||
<!-- Root tabs -->
|
||||
<nav v-if="isBrowsingRoots" class="root-tabs">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: activeTab === 'recent-added' }"
|
||||
@click="switchTab('recent-added')"
|
||||
>最近添加</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: activeTab === 'recent-played' }"
|
||||
@click="switchTab('recent-played')"
|
||||
>最近播放</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: activeTab === 'libraries' }"
|
||||
@click="switchTab('libraries')"
|
||||
>文件库</button>
|
||||
</nav>
|
||||
|
||||
<!-- Recently added / played files -->
|
||||
<section v-if="isBrowsingRoots && activeTab !== 'libraries'" class="recent-files">
|
||||
<div class="view-toggle-bar">
|
||||
<div></div>
|
||||
<div class="view-toggle">
|
||||
<button type="button" :class="{ active: viewMode === 'list' }" @click="viewMode = 'list'">列表</button>
|
||||
<button type="button" :class="{ active: viewMode === 'grid' }" @click="viewMode = 'grid'">网格</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="recentLoading" class="empty-state">加载中...</p>
|
||||
<p v-else-if="recentFiles.length === 0" class="empty-state">
|
||||
{{ activeTab === 'recent-added' ? '暂无最近添加的文件' : '暂无播放记录' }}
|
||||
</p>
|
||||
|
||||
<!-- Grid view -->
|
||||
<div v-else-if="viewMode === 'grid'" class="file-grid">
|
||||
<button
|
||||
v-for="file in recentFiles"
|
||||
:key="file.id"
|
||||
class="file-card"
|
||||
type="button"
|
||||
@click="selectFile(file)"
|
||||
>
|
||||
<img
|
||||
v-if="file.thumbnailUrl"
|
||||
:src="api.thumbnailUrl(file.thumbnailUrl)"
|
||||
class="card-thumb"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
<div v-else class="card-thumb-placeholder">{{ file.mediaType }}</div>
|
||||
<div class="card-body">
|
||||
<strong>{{ file.fileName }}</strong>
|
||||
<small>
|
||||
{{ formatSize(file.sizeBytes) }}
|
||||
<template v-if="file.videoDuration"> · {{ formatDuration(file.videoDuration) }}</template>
|
||||
</small>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- List view -->
|
||||
<div v-else class="file-list">
|
||||
<button
|
||||
v-for="file in recentFiles"
|
||||
:key="file.id"
|
||||
class="mobile-file"
|
||||
@click="selectFile(file)"
|
||||
>
|
||||
<img
|
||||
v-if="file.thumbnailUrl"
|
||||
:src="api.thumbnailUrl(file.thumbnailUrl)"
|
||||
class="file-thumb"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
<span v-else class="type-badge">{{ file.mediaType }}</span>
|
||||
<span>
|
||||
<strong>{{ file.fileName }}</strong>
|
||||
<small>
|
||||
{{ formatSize(file.sizeBytes) }}
|
||||
<template v-if="file.videoDuration"> · {{ formatDuration(file.videoDuration) }}</template>
|
||||
<template v-if="file.lastPlayedAt && activeTab === 'recent-played'">
|
||||
· {{ formatDate(file.lastPlayedAt) }}
|
||||
</template>
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Library root tiles -->
|
||||
<section v-if="isBrowsingRoots" class="root-picker">
|
||||
<section v-if="isBrowsingRoots && activeTab === 'libraries'" class="root-picker">
|
||||
<button
|
||||
v-for="root in activeRoots"
|
||||
:key="root.id"
|
||||
@@ -178,7 +318,7 @@ onMounted(async () => {
|
||||
</section>
|
||||
|
||||
<!-- Directory browser -->
|
||||
<template v-else>
|
||||
<template v-else-if="!isBrowsingRoots">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="breadcrumb-nav">
|
||||
<template v-for="(crumb, index) in breadcrumbs" :key="crumb.path">
|
||||
@@ -222,26 +362,46 @@ onMounted(async () => {
|
||||
<span>{{ selectedFile.extension }}</span>
|
||||
</div>
|
||||
|
||||
<video
|
||||
v-if="selectedFile.mediaType === 'video' && selectedFile.browserPlayable"
|
||||
:key="selectedFile.id"
|
||||
controls
|
||||
playsinline
|
||||
webkit-playsinline
|
||||
preload="metadata"
|
||||
>
|
||||
<source :src="selectedMediaUrl" :type="selectedFile.contentType" />
|
||||
</video>
|
||||
<audio
|
||||
v-else-if="selectedFile.mediaType === 'audio' && selectedFile.browserPlayable"
|
||||
:key="selectedFile.id"
|
||||
controls
|
||||
preload="metadata"
|
||||
>
|
||||
<source :src="selectedMediaUrl" :type="selectedFile.contentType" />
|
||||
</audio>
|
||||
<pre v-else-if="selectedFile.mediaType === 'text'">{{ textPreview?.content ?? '加载中...' }}</pre>
|
||||
<p v-else class="unsupported">浏览器不支持在线播放此格式。</p>
|
||||
<div v-if="selectedFile.mediaType === 'video'" class="video-info-bar">
|
||||
<span v-if="selectedFile.videoDuration">时长 {{ formatDuration(selectedFile.videoDuration) }}</span>
|
||||
<span>{{ formatSize(selectedFile.sizeBytes) }}</span>
|
||||
<span>{{ selectedFile.contentType }}</span>
|
||||
</div>
|
||||
|
||||
<div class="player-media-wrapper">
|
||||
<video
|
||||
v-if="selectedFile.mediaType === 'video' && selectedFile.browserPlayable"
|
||||
:key="selectedFile.id"
|
||||
:poster="selectedThumbnailUrl || undefined"
|
||||
controls
|
||||
playsinline
|
||||
webkit-playsinline
|
||||
preload="metadata"
|
||||
>
|
||||
<source :src="selectedMediaUrl" :type="selectedFile.contentType" />
|
||||
</video>
|
||||
<div
|
||||
v-else-if="selectedFile.mediaType === 'video' && !selectedFile.browserPlayable"
|
||||
class="unsupported-player"
|
||||
>
|
||||
<img
|
||||
v-if="selectedThumbnailUrl"
|
||||
:src="selectedThumbnailUrl"
|
||||
class="unsupported-thumb"
|
||||
alt=""
|
||||
/>
|
||||
<p class="unsupported">浏览器不支持在线播放此格式。</p>
|
||||
</div>
|
||||
<audio
|
||||
v-else-if="selectedFile.mediaType === 'audio' && selectedFile.browserPlayable"
|
||||
:key="selectedFile.id"
|
||||
controls
|
||||
preload="metadata"
|
||||
>
|
||||
<source :src="selectedMediaUrl" :type="selectedFile.contentType" />
|
||||
</audio>
|
||||
<pre v-else-if="selectedFile.mediaType === 'text'">{{ textPreview?.content ?? '加载中...' }}</pre>
|
||||
</div>
|
||||
<a
|
||||
v-if="selectedFile.mediaType !== 'text'"
|
||||
class="open-media-link"
|
||||
@@ -256,8 +416,44 @@ onMounted(async () => {
|
||||
|
||||
<!-- Files -->
|
||||
<section v-if="browseData.files.length > 0" class="browse-section">
|
||||
<h3>文件</h3>
|
||||
<div class="file-list">
|
||||
<div class="section-header">
|
||||
<h3>文件</h3>
|
||||
<div class="view-toggle">
|
||||
<button type="button" :class="{ active: viewMode === 'list' }" @click="viewMode = 'list'">列表</button>
|
||||
<button type="button" :class="{ active: viewMode === 'grid' }" @click="viewMode = 'grid'">网格</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid view -->
|
||||
<div v-if="viewMode === 'grid'" class="file-grid">
|
||||
<button
|
||||
v-for="file in browseData.files"
|
||||
:key="file.id"
|
||||
class="file-card"
|
||||
:class="{ active: selectedFile?.id === file.id }"
|
||||
type="button"
|
||||
@click="selectFile(file)"
|
||||
>
|
||||
<img
|
||||
v-if="file.thumbnailUrl"
|
||||
:src="api.thumbnailUrl(file.thumbnailUrl)"
|
||||
class="card-thumb"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
<div v-else class="card-thumb-placeholder">{{ file.mediaType }}</div>
|
||||
<div class="card-body">
|
||||
<strong>{{ file.fileName }}</strong>
|
||||
<small>
|
||||
{{ formatSize(file.sizeBytes) }}
|
||||
<template v-if="file.videoDuration"> · {{ formatDuration(file.videoDuration) }}</template>
|
||||
</small>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- List view -->
|
||||
<div v-else class="file-list">
|
||||
<button
|
||||
v-for="file in browseData.files"
|
||||
:key="file.id"
|
||||
@@ -266,10 +462,20 @@ onMounted(async () => {
|
||||
type="button"
|
||||
@click="selectFile(file)"
|
||||
>
|
||||
<span class="type-badge">{{ file.mediaType }}</span>
|
||||
<img
|
||||
v-if="file.thumbnailUrl"
|
||||
:src="api.thumbnailUrl(file.thumbnailUrl)"
|
||||
class="file-thumb"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
<span v-else class="type-badge">{{ file.mediaType }}</span>
|
||||
<span>
|
||||
<strong>{{ file.fileName }}</strong>
|
||||
<small>{{ formatSize(file.sizeBytes) }} · {{ formatDate(file.lastWriteTimeUtc) }}</small>
|
||||
<small>
|
||||
{{ formatSize(file.sizeBytes) }}
|
||||
<template v-if="file.videoDuration"> · {{ formatDuration(file.videoDuration) }}</template>
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user