- 将 App.vue 拆分为 AdminPage、ClientPage、QrCodeModal 三个独立组件 - 新增 BrowseDirectory 接口,基于 RelativePath 实现层级目录浏览 - 前端新增面包屑导航、文件夹网格、文件列表等目录浏览 UI - 新增对应 CSS 样式(breadcrumb、folder-grid、file-list 等) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
289 lines
8.9 KiB
Vue
289 lines
8.9 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { api, type BrowseDirectoryResponse, type FileRecordDto, type LibraryRootDto, type TextPreviewDto } from '../api'
|
||
import QrCodeModal from './QrCodeModal.vue'
|
||
|
||
const roots = ref<LibraryRootDto[]>([])
|
||
const browseData = ref<BrowseDirectoryResponse | null>(null)
|
||
const selectedFile = ref<FileRecordDto | null>(null)
|
||
const textPreview = ref<TextPreviewDto | null>(null)
|
||
const loading = ref(false)
|
||
const errorMessage = ref('')
|
||
|
||
// Navigation state
|
||
const rootId = ref<number | undefined>()
|
||
const browsePath = ref<string[]>([])
|
||
const isBrowsingRoots = ref(true)
|
||
|
||
const qrModal = ref<InstanceType<typeof QrCodeModal> | null>(null)
|
||
|
||
const activeRoots = computed(() => roots.value.filter((root) => root.isEnabled && root.isAvailable))
|
||
const selectedRoot = computed(() => roots.value.find((root) => root.id === selectedFile.value?.libraryRootId))
|
||
const currentBrowsePath = computed(() => browsePath.value.join('/'))
|
||
|
||
const breadcrumbs = computed(() => {
|
||
const root = roots.value.find((r) => r.id === rootId.value)
|
||
const items = [{ label: root?.displayName ?? '文件库', path: '' }]
|
||
for (let i = 0; i < browsePath.value.length; i++) {
|
||
items.push({
|
||
label: browsePath.value[i],
|
||
path: browsePath.value.slice(0, i + 1).join('/'),
|
||
})
|
||
}
|
||
return items
|
||
})
|
||
|
||
const selectedMediaUrl = computed(() => selectedFile.value ? api.mediaUrl(selectedFile.value.streamUrl) : '')
|
||
|
||
const clientTitle = computed(() => {
|
||
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] : ''
|
||
return dir || (root?.displayName ?? '文件')
|
||
})
|
||
|
||
function formatSize(bytes: number) {
|
||
if (bytes < 1024) return `${bytes} B`
|
||
const units = ['KB', 'MB', 'GB', 'TB']
|
||
let value = bytes / 1024
|
||
let index = 0
|
||
while (value >= 1024 && index < units.length - 1) {
|
||
value /= 1024
|
||
index += 1
|
||
}
|
||
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[index]}`
|
||
}
|
||
|
||
function formatDate(value: string | null) {
|
||
if (!value) return ''
|
||
return new Date(value).toLocaleString()
|
||
}
|
||
|
||
function setError(error: unknown) {
|
||
errorMessage.value = error instanceof Error ? error.message : '操作失败'
|
||
}
|
||
|
||
async function loadRoots() {
|
||
roots.value = await api.getRoots()
|
||
}
|
||
|
||
async function browseDirectory() {
|
||
if (!rootId.value) return
|
||
try {
|
||
errorMessage.value = ''
|
||
loading.value = true
|
||
browseData.value = await api.browseDirectory(rootId.value, currentBrowsePath.value)
|
||
} catch (error) {
|
||
setError(error)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function enterRoot(id: number) {
|
||
rootId.value = id
|
||
isBrowsingRoots.value = false
|
||
browsePath.value = []
|
||
selectedFile.value = null
|
||
textPreview.value = null
|
||
await browseDirectory()
|
||
}
|
||
|
||
function backToRoots() {
|
||
isBrowsingRoots.value = true
|
||
rootId.value = undefined
|
||
browseData.value = null
|
||
browsePath.value = []
|
||
selectedFile.value = null
|
||
textPreview.value = null
|
||
}
|
||
|
||
async function navigateTo(path: string) {
|
||
if (path === '') {
|
||
browsePath.value = []
|
||
} else {
|
||
browsePath.value = path.split('/')
|
||
}
|
||
selectedFile.value = null
|
||
textPreview.value = null
|
||
await browseDirectory()
|
||
}
|
||
|
||
async function enterSubdirectory(name: string) {
|
||
browsePath.value.push(name)
|
||
selectedFile.value = null
|
||
textPreview.value = null
|
||
await browseDirectory()
|
||
}
|
||
|
||
async function selectFile(file: FileRecordDto) {
|
||
selectedFile.value = file
|
||
textPreview.value = null
|
||
if (file.mediaType !== 'text') return
|
||
|
||
try {
|
||
textPreview.value = await api.getTextPreview(file.id)
|
||
} catch (error) {
|
||
setError(error)
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
loading.value = true
|
||
try {
|
||
await loadRoots()
|
||
} catch (error) {
|
||
setError(error)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<main class="client-shell">
|
||
<header class="mobile-header">
|
||
<div>
|
||
<h1>{{ clientTitle }}</h1>
|
||
<p>
|
||
<template v-if="isBrowsingRoots">{{ activeRoots.length }} 个目录</template>
|
||
<template v-else-if="browseData">
|
||
{{ browseData.subdirectories.length }} 个文件夹 · {{ browseData.files.length }} 个文件
|
||
</template>
|
||
</p>
|
||
</div>
|
||
<div class="mobile-header-actions">
|
||
<button v-if="!isBrowsingRoots" type="button" class="back-button" @click="backToRoots">返回</button>
|
||
<button type="button" class="qr-button" title="生成二维码" @click="qrModal?.open()">二维码</button>
|
||
<a href="/admin" class="admin-link">管理</a>
|
||
</div>
|
||
</header>
|
||
|
||
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
|
||
|
||
<!-- Library root tiles -->
|
||
<section v-if="isBrowsingRoots" class="root-picker">
|
||
<button
|
||
v-for="root in activeRoots"
|
||
:key="root.id"
|
||
class="root-tile"
|
||
type="button"
|
||
@click="enterRoot(root.id)"
|
||
>
|
||
<span>{{ root.displayName }}</span>
|
||
<strong>{{ root.fileCount }}</strong>
|
||
<small>{{ root.path }}</small>
|
||
</button>
|
||
<p v-if="activeRoots.length === 0" class="empty-state">暂无可访问目录</p>
|
||
</section>
|
||
|
||
<!-- Directory browser -->
|
||
<template v-else>
|
||
<!-- Breadcrumb -->
|
||
<nav class="breadcrumb-nav">
|
||
<template v-for="(crumb, index) in breadcrumbs" :key="crumb.path">
|
||
<span v-if="index > 0" class="breadcrumb-sep">/</span>
|
||
<button
|
||
type="button"
|
||
class="breadcrumb-item"
|
||
:class="{ active: index === breadcrumbs.length - 1 }"
|
||
@click="navigateTo(crumb.path)"
|
||
>
|
||
{{ crumb.label }}
|
||
</button>
|
||
</template>
|
||
</nav>
|
||
|
||
<section v-if="browseData" class="browse-content">
|
||
<!-- Subdirectories -->
|
||
<section v-if="browseData.subdirectories.length > 0" class="browse-section">
|
||
<h3>文件夹</h3>
|
||
<div class="folder-grid">
|
||
<button
|
||
v-for="dir in browseData.subdirectories"
|
||
:key="dir"
|
||
type="button"
|
||
class="folder-item"
|
||
@click="enterSubdirectory(dir)"
|
||
>
|
||
<span class="folder-icon">📁</span>
|
||
<span>{{ dir }}</span>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Media player -->
|
||
<section v-if="selectedFile" class="player-panel">
|
||
<div class="player-title">
|
||
<div>
|
||
<h2>{{ selectedFile.fileName }}</h2>
|
||
<p>{{ selectedRoot?.displayName ?? '文件库' }} · {{ selectedFile.relativePath }}</p>
|
||
</div>
|
||
<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>
|
||
<a
|
||
v-if="selectedFile.mediaType !== 'text'"
|
||
class="open-media-link"
|
||
:href="selectedMediaUrl"
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>
|
||
新窗口打开原视频/音频
|
||
</a>
|
||
<p v-if="textPreview?.truncated" class="hint">文本超过 1 MB,已截断显示。</p>
|
||
</section>
|
||
|
||
<!-- Files -->
|
||
<section v-if="browseData.files.length > 0" class="browse-section">
|
||
<h3>文件</h3>
|
||
<div class="file-list">
|
||
<button
|
||
v-for="file in browseData.files"
|
||
:key="file.id"
|
||
class="mobile-file"
|
||
:class="{ active: selectedFile?.id === file.id }"
|
||
type="button"
|
||
@click="selectFile(file)"
|
||
>
|
||
<span class="type-badge">{{ file.mediaType }}</span>
|
||
<span>
|
||
<strong>{{ file.fileName }}</strong>
|
||
<small>{{ formatSize(file.sizeBytes) }} · {{ formatDate(file.lastWriteTimeUtc) }}</small>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<p v-if="browseData.subdirectories.length === 0 && browseData.files.length === 0" class="empty-state">
|
||
此目录下没有支持的文件
|
||
</p>
|
||
</section>
|
||
|
||
<p v-else-if="!loading" class="empty-state">加载中...</p>
|
||
</template>
|
||
</main>
|
||
|
||
<QrCodeModal ref="qrModal" />
|
||
</template>
|