- 新增二维码生成端点,自动检测局域网 IP,前端扫一扫即可打开网站 - 提取 IApiResponse 接口,ServiceRequestBinder 支持强类型请求 DTO 绑定 - FileStream 端点迁移至 AppEndpoints 统一注册,管道支持 FileStreamResponse 原始文件返回 - 文件库端点全面使用 MapGet<TService, TRequest> 泛型注册 - 移除 Avalonia-API/Extensions 中的业务端点文件,统一由 Services 层管理
460 lines
15 KiB
Vue
460 lines
15 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { api, type DirectoryDto, type DriveDto, type FileRecordDto, type LibraryRootDto, type MediaType, type TextPreviewDto } from './api'
|
||
|
||
const isAdminPage = computed(() => window.location.pathname.toLowerCase().startsWith('/admin'))
|
||
const roots = ref<LibraryRootDto[]>([])
|
||
const drives = ref<DriveDto[]>([])
|
||
const directories = ref<DirectoryDto[]>([])
|
||
const files = ref<FileRecordDto[]>([])
|
||
const selectedFile = ref<FileRecordDto | null>(null)
|
||
const textPreview = ref<TextPreviewDto | null>(null)
|
||
const currentPath = ref('')
|
||
const manualPath = ref('')
|
||
const keyword = ref('')
|
||
const mediaType = ref<MediaType>('all')
|
||
const rootId = ref<number | undefined>()
|
||
const isBrowsingRoots = ref(true)
|
||
const page = ref(1)
|
||
const pageSize = 24
|
||
const total = ref(0)
|
||
const loading = ref(false)
|
||
const scanningId = ref<number | null>(null)
|
||
const errorMessage = ref('')
|
||
const showQrCode = ref(false)
|
||
const qrCodeData = ref<{ url: string; qrCodeBase64: string } | null>(null)
|
||
|
||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||
const availableRoots = computed(() => roots.value.filter((root) => root.isAvailable))
|
||
const activeRoots = computed(() => roots.value.filter((root) => root.isEnabled && root.isAvailable))
|
||
const selectedRoot = computed(() => roots.value.find((root) => root.id === selectedFile.value?.libraryRootId))
|
||
const totalRootFiles = computed(() => roots.value.reduce((sum, root) => sum + root.fileCount, 0))
|
||
const selectedMediaUrl = computed(() => selectedFile.value ? api.mediaUrl(selectedFile.value.streamUrl) : '')
|
||
const clientTitle = computed(() => {
|
||
if (isBrowsingRoots.value) return '文件库'
|
||
return rootId.value ? roots.value.find((root) => root.id === rootId.value)?.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 loadDrives() {
|
||
drives.value = await api.getDrives()
|
||
}
|
||
|
||
async function openDirectory(path: string) {
|
||
try {
|
||
errorMessage.value = ''
|
||
currentPath.value = path
|
||
manualPath.value = path
|
||
directories.value = await api.getDirectories(path)
|
||
} catch (error) {
|
||
setError(error)
|
||
}
|
||
}
|
||
|
||
async function addRoot(path = manualPath.value) {
|
||
try {
|
||
errorMessage.value = ''
|
||
loading.value = true
|
||
await api.addRoot({ path, scanIntervalMinutes: 5 })
|
||
await Promise.all([loadRoots(), loadFiles()])
|
||
} catch (error) {
|
||
setError(error)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function toggleRoot(root: LibraryRootDto) {
|
||
try {
|
||
errorMessage.value = ''
|
||
await api.setRootEnabled(root.id, !root.isEnabled)
|
||
await loadRoots()
|
||
} catch (error) {
|
||
setError(error)
|
||
}
|
||
}
|
||
|
||
async function deleteRoot(root: LibraryRootDto) {
|
||
try {
|
||
errorMessage.value = ''
|
||
await api.deleteRoot(root.id)
|
||
if (rootId.value === root.id) rootId.value = undefined
|
||
await Promise.all([loadRoots(), loadFiles()])
|
||
} catch (error) {
|
||
setError(error)
|
||
}
|
||
}
|
||
|
||
async function scanRoot(root: LibraryRootDto) {
|
||
try {
|
||
errorMessage.value = ''
|
||
scanningId.value = root.id
|
||
await api.scanRoot(root.id)
|
||
await Promise.all([loadRoots(), loadFiles()])
|
||
} catch (error) {
|
||
setError(error)
|
||
} finally {
|
||
scanningId.value = null
|
||
}
|
||
}
|
||
|
||
async function loadFiles(resetPage = false) {
|
||
try {
|
||
errorMessage.value = ''
|
||
if (resetPage) page.value = 1
|
||
const result = await api.searchFiles({
|
||
page: page.value,
|
||
pageSize,
|
||
mediaType: mediaType.value,
|
||
keyword: keyword.value,
|
||
rootId: rootId.value,
|
||
})
|
||
files.value = result.items
|
||
total.value = result.total
|
||
if (!selectedFile.value || !files.value.some((file) => file.id === selectedFile.value?.id)) {
|
||
selectedFile.value = null
|
||
textPreview.value = null
|
||
}
|
||
} catch (error) {
|
||
setError(error)
|
||
}
|
||
}
|
||
|
||
async function openClientRoot(id: number) {
|
||
rootId.value = id
|
||
isBrowsingRoots.value = false
|
||
await loadFiles(true)
|
||
}
|
||
|
||
function backToRoots() {
|
||
isBrowsingRoots.value = true
|
||
rootId.value = undefined
|
||
selectedFile.value = null
|
||
textPreview.value = null
|
||
}
|
||
|
||
async function selectFile(file: FileRecordDto | null) {
|
||
selectedFile.value = file
|
||
textPreview.value = null
|
||
if (!file || file.mediaType !== 'text') return
|
||
|
||
try {
|
||
textPreview.value = await api.getTextPreview(file.id)
|
||
} catch (error) {
|
||
setError(error)
|
||
}
|
||
}
|
||
|
||
async function changePage(next: number) {
|
||
page.value = Math.min(Math.max(1, next), totalPages.value)
|
||
await loadFiles()
|
||
}
|
||
|
||
async function refreshAll() {
|
||
await Promise.all([loadRoots(), loadFiles()])
|
||
}
|
||
|
||
async function loadQrCode() {
|
||
try {
|
||
qrCodeData.value = await api.qrCode()
|
||
showQrCode.value = true
|
||
} catch (error) {
|
||
setError(error)
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
loading.value = true
|
||
try {
|
||
await loadRoots()
|
||
if (isAdminPage.value) {
|
||
await loadDrives()
|
||
} else {
|
||
total.value = activeRoots.value.reduce((sum, root) => sum + root.fileCount, 0)
|
||
}
|
||
} catch (error) {
|
||
setError(error)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<main v-if="isAdminPage" class="admin-shell">
|
||
<header class="admin-hero">
|
||
<div>
|
||
<p class="eyebrow">FileShare Admin</p>
|
||
<h1>文件库管理</h1>
|
||
<p>添加服务器本机磁盘或目录,系统按状态定时扫描,异常目录会自动下线并停止自动扫描。</p>
|
||
</div>
|
||
<div class="admin-metrics">
|
||
<div>
|
||
<strong>{{ roots.length }}</strong>
|
||
<span>目录</span>
|
||
</div>
|
||
<div>
|
||
<strong>{{ activeRoots.length }}</strong>
|
||
<span>正常启用</span>
|
||
</div>
|
||
<div>
|
||
<strong>{{ totalRootFiles }}</strong>
|
||
<span>入库文件</span>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
|
||
|
||
<section class="admin-layout">
|
||
<section class="admin-card path-card">
|
||
<div class="card-heading">
|
||
<div>
|
||
<h2>添加扫描目录</h2>
|
||
<p>选择服务器路径,或直接输入绝对路径。</p>
|
||
</div>
|
||
<a href="/" class="client-link">客户端</a>
|
||
</div>
|
||
|
||
<label class="field">
|
||
<span>服务器路径</span>
|
||
<div class="inline-form">
|
||
<input v-model="manualPath" type="text" placeholder="例如 D:\Media 或 E:\" />
|
||
<button class="primary-button" type="button" :disabled="loading || !manualPath" @click="addRoot()">添加并扫描</button>
|
||
</div>
|
||
</label>
|
||
|
||
<div class="admin-browser">
|
||
<div class="drive-list">
|
||
<h3>磁盘</h3>
|
||
<button
|
||
v-for="drive in drives"
|
||
:key="drive.name"
|
||
class="drive-row"
|
||
type="button"
|
||
:disabled="!drive.isReady"
|
||
@click="openDirectory(drive.rootDirectory)"
|
||
>
|
||
<span>{{ drive.displayName }}</span>
|
||
<small>{{ drive.driveType }} · {{ drive.availableFreeSpace !== null ? formatSize(drive.availableFreeSpace) : '不可用' }}</small>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="directory-list">
|
||
<div class="browser-header">
|
||
<h3>目录</h3>
|
||
<button type="button" class="text-button" :disabled="!currentPath" @click="addRoot(currentPath)">添加当前目录</button>
|
||
</div>
|
||
<p class="current-path">{{ currentPath || '请选择一个磁盘' }}</p>
|
||
<button
|
||
v-for="directory in directories"
|
||
:key="directory.fullPath"
|
||
class="directory-row"
|
||
type="button"
|
||
@click="openDirectory(directory.fullPath)"
|
||
>
|
||
{{ directory.name }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="admin-card roots-card">
|
||
<div class="card-heading">
|
||
<div>
|
||
<h2>目录状态</h2>
|
||
<p>异常目录不会自动扫描,客户端也无法访问其中的文件;手动扫描成功后恢复正常。</p>
|
||
</div>
|
||
<button class="secondary-button" type="button" :disabled="loading" @click="refreshAll">刷新</button>
|
||
</div>
|
||
|
||
<div class="root-table">
|
||
<article v-for="root in roots" :key="root.id" class="root-item">
|
||
<div class="root-main">
|
||
<span :class="['status-pill', root.isAvailable ? 'ok' : 'bad']">{{ root.isAvailable ? '正常' : '异常' }}</span>
|
||
<div>
|
||
<strong>{{ root.displayName }}</strong>
|
||
<p>{{ root.path }}</p>
|
||
</div>
|
||
</div>
|
||
<div class="root-meta">
|
||
<span>{{ root.fileCount }} 个文件</span>
|
||
<span>{{ formatDate(root.lastScanCompletedAt) }}</span>
|
||
<span>{{ root.isEnabled ? '启用' : '停用' }}</span>
|
||
</div>
|
||
<p v-if="root.lastScanError" class="root-error">{{ root.lastScanError }}</p>
|
||
<div class="root-actions">
|
||
<button type="button" class="secondary-button" @click="scanRoot(root)">
|
||
{{ scanningId === root.id ? '扫描中' : root.isAvailable ? '立即扫描' : '手动扫描恢复' }}
|
||
</button>
|
||
<button type="button" class="secondary-button" :disabled="!root.isAvailable" @click="toggleRoot(root)">
|
||
{{ root.isEnabled ? '停用' : '启用' }}
|
||
</button>
|
||
<button type="button" class="danger-button" @click="deleteRoot(root)">删除</button>
|
||
</div>
|
||
</article>
|
||
<p v-if="roots.length === 0" class="empty-state">还没有添加扫描目录</p>
|
||
</div>
|
||
</section>
|
||
</section>
|
||
</main>
|
||
|
||
<main v-else class="client-shell">
|
||
<header class="mobile-header">
|
||
<div>
|
||
<h1>{{ clientTitle }}</h1>
|
||
<p>{{ isBrowsingRoots ? `${activeRoots.length} 个目录` : `${total} 个文件` }}</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="loadQrCode">二维码</button>
|
||
<a href="/admin" class="admin-link">管理</a>
|
||
</div>
|
||
</header>
|
||
|
||
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
|
||
|
||
<section v-if="isBrowsingRoots" class="root-picker">
|
||
<button
|
||
v-for="root in activeRoots"
|
||
:key="root.id"
|
||
class="root-tile"
|
||
type="button"
|
||
@click="openClientRoot(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>
|
||
|
||
<section v-else class="mobile-filters">
|
||
<input v-model="keyword" type="search" placeholder="搜索文件" @keyup.enter="loadFiles(true)" />
|
||
<div class="filter-row">
|
||
<select v-model="mediaType" @change="loadFiles(true)">
|
||
<option value="all">全部</option>
|
||
<option value="video">视频</option>
|
||
<option value="audio">音频</option>
|
||
<option value="text">文本</option>
|
||
</select>
|
||
<select v-model="rootId" @change="loadFiles(true)">
|
||
<option v-for="root in availableRoots" :key="root.id" :value="root.id">{{ root.displayName }}</option>
|
||
</select>
|
||
<button class="primary-button" type="button" @click="loadFiles(true)">查询</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="!isBrowsingRoots" class="player-panel">
|
||
<p v-if="!selectedFile" class="empty-state">请选择一个文件</p>
|
||
|
||
<template v-else>
|
||
<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>
|
||
</template>
|
||
</section>
|
||
|
||
<section v-if="!isBrowsingRoots" class="mobile-list">
|
||
<button
|
||
v-for="file in 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>{{ file.relativePath }}</small>
|
||
<small>
|
||
{{ formatSize(file.sizeBytes) }} · {{ formatDate(file.lastWriteTimeUtc) }}
|
||
<template v-if="file.mediaType !== 'text' && !file.browserPlayable"> · 手机可能不支持</template>
|
||
</small>
|
||
</span>
|
||
</button>
|
||
|
||
<p v-if="files.length === 0" class="empty-state">暂无可查看文件</p>
|
||
</section>
|
||
|
||
<nav v-if="!isBrowsingRoots" class="mobile-pager">
|
||
<button type="button" :disabled="page <= 1" @click="changePage(page - 1)">上一页</button>
|
||
<span>{{ page }} / {{ totalPages }}</span>
|
||
<button type="button" :disabled="page >= totalPages" @click="changePage(page + 1)">下一页</button>
|
||
</nav>
|
||
|
||
<Teleport to="body">
|
||
<div v-if="showQrCode" class="qr-overlay" @click.self="showQrCode = false">
|
||
<div class="qr-modal">
|
||
<h2>扫码访问</h2>
|
||
<img v-if="qrCodeData" :src="qrCodeData.qrCodeBase64" alt="QR Code" class="qr-image" />
|
||
<p v-else class="qr-hint">加载中...</p>
|
||
<p class="qr-hint">使用手机扫描二维码,即可在局域网中打开此网站</p>
|
||
<button type="button" class="primary-button qr-close" @click="showQrCode = false">关闭</button>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
</main>
|
||
</template>
|