luoqian d84bbb3a18 feat: 二维码访问功能,统一端点管道增强,端点迁移至 Services 层
- 新增二维码生成端点,自动检测局域网 IP,前端扫一扫即可打开网站
  - 提取 IApiResponse 接口,ServiceRequestBinder 支持强类型请求 DTO 绑定
  - FileStream 端点迁移至 AppEndpoints 统一注册,管道支持 FileStreamResponse 原始文件返回
  - 文件库端点全面使用 MapGet<TService, TRequest> 泛型注册
  - 移除 Avalonia-API/Extensions 中的业务端点文件,统一由 Services 层管理
2026-05-22 11:18:47 +08:00

460 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>