feat: 前端组件拆分与文件库目录浏览功能
- 将 App.vue 拆分为 AdminPage、ClientPage、QrCodeModal 三个独立组件 - 新增 BrowseDirectory 接口,基于 RelativePath 实现层级目录浏览 - 前端新增面包屑导航、文件夹网格、文件列表等目录浏览 UI - 新增对应 CSS 样式(breadcrumb、folder-grid、file-list 等) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
d84bbb3a18
commit
8270cf198b
@ -65,6 +65,10 @@ namespace Avalonia_Services.Endpoints
|
|||||||
.WithOpenApi("FileLibrary", "分页查询已扫描文件。")
|
.WithOpenApi("FileLibrary", "分页查询已扫描文件。")
|
||||||
.WithName("SearchFiles");
|
.WithName("SearchFiles");
|
||||||
|
|
||||||
|
endpoints.MapGet<IFileLibraryEndpointService, BrowseDirectoryRequest>("api/files/browse", (service, request, _) => service.BrowseDirectoryAsync(request))
|
||||||
|
.WithOpenApi("FileLibrary", "浏览文件库目录结构。")
|
||||||
|
.WithName("BrowseDirectory");
|
||||||
|
|
||||||
endpoints.MapGet<IFileLibraryEndpointService, FileQueryRequest>("api/files/detail", (service, request, _) => service.GetFileAsync(request))
|
endpoints.MapGet<IFileLibraryEndpointService, FileQueryRequest>("api/files/detail", (service, request, _) => service.GetFileAsync(request))
|
||||||
.WithOpenApi("FileLibrary", "查询文件详情。")
|
.WithOpenApi("FileLibrary", "查询文件详情。")
|
||||||
.WithName("GetFileDetail");
|
.WithName("GetFileDetail");
|
||||||
|
|||||||
@ -69,6 +69,15 @@ namespace Avalonia_Services.Services.FileLibrary
|
|||||||
string? TextUrl,
|
string? TextUrl,
|
||||||
bool BrowserPlayable);
|
bool BrowserPlayable);
|
||||||
|
|
||||||
|
public sealed record BrowseDirectoryRequest(
|
||||||
|
[property: JsonPropertyName("rootId")] int RootId = 0,
|
||||||
|
[property: JsonPropertyName("path")] string? Path = null);
|
||||||
|
|
||||||
|
public sealed record BrowseDirectoryResponse(
|
||||||
|
string CurrentPath,
|
||||||
|
List<string> Subdirectories,
|
||||||
|
List<FileRecordDto> Files);
|
||||||
|
|
||||||
public sealed record TextPreviewDto(
|
public sealed record TextPreviewDto(
|
||||||
int Id,
|
int Id,
|
||||||
string FileName,
|
string FileName,
|
||||||
|
|||||||
@ -64,6 +64,15 @@ namespace Avalonia_Services.Services.FileLibrary
|
|||||||
: ResponseHelper.Ok(preview);
|
: ResponseHelper.Ok(preview);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IApiResponse> BrowseDirectoryAsync(BrowseDirectoryRequest request)
|
||||||
|
{
|
||||||
|
if (request.RootId <= 0)
|
||||||
|
return ResponseHelper.Failure(400, "rootId 参数无效。");
|
||||||
|
|
||||||
|
var result = await fileLibrary.BrowseDirectoryAsync(request);
|
||||||
|
return ResponseHelper.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
private static void ValidateFileId(int id)
|
private static void ValidateFileId(int id)
|
||||||
{
|
{
|
||||||
if (id > 0)
|
if (id > 0)
|
||||||
|
|||||||
@ -261,6 +261,54 @@ namespace Avalonia_Services.Services.FileLibrary
|
|||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<BrowseDirectoryResponse> BrowseDirectoryAsync(BrowseDirectoryRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var rootId = request.RootId;
|
||||||
|
// URL 友好的正斜杠,用于响应和内存处理
|
||||||
|
var prefix = (request.Path ?? "").Trim().Replace('\\', '/').Trim('/');
|
||||||
|
// Windows 反斜杠,用于数据库查询
|
||||||
|
var dbPrefix = prefix.Replace('/', '\\');
|
||||||
|
|
||||||
|
var query = db.ManagedFileRecords
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(f => f.LibraryRootId == rootId && f.Exists
|
||||||
|
&& f.LibraryRoot != null && f.LibraryRoot.IsAvailable);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(dbPrefix))
|
||||||
|
{
|
||||||
|
var dbPrefixWithSlash = dbPrefix + "\\";
|
||||||
|
query = query.Where(f => f.RelativePath.StartsWith(dbPrefixWithSlash));
|
||||||
|
}
|
||||||
|
|
||||||
|
var allFiles = await query.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var subdirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var currentFiles = new List<FileRecordDto>();
|
||||||
|
|
||||||
|
foreach (var file in allFiles)
|
||||||
|
{
|
||||||
|
var relativePath = file.RelativePath.Replace('\\', '/');
|
||||||
|
var remaining = string.IsNullOrEmpty(prefix)
|
||||||
|
? relativePath
|
||||||
|
: relativePath[(prefix.Length + 1)..];
|
||||||
|
|
||||||
|
var slashIndex = remaining.IndexOf('/');
|
||||||
|
if (slashIndex < 0)
|
||||||
|
{
|
||||||
|
currentFiles.Add(ToFileDto(file));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
subdirs.Add(remaining[..slashIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BrowseDirectoryResponse(
|
||||||
|
prefix,
|
||||||
|
subdirs.OrderBy(d => d, StringComparer.OrdinalIgnoreCase).ToList(),
|
||||||
|
currentFiles);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<TextPreviewDto?> GetTextPreviewAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<TextPreviewDto?> GetTextPreviewAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var file = await db.ManagedFileRecords
|
var file = await db.ManagedFileRecords
|
||||||
|
|||||||
@ -24,5 +24,7 @@ namespace Avalonia_Services.Services.FileLibrary
|
|||||||
Task<IApiResponse> GetFileAsync(FileQueryRequest request);
|
Task<IApiResponse> GetFileAsync(FileQueryRequest request);
|
||||||
|
|
||||||
Task<IApiResponse> GetTextPreviewAsync(FileQueryRequest request);
|
Task<IApiResponse> GetTextPreviewAsync(FileQueryRequest request);
|
||||||
|
|
||||||
|
Task<IApiResponse> BrowseDirectoryAsync(BrowseDirectoryRequest request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,5 +25,7 @@ namespace Avalonia_Services.Services.FileLibrary
|
|||||||
Task<FileRecordDto?> GetFileAsync(int id, CancellationToken cancellationToken = default);
|
Task<FileRecordDto?> GetFileAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<TextPreviewDto?> GetTextPreviewAsync(int id, CancellationToken cancellationToken = default);
|
Task<TextPreviewDto?> GetTextPreviewAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<BrowseDirectoryResponse> BrowseDirectoryAsync(BrowseDirectoryRequest request, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,459 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { api, type DirectoryDto, type DriveDto, type FileRecordDto, type LibraryRootDto, type MediaType, type TextPreviewDto } from './api'
|
import AdminPage from './components/AdminPage.vue'
|
||||||
|
import ClientPage from './components/ClientPage.vue'
|
||||||
|
|
||||||
const isAdminPage = computed(() => window.location.pathname.toLowerCase().startsWith('/admin'))
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main v-if="isAdminPage" class="admin-shell">
|
<AdminPage v-if="isAdminPage" />
|
||||||
<header class="admin-hero">
|
<ClientPage v-else />
|
||||||
<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>
|
</template>
|
||||||
|
|||||||
@ -45,6 +45,12 @@ export interface FileRecordDto {
|
|||||||
browserPlayable: boolean
|
browserPlayable: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BrowseDirectoryResponse {
|
||||||
|
currentPath: string
|
||||||
|
subdirectories: string[]
|
||||||
|
files: FileRecordDto[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface TextPreviewDto {
|
export interface TextPreviewDto {
|
||||||
id: number
|
id: number
|
||||||
fileName: string
|
fileName: string
|
||||||
@ -87,6 +93,8 @@ export const api = {
|
|||||||
request<LibraryRootDto>('library/roots/scan', { method: 'POST', body: { id } }),
|
request<LibraryRootDto>('library/roots/scan', { method: 'POST', body: { id } }),
|
||||||
searchFiles: (params: { page: number; pageSize: number; mediaType?: MediaType; keyword?: string; rootId?: number }) =>
|
searchFiles: (params: { page: number; pageSize: number; mediaType?: MediaType; keyword?: string; rootId?: number }) =>
|
||||||
request<PagedResponse<FileRecordDto>>(`files${qs(params)}`),
|
request<PagedResponse<FileRecordDto>>(`files${qs(params)}`),
|
||||||
|
browseDirectory: (rootId: number, path: string) =>
|
||||||
|
request<BrowseDirectoryResponse>(`files/browse${qs({ rootId, path })}`),
|
||||||
getTextPreview: (id: number) =>
|
getTextPreview: (id: number) =>
|
||||||
request<TextPreviewDto>(`files/text${qs({ id })}`),
|
request<TextPreviewDto>(`files/text${qs({ id })}`),
|
||||||
mediaUrl: (path: string) => apiUrl(path),
|
mediaUrl: (path: string) => apiUrl(path),
|
||||||
|
|||||||
@ -648,6 +648,82 @@ a {
|
|||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.breadcrumb-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item {
|
||||||
|
min-height: 30px;
|
||||||
|
border: none;
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: transparent;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item:hover:not(:disabled) {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item.active {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-sep {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-content {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-section h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.admin-layout,
|
.admin-layout,
|
||||||
.admin-browser {
|
.admin-browser {
|
||||||
|
|||||||
239
Avalonia-Web-VUE/src/components/AdminPage.vue
Normal file
239
Avalonia-Web-VUE/src/components/AdminPage.vue
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { api, type DirectoryDto, type DriveDto, type LibraryRootDto } from '../api'
|
||||||
|
|
||||||
|
const roots = ref<LibraryRootDto[]>([])
|
||||||
|
const drives = ref<DriveDto[]>([])
|
||||||
|
const directories = ref<DirectoryDto[]>([])
|
||||||
|
const currentPath = ref('')
|
||||||
|
const manualPath = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const scanningId = ref<number | null>(null)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
const activeRoots = ref<LibraryRootDto[]>([])
|
||||||
|
const totalRootFiles = ref(0)
|
||||||
|
|
||||||
|
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 refreshAll() {
|
||||||
|
await Promise.all([loadRoots(), drives.value.length > 0 ? Promise.resolve() : loadDrives()])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRoots() {
|
||||||
|
roots.value = await api.getRoots()
|
||||||
|
activeRoots.value = roots.value.filter((root) => root.isEnabled && root.isAvailable)
|
||||||
|
totalRootFiles.value = roots.value.reduce((sum, root) => sum + root.fileCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 loadRoots()
|
||||||
|
} 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)
|
||||||
|
await loadRoots()
|
||||||
|
} catch (error) {
|
||||||
|
setError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanRoot(root: LibraryRootDto) {
|
||||||
|
try {
|
||||||
|
errorMessage.value = ''
|
||||||
|
scanningId.value = root.id
|
||||||
|
await api.scanRoot(root.id)
|
||||||
|
await loadRoots()
|
||||||
|
} catch (error) {
|
||||||
|
setError(error)
|
||||||
|
} finally {
|
||||||
|
scanningId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await Promise.all([loadRoots(), loadDrives()])
|
||||||
|
} catch (error) {
|
||||||
|
setError(error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main 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>
|
||||||
|
</template>
|
||||||
288
Avalonia-Web-VUE/src/components/ClientPage.vue
Normal file
288
Avalonia-Web-VUE/src/components/ClientPage.vue
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
<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>
|
||||||
39
Avalonia-Web-VUE/src/components/QrCodeModal.vue
Normal file
39
Avalonia-Web-VUE/src/components/QrCodeModal.vue
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const qrCodeData = ref<{ url: string; qrCodeBase64: string } | null>(null)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function open() {
|
||||||
|
try {
|
||||||
|
error.value = ''
|
||||||
|
qrCodeData.value = await api.qrCode()
|
||||||
|
visible.value = true
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '加载失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible" class="qr-overlay" @click.self="close">
|
||||||
|
<div class="qr-modal">
|
||||||
|
<h2>扫码访问</h2>
|
||||||
|
<p v-if="error" class="error-banner">{{ error }}</p>
|
||||||
|
<img v-if="qrCodeData" :src="qrCodeData.qrCodeBase64" alt="QR Code" class="qr-image" />
|
||||||
|
<p v-else-if="!error" class="qr-hint">加载中...</p>
|
||||||
|
<p class="qr-hint">使用手机扫描二维码,即可在局域网中打开此网站</p>
|
||||||
|
<button type="button" class="primary-button qr-close" @click="close">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
Loading…
x
Reference in New Issue
Block a user